DAG indexer follows chain-reported finality, leaving ~2.6 days of uploads unservable
Summary
The DAG indexer targets the chain's reported finalized head instead of the chain tip. On Autonomys mainnet that head currently trails best by ~37,600 blocks (~2.6 days), so any file uploaded in that window has no row in "dag-indexer".nodes and GET /files/:cid/metadata returns 404.
The cause is a single boolean in services/dag-indexer/project.ts: historical: false silently forces unfinalizedBlocks to false, discarding the --unfinalized-blocks=true flag we pass on the command line. This has been the case since the indexer was first added (2025-07-30). Nothing was changed or mis-deployed; the wall-clock cost of the misconfiguration grew until it became user-visible.
Note that simply flipping that boolean is not a safe hotfix: SubQuery refuses to run unfinalized-block indexing without historical state, and historical state cannot be turned on for an existing schema. See "Fix options" for the three real routes.
Three separate layers of monitoring report healthy throughout, which is the second half of this issue.
Impact
- Files uploaded within roughly the last 2.6 days cannot be retrieved through the gateway, despite being fully archived on the DSN.
- Auto Drive surfaces this as
500 {"error":"Failed to retrieve data","details":"Error fetching file header: 404 Not Found"}, and because 404 reads as permanent, downstream consumers treat the objects as dead rather than retrying.
- The window is not fixed. It is one finality "jump" wide, and jump duration depends on chain throughput, so it can grow without any change on our side.
Evidence
Measured on auto-drive-gateway, 2026-08-03 ~12:30 UTC.
curl -s http://127.0.0.1:30001/meta:
{
"currentProcessingHeight": 8926444,
"targetHeight": 8926444,
"bestHeight": 8964097,
"indexerNodeVersion": "6.0.4",
"uptime": 17297798
}
targetHeight is 37,653 blocks behind bestHeight, about 2.6 days at 6s blocks.
That targetHeight is exactly the chain's reported finalized head. Queried independently against the public RPC at the same time:
best=8964049 finalized=8926444 gap=37605
Our own node (http://subspace-node:9944, ghcr.io/autonomys/node:mainnet-2026-jan-20) returns the same finalized number as the public endpoint, so this is a consensus-level property, not a local node or pruning quirk.
The indexer is not stalled or under-resourced
It idles at the finalized head, then catches up in a burst when finality jumps. Observed live:
12:16:02 <UnfinalizedBlocks> Effective finalized header updated to: 8963778
12:16:02 Enqueuing blocks 8917625...8917749
...
12:21:16 Target height: 8,926,444. Current height: 8,926,444
12:21:31 Fully synced, waiting for new blocks
- Resumed at 8,917,625, exactly one past the finalized head measured 20 minutes earlier (8,917,624).
- The jump was 8,820 blocks, roughly 14.7 hours of chain time arriving in one step.
- It cleared those 8,820 blocks in about 5 minutes at ~28 blocks/s, then went idle again.
So throughput is not the problem, and --workers=1 is not the problem. Closing the current 37,653-block gap would take about 22 minutes.
Root cause
@subql/node decides its fetch target here (packages/node-core/src/indexer/fetch.service.ts):
private latestHeight(): number {
return this.nodeConfig.unfinalizedBlocks ? this.latestBestHeight : this.latestFinalizedHeight;
}
latestFinalizedHeight comes from raw chain_getFinalizedHead. Per the forum explanation of Subspace finality, Autonomys has probabilistic finality and the reported finalized block is artificially delayed rather than a consistent -100, because Substrate only allows a fixed offset and not custom pruning logic. So the raw value is an unsuitable fetch target, and the unfinalizedBlocks ? latestBestHeight branch is the only sane one.
We pass --unfinalized-blocks=true in docker/dag-indexer/docker-compose.yml, but it never takes effect:
-
services/dag-indexer/project.ts declares historical: false in runner.node.options.
-
rebaseArgsWithManifest (packages/node-core/src/utils/configure.ts) copies most manifest options only when the CLI left them undefined, but handles historical separately and copies it unconditionally, so it overrides our --disable-historical=false:
if (key === 'historical') {
if (value !== undefined) {
// THIS IS OPPOSITE
argvs.historical = value === true ? 'height' : value;
}
return;
}
-
NodeConfig.unfinalizedBlocks then gates on it:
get unfinalizedBlocks(): boolean {
if (this._isTest) return false;
if (this._config.unfinalizedBlocks === false) {
return false;
}
return this.historical !== false; // historical is false, so this returns false
}
Our --unfinalized-blocks=true clears the first gate and is then discarded by the second, with no warning.
-
latestHeight() therefore takes the raw-finality branch.
Note that our subql-node-substrate fork already solves this problem correctly for a different consumer. UnfinalizedBlocksService.updateEffectiveFinalizedHeader() computes bestHeight - finalizedDepth (the Effective finalized header updated to: 8963778 line above, matching --finalized-depth=100), which is exactly what the forum post says finality means on this network. But that value is only used for fork detection and pruning; it never reaches the fetch target. The fork was built to be run with unfinalized-block indexing enabled, and our manifest disables it.
The manifest in use is not the one in git
@subql/node resolves DEFAULT_MANIFEST (project.yaml) for a directory target and never reads project.ts at runtime. project.yaml is gitignored (services/dag-indexer/.gitignore:30) and generated by subql codegen && subql build. The copy on the host is dated 2025-12-02 and its contents match project.ts:
# // Auto-generated , DO NOT EDIT
runner:
node:
options:
unsafe: true
historical: false
unfinalizedBlocks: false
This is worth fixing on its own (see follow-ups): the configuration actually governing a production service cannot currently be reviewed, diffed, or reproduced from this repository, and reading project.ts or the compose command line gives a misleading answer.
Why nothing caught it
Three layers were green the whole time:
| Signal |
Reports |
Docker healthcheck (curl -f /ready) |
Up 6 months (healthy) |
| SubQuery's own logs |
Fully synced, waiting for new blocks |
Proposed /health/dag-indexer in PR #173 |
lagBlocks = targetHeight - lastProcessedHeight = 0, so status: ok, HTTP 200 |
All three are correct on their own terms. The indexer really has processed everything up to its target; the target is what is wrong. targetHeight is also only written to _metadata when fillNextBlockBuffer is about to fetch, so it converges on lastProcessedHeight precisely when fetching stops, making that subtraction structurally incapable of detecting this.
/meta has had targetHeight and bestHeight side by side the entire time. Nothing compared them.
Fix options
The obvious fix (set historical: true in the manifest so unfinalizedBlocks takes effect) cannot be applied to the existing schema. Verified against the fork at 1ae076f:
-
store.service.ts:192 sets _historical from getHistoricalStateEnabled(schema), which for an existing schema returns the stored value: store.service.ts:358 does return value ? 'height' : false, so our stored boolean false yields false. The config value is ignored, and only wins via the fresh-schema fallback at store.service.ts:366.
-
project.service.ts:88: isHistorical is storeService.historical, so also false.
-
project.service.ts:384 then hard-exits:
if (this.nodeConfig.unfinalizedBlocks && !this.isHistorical) {
exitWithError(
'Unfinalized blocks cannot be enabled without historical. You will need to reindex your project to enable historical',
logger
);
}
With restart: unless-stopped that is a crash loop. So there are three real options.
Option 1: patch the fork's fetch target (recommended)
Leave historical and unfinalizedBlocks off. Change what feeds the fetch target so it uses the fork's own effectiveFinalizedHeader (bestHeight - finalizedDepth, i.e. best - 100) instead of the raw chain_getFinalizedHead.
This is what the fork was already reaching for: updateEffectiveFinalizedHeader() (unfinalizedBlocks.service.ts:124) computes exactly the right number, and it is currently only consumed for fork detection and pruning, never for the fetch target.
Two changes are needed, not one:
- Source
_latestFinalizedHeight (or the BlockTarget emission and nextEndBlockHeight clamp) from the effective header rather than the raw finalized header.
- Drive
updateEffectiveFinalizedHeader() from the getBestBlockHead interval. Today it is only called when the raw finalized head advances (fetch.service.ts:123-128) or from processUnfinalizedBlockHeader, which is gated off while unfinalizedBlocks is false. So the effective header is itself ~15 hours stale, and repointing the target at it alone would change nothing.
Safe on the same premise the fork already relies on: best - 100 is below the chain's reorg floor. No re-index, no schema change, no historical storage, no rollback machinery, and no file-retriever changes. Cost is a fork build and image push.
Option 2: fresh schema with historical enabled, re-index
Set historical: true and unfinalizedBlocks: true in the manifest (boolean true, not 'height': the model declares @IsBoolean() historical?: boolean and the rebase maps true to 'height'), point at a fresh --db-schema, re-index, then cut over. Also remove the deprecated and contradictory --disable-historical=false from docker/dag-indexer/docker-compose.yml.
Prerequisites and consequences, all verified:
-
btree_gist extension required, or store.service.ts:210 exits with 'Btree_gist extension is required to enable historical data, contact DB admin for support'. Needs the DB admin; the DB is remote.
-
id stops being unique. Historical tables gain _id (uuid PK) and _block_range (int8range), and multiple rows may share an id (see sync-helper.test.ts:150-159). Sequelize masks this with a beforeFind hook injecting a _block_range filter, but the file-retriever queries Postgres directly via pg and gets no filter:
getDagNode's SELECT * FROM "dag-indexer".nodes WHERE id = $1 would take an arbitrary version.
- The recursive CTE in
getSortedChunksByCid joins n.cid = link_with_idx.cid; any node with two versions multiplies rows and silently corrupts the chunk list.
DAG nodes are written once, so one version each in practice, but a rollback re-processing a block can create a second, and rollback becomes active under this option. Both queries need upper_inf(_block_range) filters before cutover.
-
Per-block fork detection becomes active (indexer.manager.ts:130), adding RPC and write overhead, so the re-index will run slower than the 28 blocks/s measured.
-
Re-index cost: ~8.9M blocks from block 1000, on the order of four days at best.
Option 3: accept the gap
Keep the current config and rely on the DSN fallback in PR #173 as the mitigation, accepting a permanent multi-day window served by reconstruction.
Deployment note (applies to any manifest change)
The deployed manifest is the generated project.yaml, not project.ts. Any manifest change must either be rebuilt (yarn dag-indexer build) as part of the deploy or applied to project.yaml on the host. Editing only project.ts has no effect until something rebuilds.
Image drift is not a risk for a restart: the locally cached :latest (sha256:41e2d3a8…) is identical to what the container is already running. It is about twelve hours older than the current tip of autonomys-subql-node, so a docker pull would change the binary.
Verification
Under option 1 or 2, expected recovery for the current 37,653-block gap is about 22 minutes at the observed rate.
curl -s http://127.0.0.1:30001/meta | python3 -c "import sys,json;m=json.load(sys.stdin);print('gap:', m['bestHeight']-m['targetHeight'])"
Should settle between 0 and ~100 rather than ~37,600.
Audit of what else the settings affect
For completeness, nodeConfig.historical has no consumers outside its own getter; every other historical decision reads storeService.historical. nodeConfig.unfinalizedBlocks has exactly these consumers:
| Site |
Effect when true |
fetch.service.ts:165 latestHeight() |
Target becomes best height (the intended change) |
fetch.service.ts:377 nextEndBlockHeight |
Batch end clamps to best, not finalized |
fetch.service.ts:129 / :149 |
BlockTarget event sourced from best (affects /meta and logs) |
indexer.manager.ts:130 |
Per-block fork detection and rollback becomes active |
project.service.ts:384 |
Hard exit unless historical is enabled |
unfinalizedBlocks.service.ts:71 |
Log line only |
Follow-ups
Relationship to PR #173
PR #173 was written to contain the symptoms of this bug, on the understanding that the indexer was wedged. It is not wedged, and the "root cause, now measured" section of that PR should be read in light of this issue.
Once the config is fixed, the DAG indexer sits within ~100 blocks (~10 minutes) of head. Object mappings are only published once the segment holding an object is archived, so a correctly configured DAG indexer is fresher than the object-mapping indexer can be. The DSN fallback therefore cannot serve the newest window at all, and reverts to covering genuine indexer gaps (dropped nodes) rather than being the primary read path for recent content.
Three fixes in that PR are independent of all of this and worth landing on their own:
- The
link_order chunk-ordering bug in getSortedChunksByCid, which silently corrupts any file with a multi-level DAG (above ~106 MB) on the indexed path today.
errorMiddleware being exported but never registered.
- Returning a retryable
503 with a reason code instead of a 404 on an indexer miss, since a derived index having no row is not evidence that content is absent from a permanent store.
DAG indexer follows chain-reported finality, leaving ~2.6 days of uploads unservable
Summary
The DAG indexer targets the chain's reported finalized head instead of the chain tip. On Autonomys mainnet that head currently trails best by ~37,600 blocks (~2.6 days), so any file uploaded in that window has no row in
"dag-indexer".nodesandGET /files/:cid/metadatareturns404.The cause is a single boolean in
services/dag-indexer/project.ts:historical: falsesilently forcesunfinalizedBlockstofalse, discarding the--unfinalized-blocks=trueflag we pass on the command line. This has been the case since the indexer was first added (2025-07-30). Nothing was changed or mis-deployed; the wall-clock cost of the misconfiguration grew until it became user-visible.Note that simply flipping that boolean is not a safe hotfix: SubQuery refuses to run unfinalized-block indexing without historical state, and historical state cannot be turned on for an existing schema. See "Fix options" for the three real routes.
Three separate layers of monitoring report healthy throughout, which is the second half of this issue.
Impact
500 {"error":"Failed to retrieve data","details":"Error fetching file header: 404 Not Found"}, and because404reads as permanent, downstream consumers treat the objects as dead rather than retrying.Evidence
Measured on
auto-drive-gateway, 2026-08-03 ~12:30 UTC.curl -s http://127.0.0.1:30001/meta:{ "currentProcessingHeight": 8926444, "targetHeight": 8926444, "bestHeight": 8964097, "indexerNodeVersion": "6.0.4", "uptime": 17297798 }targetHeightis 37,653 blocks behindbestHeight, about 2.6 days at 6s blocks.That
targetHeightis exactly the chain's reported finalized head. Queried independently against the public RPC at the same time:Our own node (
http://subspace-node:9944,ghcr.io/autonomys/node:mainnet-2026-jan-20) returns the same finalized number as the public endpoint, so this is a consensus-level property, not a local node or pruning quirk.The indexer is not stalled or under-resourced
It idles at the finalized head, then catches up in a burst when finality jumps. Observed live:
So throughput is not the problem, and
--workers=1is not the problem. Closing the current 37,653-block gap would take about 22 minutes.Root cause
@subql/nodedecides its fetch target here (packages/node-core/src/indexer/fetch.service.ts):latestFinalizedHeightcomes from rawchain_getFinalizedHead. Per the forum explanation of Subspace finality, Autonomys has probabilistic finality and the reported finalized block is artificially delayed rather than a consistent -100, because Substrate only allows a fixed offset and not custom pruning logic. So the raw value is an unsuitable fetch target, and theunfinalizedBlocks ? latestBestHeightbranch is the only sane one.We pass
--unfinalized-blocks=trueindocker/dag-indexer/docker-compose.yml, but it never takes effect:services/dag-indexer/project.tsdeclareshistorical: falseinrunner.node.options.rebaseArgsWithManifest(packages/node-core/src/utils/configure.ts) copies most manifest options only when the CLI left them undefined, but handleshistoricalseparately and copies it unconditionally, so it overrides our--disable-historical=false:NodeConfig.unfinalizedBlocksthen gates on it:Our
--unfinalized-blocks=trueclears the first gate and is then discarded by the second, with no warning.latestHeight()therefore takes the raw-finality branch.Note that our
subql-node-substratefork already solves this problem correctly for a different consumer.UnfinalizedBlocksService.updateEffectiveFinalizedHeader()computesbestHeight - finalizedDepth(theEffective finalized header updated to: 8963778line above, matching--finalized-depth=100), which is exactly what the forum post says finality means on this network. But that value is only used for fork detection and pruning; it never reaches the fetch target. The fork was built to be run with unfinalized-block indexing enabled, and our manifest disables it.The manifest in use is not the one in git
@subql/noderesolvesDEFAULT_MANIFEST(project.yaml) for a directory target and never readsproject.tsat runtime.project.yamlis gitignored (services/dag-indexer/.gitignore:30) and generated bysubql codegen && subql build. The copy on the host is dated 2025-12-02 and its contents matchproject.ts:This is worth fixing on its own (see follow-ups): the configuration actually governing a production service cannot currently be reviewed, diffed, or reproduced from this repository, and reading
project.tsor the compose command line gives a misleading answer.Why nothing caught it
Three layers were green the whole time:
curl -f /ready)Up 6 months (healthy)Fully synced, waiting for new blocks/health/dag-indexerin PR #173lagBlocks = targetHeight - lastProcessedHeight= 0, sostatus: ok, HTTP 200All three are correct on their own terms. The indexer really has processed everything up to its target; the target is what is wrong.
targetHeightis also only written to_metadatawhenfillNextBlockBufferis about to fetch, so it converges onlastProcessedHeightprecisely when fetching stops, making that subtraction structurally incapable of detecting this./metahas hadtargetHeightandbestHeightside by side the entire time. Nothing compared them.Fix options
The obvious fix (set
historical: truein the manifest sounfinalizedBlockstakes effect) cannot be applied to the existing schema. Verified against the fork at1ae076f:store.service.ts:192sets_historicalfromgetHistoricalStateEnabled(schema), which for an existing schema returns the stored value:store.service.ts:358doesreturn value ? 'height' : false, so our stored booleanfalseyieldsfalse. The config value is ignored, and only wins via the fresh-schema fallback atstore.service.ts:366.project.service.ts:88:isHistoricalisstoreService.historical, so alsofalse.project.service.ts:384then hard-exits:With
restart: unless-stoppedthat is a crash loop. So there are three real options.Option 1: patch the fork's fetch target (recommended)
Leave
historicalandunfinalizedBlocksoff. Change what feeds the fetch target so it uses the fork's owneffectiveFinalizedHeader(bestHeight - finalizedDepth, i.e.best - 100) instead of the rawchain_getFinalizedHead.This is what the fork was already reaching for:
updateEffectiveFinalizedHeader()(unfinalizedBlocks.service.ts:124) computes exactly the right number, and it is currently only consumed for fork detection and pruning, never for the fetch target.Two changes are needed, not one:
_latestFinalizedHeight(or theBlockTargetemission andnextEndBlockHeightclamp) from the effective header rather than the raw finalized header.updateEffectiveFinalizedHeader()from thegetBestBlockHeadinterval. Today it is only called when the raw finalized head advances (fetch.service.ts:123-128) or fromprocessUnfinalizedBlockHeader, which is gated off whileunfinalizedBlocksis false. So the effective header is itself ~15 hours stale, and repointing the target at it alone would change nothing.Safe on the same premise the fork already relies on:
best - 100is below the chain's reorg floor. No re-index, no schema change, no historical storage, no rollback machinery, and no file-retriever changes. Cost is a fork build and image push.Option 2: fresh schema with historical enabled, re-index
Set
historical: trueandunfinalizedBlocks: truein the manifest (booleantrue, not'height': the model declares@IsBoolean() historical?: booleanand the rebase mapstrueto'height'), point at a fresh--db-schema, re-index, then cut over. Also remove the deprecated and contradictory--disable-historical=falsefromdocker/dag-indexer/docker-compose.yml.Prerequisites and consequences, all verified:
btree_gistextension required, orstore.service.ts:210exits with'Btree_gist extension is required to enable historical data, contact DB admin for support'. Needs the DB admin; the DB is remote.idstops being unique. Historical tables gain_id(uuid PK) and_block_range(int8range), and multiple rows may share anid(seesync-helper.test.ts:150-159). Sequelize masks this with abeforeFindhook injecting a_block_rangefilter, but the file-retriever queries Postgres directly viapgand gets no filter:getDagNode'sSELECT * FROM "dag-indexer".nodes WHERE id = $1would take an arbitrary version.getSortedChunksByCidjoinsn.cid = link_with_idx.cid; any node with two versions multiplies rows and silently corrupts the chunk list.DAG nodes are written once, so one version each in practice, but a rollback re-processing a block can create a second, and rollback becomes active under this option. Both queries need
upper_inf(_block_range)filters before cutover.Per-block fork detection becomes active (
indexer.manager.ts:130), adding RPC and write overhead, so the re-index will run slower than the 28 blocks/s measured.Re-index cost: ~8.9M blocks from block 1000, on the order of four days at best.
Option 3: accept the gap
Keep the current config and rely on the DSN fallback in PR #173 as the mitigation, accepting a permanent multi-day window served by reconstruction.
Deployment note (applies to any manifest change)
The deployed manifest is the generated
project.yaml, notproject.ts. Any manifest change must either be rebuilt (yarn dag-indexer build) as part of the deploy or applied toproject.yamlon the host. Editing onlyproject.tshas no effect until something rebuilds.Image drift is not a risk for a restart: the locally cached
:latest(sha256:41e2d3a8…) is identical to what the container is already running. It is about twelve hours older than the current tip ofautonomys-subql-node, so adocker pullwould change the binary.Verification
Under option 1 or 2, expected recovery for the current 37,653-block gap is about 22 minutes at the observed rate.
Should settle between 0 and ~100 rather than ~37,600.
Audit of what else the settings affect
For completeness,
nodeConfig.historicalhas no consumers outside its own getter; every other historical decision readsstoreService.historical.nodeConfig.unfinalizedBlockshas exactly these consumers:fetch.service.ts:165latestHeight()fetch.service.ts:377nextEndBlockHeightfetch.service.ts:129/:149BlockTargetevent sourced from best (affects/metaand logs)indexer.manager.ts:130project.service.ts:384unfinalizedBlocks.service.ts:71Follow-ups
bestHeight - targetHeightfrom/meta, and on the frontier block's chain timestamp against wall clock. Do not alert ontargetHeight - lastProcessedHeight, which is 0 under this failure mode.project.yaml, or generate it into the image, or have the deploy regenerate it. Log the resolved runner options at startup so the effective config appears in the logs.--unfinalized-blocks=trueeither take effect or emit a warning whenhistorical: falsediscards it. Silently ignoring an explicitly passed flag is what hid this for a year.fetch.service.ts:128callsregisterFinalizedBlock()withoutawait, but the fork changed that method toasync(unfinalizedBlocks.service.ts:212). Rejections insideupdateEffectiveFinalizedHeaderare unhandled and the caller proceeds before it completes. Not the cause of this incident.dag-indexer'shandleCallcatches every error with one generic log line and drops the node, which is the only remaining mechanism that can produce a genuine indexer gap once this is fixed. Surface it as a metric.Relationship to PR #173
PR #173 was written to contain the symptoms of this bug, on the understanding that the indexer was wedged. It is not wedged, and the "root cause, now measured" section of that PR should be read in light of this issue.
Once the config is fixed, the DAG indexer sits within ~100 blocks (~10 minutes) of head. Object mappings are only published once the segment holding an object is archived, so a correctly configured DAG indexer is fresher than the object-mapping indexer can be. The DSN fallback therefore cannot serve the newest window at all, and reverts to covering genuine indexer gaps (dropped nodes) rather than being the primary read path for recent content.
Three fixes in that PR are independent of all of this and worth landing on their own:
link_orderchunk-ordering bug ingetSortedChunksByCid, which silently corrupts any file with a multi-level DAG (above ~106 MB) on the indexed path today.errorMiddlewarebeing exported but never registered.503with a reason code instead of a404on an indexer miss, since a derived index having no row is not evidence that content is absent from a permanent store.