From 0e20120bcb4c78be890bfde0e98e1cb4251c644f Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 25 Aug 2026 11:14:49 -0400 Subject: [PATCH] fix(filler): stop deriving the processing state from a one-way flag contract_readers.live goes true the first time a reader reaches head and nothing ever writes it back, so seeding the processing state from it at startup puts a reader that once reached head into head mode however far behind it restarts. The in-process promotion cannot correct that, since it only fires while the state is still catchup, and the first commit writes the flag out again. Every restart re-arms it. Head mode is not free while a reader replays a backlog. Before 2.2.1 it published every trace and delta to redis inside the commit path, and it still forces a checkpoint write on each commit. Starting in catchup costs nothing instead, because process() re-checks the same head-distance predicate on every block and promotes on the first one that qualifies, ahead of that block's commit and of its notifications. A reader that really is at head is therefore in head mode from its first block. The flag is still written, since reconcile reads it as a liveness hint, but nothing derives behaviour from it and getReaderPosition no longer returns it. Signed-off-by: Rob Konsdorf --- src/filler/database.ts | 9 +++++---- src/filler/receiver.test.ts | 11 +++++++++++ src/filler/receiver.ts | 10 +++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/filler/database.ts b/src/filler/database.ts index c8ab14bd..cc407c99 100644 --- a/src/filler/database.ts +++ b/src/filler/database.ts @@ -446,19 +446,20 @@ export class ContractDB { } } - async getReaderPosition(): Promise<{ live: boolean, block_num: number, updated: number }> { - const query = await this.connection.database.query('SELECT live, block_num, updated FROM contract_readers WHERE name = $1', [this.name]); + // The live column is deliberately absent here. It is a one-way latch and the + // reader no longer derives its processing state from it, so returning it + // only invites that back. reconcile reads the column with its own query. + async getReaderPosition(): Promise<{ block_num: number, updated: number }> { + const query = await this.connection.database.query('SELECT block_num, updated FROM contract_readers WHERE name = $1', [this.name]); if (query.rows.length === 0) { return { - live: false, block_num: 0, updated: 0 }; } return { - live: query.rows[0].live, block_num: parseInt(query.rows[0].block_num, 10), updated: parseInt(query.rows[0].updated, 10) }; diff --git a/src/filler/receiver.test.ts b/src/filler/receiver.test.ts index 1487c2cd..81fc6eb5 100644 --- a/src/filler/receiver.test.ts +++ b/src/filler/receiver.test.ts @@ -247,6 +247,17 @@ describe('StateReceiver', () => { return receiver; } + it('starts in catchup even when the stored live flag is set', async () => { + // live is a one-way latch: it stays true from the first time a reader + // reached head, so a reader restarting far behind must ignore it and + // let the per-block head-distance check promote instead. + const receiver = createStartedReceiver(6_000_000); + await receiver.startProcessing(); + + const setState = (receiver as any).processor.setState; + expect(setState.calledOnceWith(ProcessingState.CATCHUP)).to.equal(true); + }); + it('arms the irreversible floor from the checkpoint before the first block', async () => { const receiver = createStartedReceiver(6_000_000); await receiver.startProcessing(); diff --git a/src/filler/receiver.ts b/src/filler/receiver.ts index c964d2d2..552e9a70 100644 --- a/src/filler/receiver.ts +++ b/src/filler/receiver.ts @@ -148,7 +148,15 @@ export default class StateReceiver implements IShipConsumer { position = await this.database.getReaderPosition(); } - this.processor.setState(position.live ? ProcessingState.HEAD : ProcessingState.CATCHUP); + // Deliberately not seeded from position.live. That column is a one-way + // latch: it goes true the first time a reader reaches head and nothing + // ever writes it back, so seeding from it puts a reader that once + // reached head into head mode however far behind it restarts. Starting + // in catchup costs nothing, because process() re-evaluates the same + // head-distance predicate on every block and promotes on the first one + // that qualifies, before that block's commit or its notifications. The + // column is still written, since reconcile reads it as a liveness hint. + this.processor.setState(ProcessingState.CATCHUP); let startBlock = position.block_num + 1;