Repository navigation
fix(indexer): monotonic sync cursor, single-flight polls, and fair trader healing #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
1b9ce22
fix(indexer): keep sync cursors monotonic and page trader healing fairly
Ayush7614 58f5c3c
fix(indexer): heal passes run one at a time per chain, never blocking…
kevincodex1 b3e792d
docs(indexer): state why pollAll shares work per chain, not the whole…
kevincodex1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { setBounded } from "./boundedMap.ts"; | ||
|
|
||
| test("expired entries are dropped before an active one is evicted", () => { | ||
| const map = new Map([ | ||
| ["expired", 1], | ||
| ["live", 100], | ||
| ]); | ||
| setBounded(map, "new", 100, 2, (value, now) => value <= now, 50); | ||
| assert.deepEqual([...map.keys()], ["live", "new"]); | ||
| }); | ||
|
|
||
| test("a full map with nothing expired evicts the oldest insertion", () => { | ||
| const map = new Map([ | ||
| ["a", 1], | ||
| ["b", 2], | ||
| ]); | ||
| setBounded(map, "c", 3, 2, () => false, 0); | ||
| assert.deepEqual([...map.keys()], ["b", "c"]); | ||
| }); | ||
|
|
||
| test("refreshing an existing key moves it out of the eviction lane", () => { | ||
| const map = new Map([ | ||
| ["a", 1], | ||
| ["b", 2], | ||
| ]); | ||
| setBounded(map, "a", 3, 2, () => false, 0); | ||
| assert.deepEqual([...map.keys()], ["b", "a"]); | ||
| setBounded(map, "c", 4, 2, () => false, 0); | ||
| assert.deepEqual([...map.keys()], ["a", "c"], "b was the oldest, not the refreshed a"); | ||
| }); | ||
|
|
||
| test("a burst of new keys stays at the bound", () => { | ||
| const map = new Map<string, number>(); | ||
| for (let i = 0; i < 100; i++) setBounded(map, `k${i}`, i, 10, () => false, 0); | ||
| assert.equal(map.size, 10); | ||
| assert.deepEqual([...map.keys()], Array.from({ length: 10 }, (_, i) => `k${90 + i}`)); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| /** | ||
| * Put a key into a bounded retry map without throwing the map away. On overflow, entries the caller marks expired are | ||
| * dropped first; if the map is still full, the oldest insertions go. Refreshing an existing key also refreshes its | ||
| * insertion order, so a row that is actively being retried is not the next one evicted. | ||
| */ | ||
| export function setBounded<K, V>( | ||
| map: Map<K, V>, | ||
| key: K, | ||
| value: V, | ||
| max: number, | ||
| expired: (value: V, now: number) => boolean, | ||
| now: number, | ||
| ): void { | ||
| if (max < 1) return; | ||
| if (!map.has(key) && map.size >= max) { | ||
| for (const [k, v] of map) if (expired(v, now)) map.delete(k); | ||
| while (map.size >= max) { | ||
| const oldest = map.keys().next().value; | ||
| if (oldest === undefined) break; | ||
| map.delete(oldest); | ||
| } | ||
| } | ||
| map.delete(key); | ||
| map.set(key, value); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { readFileSync } from "node:fs"; | ||
| import test from "node:test"; | ||
|
|
||
| /** | ||
| * Source contracts for the indexer durability changes. The paging planner itself is unit-tested in | ||
| * traderHeal.test.ts; these assertions pin the SQL and the once-at-a-time heal wiring that node --test cannot import | ||
| * (indexer.ts is server-only). | ||
| */ | ||
| const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); | ||
| const indexer = read("./indexer.ts"); | ||
| const indexes = read("../../../db/concurrent-indexes.sql"); | ||
|
|
||
| test("cursor and head updates are monotonic across overlapping sync machines", () => { | ||
| assert.match(indexer, /cursor_block = GREATEST\(cursor_block, \$\{to\}\)/); | ||
| assert.match(indexer, /head_block = GREATEST\(COALESCE\(head_block, 0\), \$\{head\}\)/); | ||
| assert.match(indexer, /last_error = CASE WHEN cursor_block <= \$\{to\} THEN NULL ELSE last_error END/); | ||
| assert.match(indexer, /head_block = CASE WHEN \$\{knownHead\}::bigint IS NULL THEN head_block ELSE GREATEST\(COALESCE\(head_block, 0\), \$\{knownHead\}::bigint\) END/); | ||
| }); | ||
|
|
||
| test("each chain's heal pass runs once at a time, and a running one is skipped, never awaited", () => { | ||
| // runOnce.test.ts covers the helper's behaviour; this pins that pollAll uses it for the heals and nothing else | ||
| assert.match(indexer, /const healsRunning = new Map<ChainKey, Promise<void>>\(\);/); | ||
| assert.match(indexer, /out\[c\] = await pollLaunches\(c\)/); | ||
| assert.match(indexer, /await startUnlessRunning\(healsRunning, c, \(\) => healChain\(c\)\);/); | ||
| assert.ok(indexer.indexOf("out[c] = await pollLaunches(c)") < indexer.indexOf("startUnlessRunning(healsRunning"), "the chain is polled before its heals"); | ||
| // the poll itself is shared per chain: a caller joins a run() still in flight instead of starting a second one | ||
| assert.match(indexer, /export async function pollLaunches\(chain: ChainKey\): Promise<LaunchSyncResult> \{\n const cur = inFlight\.get\(chain\);\n if \(cur\) return cur;/); | ||
| // the whole pass is not single-flight: one slow chain or a stuck heal must not hold the other chains' next poll | ||
| assert.doesNotMatch(indexer, /allInFlight/); | ||
| }); | ||
|
|
||
| test("trader healing pages oldest-first with a keyset, not the newest 100 rows", () => { | ||
| assert.match(indexer, /\(block_number, log_index\) > \(\$\{after\.block\}, \$\{after\.log\}\)/); | ||
| assert.match(indexer, /ORDER BY block_number ASC, log_index ASC LIMIT \$\{HEAL_TRADER_PAGE\}/); | ||
| assert.doesNotMatch(indexer, /ORDER BY block_number DESC LIMIT 100/); | ||
| assert.match(indexer, /if \(plan\.cursor\) healTraderCursor\.set\(chain, plan\.cursor\)/); | ||
| assert.match(indexer, /healTraderCursor\.delete\(chain\)/); | ||
| }); | ||
|
|
||
| test("retry state is pruned, never cleared wholesale", () => { | ||
| assert.match(indexer, /function restHeal\(key: string\): void/); | ||
| assert.match(indexer, /function noteAttributionTry\(key: string, n: number\): void/); | ||
| assert.match(indexer, /setBounded\(healRestUntil,/); | ||
| assert.match(indexer, /setBounded\(attributionTries,/); | ||
| assert.doesNotMatch(indexer, /healRestUntil\.clear\(\)/); | ||
| assert.doesNotMatch(indexer, /attributionTries\.clear\(\)/); | ||
| }); | ||
|
|
||
| test("the partial index serves the missing-trader page and order", () => { | ||
| assert.match( | ||
| indexes, | ||
| /CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_trader_null_idx ON bb_launch_swaps \(chain_id, block_number, log_index\) WHERE trader IS NULL;/, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Share the full
pollAll()pass between overlapping callers.Overlapping callers from the 15s loop (
app/src/lib/launchpad/loop.ts:21) andPOST /api/launch/sync(app/src/app/api/launch/sync/route.ts:36) do not share the full pass.pollLaunchesshares only one chain's in-flightrun(), and it removes that entry when the run settles (app/src/lib/launchpad/indexer.ts:720-727). The heal guard atindexer.ts:748covers only a running heal for the same chain.When one chain finishes while another chain is still pending, a second
pollAll()starts a new poll for the finished chain. The first pass is still running. Issue#112requires one shared full pass, so this repeats chain polling work that the change was meant to prevent.The durability test at
app/src/lib/launchpad/indexer-durability.test.ts:27-28asserts the opposite behavior and must change with the fix.A shared pending promise is not enough by itself.
pollAll()awaits each chain's heal atindexer.ts:748. If the shared promise includes heals, later ticks wait behind a stuck heal, which the comment atindexer.ts:744-747says must not happen. The fix should share the poll work across overlapping callers and keep heals non-blocking for later callers, then update the durability test to assert that contract.