From 39c7ad95c2a49bb43cc9c7ffa503fa717e283967 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 8 Jul 2026 22:55:47 +1000 Subject: [PATCH] fix(common): keep an operator allocate rule after an agent close After the agent closes an allocation it always wrote a never rule, overwriting a rule the operator changed while the close sat in the queue and silently opting the deployment out of DIPs proposals. The stamp now checks the current rule and skips when it says allocate. --- .../__tests__/rule-preservation.test.ts | 75 +++++++++++++++++++ .../src/indexer-management/allocations.ts | 56 +++++++++++--- 2 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 packages/indexer-common/src/indexer-management/__tests__/rule-preservation.test.ts diff --git a/packages/indexer-common/src/indexer-management/__tests__/rule-preservation.test.ts b/packages/indexer-common/src/indexer-management/__tests__/rule-preservation.test.ts new file mode 100644 index 000000000..ab122439e --- /dev/null +++ b/packages/indexer-common/src/indexer-management/__tests__/rule-preservation.test.ts @@ -0,0 +1,75 @@ +import { SubgraphDeploymentID } from '@graphprotocol/common-ts' +import { findRuleRequestingAllocation } from '../allocations' +import { IndexingDecisionBasis, IndexingRuleAttributes } from '../models' +import { SubgraphIdentifierType } from '../../subgraphs' + +// Pure-function coverage for the guard that stops confirmUnallocate stamping a +// `never` rule over an operator rule that currently requests allocation. +describe('findRuleRequestingAllocation', () => { + const deployment = new SubgraphDeploymentID( + 'QmXZiV6S13ha6QXq4dmaM3TB4CHcDxBMvGexSNu9Kc28EH', + ) + const otherDeployment = new SubgraphDeploymentID( + 'QmRKs2ZfuwvmZA3QAWmCqrGUjV9pxtBUDP3wuc6iVGnjA2', + ) + + const rule = (overrides: Partial) => + ({ + identifier: deployment.ipfsHash, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + ...overrides, + }) as IndexingRuleAttributes + + it('finds an always rule for the deployment', () => { + const found = findRuleRequestingAllocation([rule({})], deployment) + expect(found?.decisionBasis).toBe(IndexingDecisionBasis.ALWAYS) + }) + + it('finds a dips rule for the deployment', () => { + const found = findRuleRequestingAllocation( + [rule({ decisionBasis: IndexingDecisionBasis.DIPS })], + deployment, + ) + expect(found?.decisionBasis).toBe(IndexingDecisionBasis.DIPS) + }) + + it('matches a rule whose identifier is stored in bytes32 form', () => { + const found = findRuleRequestingAllocation( + [rule({ identifier: deployment.bytes32 })], + deployment, + ) + expect(found).toBeDefined() + }) + + it('ignores opt-out and default bases', () => { + for (const basis of [ + IndexingDecisionBasis.NEVER, + IndexingDecisionBasis.OFFCHAIN, + IndexingDecisionBasis.RULES, + ]) { + expect( + findRuleRequestingAllocation([rule({ decisionBasis: basis })], deployment), + ).toBeUndefined() + } + }) + + it('ignores rules for other deployments', () => { + expect( + findRuleRequestingAllocation( + [rule({ identifier: otherDeployment.ipfsHash })], + deployment, + ), + ).toBeUndefined() + }) + + it('ignores subgraph-type rules and returns undefined when no rules exist', () => { + expect( + findRuleRequestingAllocation( + [rule({ identifierType: SubgraphIdentifierType.SUBGRAPH })], + deployment, + ), + ).toBeUndefined() + expect(findRuleRequestingAllocation([], deployment)).toBeUndefined() + }) +}) diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 7080d1a40..eb7e10ff4 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -140,6 +140,22 @@ export function encodeCollectData(allocationId: string, poiData: POIData): strin return encodeCollectIndexingRewardsData(allocationId, poiData.poi, encodedPOIMetadata) } +// A queued close can outlive the rule that justified it. Find a deployment +// rule that currently asks for allocation (operator `always` or DIPs-managed); +// stamping `never` over it would also blocklist the deployment for DIPs. +export function findRuleRequestingAllocation( + rules: IndexingRuleAttributes[], + deployment: SubgraphDeploymentID, +): IndexingRuleAttributes | undefined { + return rules.find( + (rule) => + rule.identifierType === SubgraphIdentifierType.DEPLOYMENT && + new SubgraphDeploymentID(rule.identifier).bytes32 === deployment.bytes32 && + (rule.decisionBasis === IndexingDecisionBasis.ALWAYS || + rule.decisionBasis === IndexingDecisionBasis.DIPS), + ) +} + export class AllocationManager { declare dipsManager: DipsManager | null constructor( @@ -911,18 +927,38 @@ export class AllocationManager { }) const allocation = await this.network.networkMonitor.allocation(allocationID) - // Upsert a rule so the agent keeps the deployment synced but doesn't allocate to it - logger.debug( - `Updating indexing rules so indexer-agent keeps the deployment synced but doesn't allocate to it`, + // The rule that justified this close may have changed while the action + // sat in the queue: an operator flipping the deployment back to `always` + // (or DIPs taking it over) must not be overwritten with `never`. + const currentRules = await this.models.IndexingRule.findAll({ + where: { protocolNetwork: this.network.specification.networkIdentifier }, + }) + const allocateRule = findRuleRequestingAllocation( + currentRules, + allocation.subgraphDeployment.id, ) - const neverIndexingRule = { - identifier: allocation.subgraphDeployment.id.ipfsHash, - protocolNetwork: this.network.specification.networkIdentifier, - identifierType: SubgraphIdentifierType.DEPLOYMENT, - decisionBasis: IndexingDecisionBasis.NEVER, - } as Partial + if (allocateRule) { + logger.info( + `Keeping the current indexing rule: it requests allocation, so skipping the never-rule stamp after this close`, + { + deployment: allocation.subgraphDeployment.id.ipfsHash, + currentDecisionBasis: allocateRule.decisionBasis, + }, + ) + } else { + // Upsert a rule so the agent keeps the deployment synced but doesn't allocate to it + logger.debug( + `Updating indexing rules so indexer-agent keeps the deployment synced but doesn't allocate to it`, + ) + const neverIndexingRule = { + identifier: allocation.subgraphDeployment.id.ipfsHash, + protocolNetwork: this.network.specification.networkIdentifier, + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.NEVER, + } as Partial - await upsertIndexingRule(logger, this.models, neverIndexingRule) + await upsertIndexingRule(logger, this.models, neverIndexingRule) + } return { actionID,