fix(signals): stop losing writes on concurrent signal mutations - #361
Merged
Darkvader-ship-it merged 2 commits intoSep 28, 2026
Merged
Darkvader-ship-it merged 2 commits into
Darkvader-ship-it merged 2 commits into
Conversation
The signal store did readFile -> JSON.parse -> mutate -> writeFile on a
shared JSON sidecar with no locking and no version. Under concurrency
every writer read the same snapshot and the last writeFile won, so
writers silently erased each other. Measured with 50 concurrent
writers: 49 of 50 signals, upvotes, and replies were lost.
Three layers, all in lib/signal-store.ts:
- Serialized read-modify-write via a per-file promise queue, so two
mutations cannot both read the same snapshot.
- Atomic replacement: write to a sibling .tmp file, then rename(2), so
readers never observe a half-flushed file and a crash mid-write
cannot corrupt the live store.
- A JSON parse failure now throws instead of degrading to {}. Returning
{} on a parse error meant the next write overwrote every record.
Each Signal gains an integer version, bumped on every mutation:
- GET /api/signals/[id] publishes it as an ETag.
- POST /api/signals/[id] accepts an optional If-Match and returns 409
with current_version plus the fresh ETag on a stale value.
- POST /api/signals/[id]/replies accepts an optional parent_version
and returns 409 on a stale value.
- Conflicts log under the signals.version_conflict event.
Both version inputs are optional, so clients that omit them keep the
previous last-writer-wins behaviour. Records written before this change
have no version and are normalized to 1 on read, so there is no
migration step.
Adds the project's first test suite (vitest): 13 tests covering
50-concurrent-writer create/upvote/reply, CAS races, corrupt-store
refusal, legacy version backfill, and version headers. Verified the
concurrency tests fail against the old store before passing against the
new one.
Residual limitation, documented in docs/TECHNICAL.md 5.3: the queue is
per-process, so two Vercel instances can still interleave. The other
six stores (follow, profile, market, notification, achievement,
narrative-world) share the same unsafe pattern and are untouched here.
|
@Chidubemkingsley Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Mikey-222
added a commit
to Mikey-222/Phase
that referenced
this pull request
Sep 28, 2026
…left unguarded PHASE-STELLAR#361 fixed lib/signal-store.ts and its commit message noted that the other six JSON stores "share the same unsafe pattern and are untouched here". This does that: each one hand-rolled a readFile -> JSON.parse -> mutate -> writeFile pair with no coordination, so concurrent read-modify-write cycles raced and the second writeFile discarded the first. Measured on this branch before the change, 50 concurrent writers: follows 1/50 persisted profile saves 1/50 persisted checkAndUnlock counters 1/50 accumulated world save versions 1 (every writer read version 0) profile view counters 1/50 counted unlockAchievement reported success 10/10 for the same unlock Adds lib/json-store.ts, giving the same three guarantees PHASE-STELLAR#361 applied inline to signals, and routes the remaining stores through it: serialized mutation, atomic temp-then-rename replacement, and a JSON parse failure that throws instead of degrading to {} (degrading meant the next write wiped the store). Also fixes a bug needing no concurrency at all: checkAndUnlock read the store, let tryUnlock call unlockAchievement (its own read-modify-write), then wrote its stale snapshot back. It returned the unlock to its caller and fired an "achievement unlocked" notification for an achievement that was never persisted. The counter update and every unlock it triggers now happen in one locked mutation. Because the lock is not reentrant, unlocks are applied to the in-flight store rather than re-entering through unlockAchievement. Two further read-then-write races are now decided under the lock: claimProfileHandle's alias uniqueness check (two wallets could claim one handle) and setNotificationPreferences' merge (concurrent updates each started from the same base and lost a field). Also fixes a pre-existing runtime bug found while testing: markNarrativeRead and getReaderProgress passed "readerProgress" to serverDataJsonPath, but the key is "worldReaderProgress", so FILES[key] was undefined and path.join(root, undefined) threw. Reader progress was entirely non-functional. @ts-nocheck on that file is why tsc never caught it. Scope: the six stores PHASE-STELLAR#361 named. market-store's listings and offers are already SQLite, so only marketProfileViews and blockList are touched here. The route-local stores (faucet claims, classic-liq claims, artist profiles, nft listings) still have the original pattern and are not addressed here. Tests: lib/__tests__/json-store-concurrency.test.ts, 22 vitest cases, 12 of which fail against the unpatched stores. Adds no new type errors (163 vs the 164 baseline, all pre-existing on main). Not addressed, and reported separately: - The lock is per-process; Vercel instances each hold their own os.tmpdir() copy, so cross-instance state still diverges. - main is broken independently of this change: 163 type errors, plus lib/signal-store.ts has a duplicate `let items` declaration that is a hard parse error, so that module and both of its test files cannot load. - 40 of the 43 test files on main use node:test, which `vitest run` cannot collect, so `npm test` reports "No test suite found" for all of them.
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.
lore-versioningRetention & GC Catastrophe: 10M Rows, No TTL #216mercury-classicHorizon Lag 45s + StalegetAccountCache 60s =105s #217gateway-healthCircuit Breaker Missing: Horizon 500 Retry Storm 10k Calls #218x402/supportedAsset Oracle Staleness: Cached 1h Serves Delisted LIQ #219The signal store did readFile -> JSON.parse -> mutate -> writeFile on a shared JSON sidecar with no locking and no version. Under concurrency every writer read the same snapshot and the last writeFile won, so writers silently erased each other. Measured with 50 concurrent writers: 49 of 50 signals, upvotes, and replies were lost.
Three layers, all in lib/signal-store.ts:
Each Signal gains an integer version, bumped on every mutation:
Both version inputs are optional, so clients that omit them keep the previous last-writer-wins behaviour. Records written before this change have no version and are normalized to 1 on read, so there is no migration step.
Adds the project's first test suite (vitest): 13 tests covering 50-concurrent-writer create/upvote/reply, CAS races, corrupt-store refusal, legacy version backfill, and version headers. Verified the concurrency tests fail against the old store before passing against the new one.
Residual limitation, documented in docs/TECHNICAL.md 5.3: the queue is per-process, so two Vercel instances can still interleave. The other six stores (follow, profile, market, notification, achievement, narrative-world) share the same unsafe pattern and are untouched here.