Stop the std_list cache drifting from the database - #68
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The KV cache kept losing problems that exist in std_answer. Three
separate defects combined to make it look intermittent.
The KV write on the upload path was a floating promise. The D1 insert
was awaited and committed, `this.kv.put` was not, and the handler
returned immediately after. The fetch handler receives an execution
context but never uses it - waitUntil appears nowhere outside the cron
- so a pending KV write had nothing extending its lifetime past the
response and was not guaranteed to complete.
Every upload also did get, string-concat, put of the entire list. Two
uploads landing close together both read the same base list, each
appended only its own id, and the second put overwrote the first. KV has
no compare-and-set to prevent it, and reads are eventually consistent,
so even non-concurrent uploads could read a stale list and clobber it.
The branch meant to backfill a missing entry was dead code. `split('\n')`
yields strings and CheckParams guarantees ProblemID is a number, so
`d === Data["ProblemID"]` was strict equality across types - always
false, for every input. The condition was also inverted: it appended
when the id was already present and did nothing when it was missing.
Two bugs cancelling into a no-op, which is why nothing ever healed.
So writes dropped entries and nothing put them back. Drift was
one-directional and permanent.
Rebuild the cache wholesale from the database instead of patching it.
GetStdList stays a pure KV read costing no database rows, since that is
why the cache exists. Uploads are bounded at one per problem ever, so
the insert path can afford a rebuild. The already-uploaded path stays
cheap: it rebuilds only when the cached list is genuinely missing the
problem. A daily rebuild in the existing cron bounds any remaining drift
to 24 hours rather than forever, which is the part that does not depend
on every write path staying correct.
Also fixes GetStdList returning a spurious trailing 0 from the trailing
newline, and throwing outright if the key were ever unset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
boomzero
force-pushed
the
fix/std-list-cache-drift
branch
from
July 27, 2026 06:28
31c450c to
dcd8cd3
Compare
Contributor
There was a problem hiding this comment.
2 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Source/index.ts">
<violation number="1" location="Source/index.ts:179">
P2: If the daily cache rebuild fails, the promise registered with `waitUntil` can remain pending forever because `RebuildStdList` runs inside an async Promise executor with no rejection path. Using an async IIFE directly with `waitUntil`, or explicitly propagating errors to the outer promise, would let the scheduled invocation settle and report the failure correctly.</violation>
</file>
<file name="Source/Process.ts">
<violation number="1" location="Source/Process.ts:56">
P2: When the `std_list` key is unset, `GetStdList` silently reports success with an empty list instead of surfacing the invalid cache state. That can make clients believe no standard answers exist during the drift window. Missing (`null`/`undefined`) should be distinguished from a valid empty string and raised as an error, while the empty-table cache remains valid.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Matches the convention of the other requires rather than sitting mid-file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one
Two problems from review.
The scheduled handler passed an async function as a Promise executor.
The constructor discards the executor's returned promise, so a throw
from ThrowErrorIfFailed - which both Database.Delete and RebuildStdList
route through - rejected a promise nobody held while the outer one
stayed pending forever. waitUntil would hang until the runtime killed
the invocation, and the failed run was never reported. Demonstrated:
current shape: Context.waitUntil(new Promise(async (Resolve) => ...))
[unhandled rejection: DB failure]
outer promise: STILL PENDING -> waitUntil hangs, failure never reported
proposed shape: Context.waitUntil((async () => ...)())
outer promise: REJECTED (DB failure)
Hand waitUntil the async call's promise directly. The pattern predates
the cache rebuild, but the rebuild added another throwing path into it.
Separately, ParseStdList treated a missing key and an empty string
alike, so GetStdList answered [] when the cache had never been built -
telling clients no problem has a std answer. Rather than raise an error
and leave the endpoint broken until the next cron run, fill the cache
from the database on a miss. An empty string remains a valid empty
cache and is served without a database read, so this costs a scan only
on a genuine miss, which the daily rebuild keeps rare.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🧙 Sourcery is reviewing your pull request! Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The
std_listKV cache kept losing problems that exist instd_answer. Three separate defects combined, which is why it looked intermittent and random.1. The KV write was a floating promise
Source/Process.ts, upload path:The D1 insert commits; the KV put has nothing keeping it alive.
Source/index.tsacceptsContextinfetchbut never uses it —waitUntilappears nowhere outside the cron handler, andProcessis constructed asnew Process(RequestData, Environment), so it has no access to the execution context at all. Once the response returns, the pending KV write is not guaranteed to complete.2. Read-modify-write over a store with no compare-and-set
Every upload did
get→ concat →putof the entire list. Two uploads landing close together both read the same base list, each appended only its own id, and the secondputoverwrote the first — that id gone permanently. KV reads are also eventually consistent, so even non-concurrent uploads from different locations could read a stale list and clobber newer entries.3. The self-repair branch was dead code
The branch meant to backfill a missing entry, reduced to a runnable repro:
split('\n')yields strings;CheckParamsguaranteesProblemIDis a number.d === Data["ProblemID"]is strict equality across types — always false, for every input. The condition was also inverted:It appended when the id was already there and did nothing when it was missing. Two bugs cancelling into a no-op — so fixing only the type comparison would have made it worse, appending a duplicate on every re-upload.
Net effect: 1 and 2 removed entries, 3 guaranteed they were never restored. Drift was one-directional and permanent.
The fix
Rebuild the cache wholesale from the database rather than patching entries into it.
GetStdListis still a pure KV read costing zero database rows — that is why the cache exists.UploadStdearly-returns when a std already exists, so a problem is inserted at most once ever; lifetime inserts are bounded by the problem count.crons = ["0 0 * * *"], which already has a properwaitUntil). One scan per day bounds any remaining drift to 24 hours instead of forever. This is the part that does not depend on every write path staying correct in the future.Also fixes
GetStdListreturning a spurious trailing0from the trailing newline ([1000, 1001, 1002, 0]), and throwing outright if the key were ever unset.Verification
9 tests added, written before the fix and confirmed failing against the old code (including the unset-key crash at the then-current
Process.ts:1283). All pass now; full suite 56 pass, 0 fail. The changed regions are clean undertsc --noEmit; the one remainingindex.tserror is pre-existing on master, verified by stashing.Coverage includes drift healing, the empty table, the hot path doing no I/O, cache repair on re-upload, and both the legacy trailing-newline and unset-key read formats.
Note
The rebuild-on-write still has a narrow window — KV put ordering is not guaranteed, so two concurrent uploads could in principle land out of order. Each writer now writes complete state rather than a delta, so the loss is far smaller, and the daily rebuild bounds it. Closing it entirely would need the list moved into D1 or a Durable Object, which is a bigger change than this bug warrants.
🤖 Generated with Claude Code
Summary by cubic
Rebuilds the
std_listKV cache from D1 to stop drift, awaits writes, and reconciles it daily.GetStdListnow rebuilds on a missing key (not empty) and ignores trailing newlines; cron failures now propagate.Bug Fixes
GetStdListrebuilds the cache on a missing key and parses legacy trailing newlines.waitUntila real async promise and runsRebuildStdList, so failures are reported and drift heals within 24 hours.Refactors
StdListKey,ParseStdList, andRebuildStdList;GetStdListremains a pure KV read on hits, doing no DB/KV work when in sync.Written for commit f8378f2. Summary will update on new commits.
Summary by Sourcery
Rebuild the std_list KV cache from the database to prevent drift and ensure consistent std answer listings, with periodic reconciliation via the scheduled worker.
Bug Fixes:
Enhancements:
Tests: