Serialise apply_delta to fix concurrent-ingest XP loss (~8%) - #38
Merged
Merged
Conversation
# The race
`StateManager::apply_delta` does a read-modify-write on `pet_state`:
let prior = self.db.get_pet_state(pet_id).await?; // READ
let xp_after = prior.total_xp + delta;
self.db.upsert_pet_state(pet_id, xp_after, ...).await?; // WRITE
With concurrent ingest the read+write isn't atomic across tasks:
Task A: read pet_state.total_xp = 100
Task B: read pet_state.total_xp = 100 β same snapshot
Task A: write 100 + 10 = 110
Task B: write 100 + 5 = 105 β overwrites A's 110
A's 10 XP is "lost" β the `xp_event` row is still written (different
UUID, different dedup key), so `sum(xp_event)` > `pet_state.total_xp`.
The pet's displayed level / stage is computed from the (stale) state,
so the user sees ~8% fewer XP than the events database implies.
Empirically reproduced: a 1.5-hour heavy Claude-Code Unicorn session
ended with `sum(xp_event)=988` but `pet_state.total_xp=910` β
78 XP / 7.9% loss.
# The fix
Single tokio Mutex held across the whole read-modify-write inside
`apply_delta` (and `rebuild`, which has the same shape). petpet
runs at most one active pet at a time, so a global mutex is the
right granularity β contention is bounded by the live ingest rate
(handful of events per second worst-case).
If multi-pet concurrent ingest ever lands, swap for a per-pet
mutex map (HashMap<pet_id, Arc<Mutex<()>>> behind a parent RwLock).
# Regression coverage
New test `apply_delta_serialises_concurrent_ingest`:
1. Spawn 200 concurrent `apply_delta(+1)` tasks against one pet
2. Await all, then read final `pet_state.total_xp`
3. Assert == 200 (pre-fix: would be < 200 with high probability)
309/309 lib tests pass; race regression stable across 5 reruns.
# Why not SQL `BEGIN IMMEDIATE` transaction?
Cleaner long-term, but would require wider plumbing changes (DbHandle
exposes `execute` shapes, not multi-statement transaction blocks).
The Mutex approach is mechanically equivalent in this codebase since
there's only one DbHandle and one active pet β write contention
is logically equivalent to mutex contention. Worth revisiting if we
add multi-pet concurrent ingest OR an external SQL client that
might bypass the engine's serialisation.
ppXD
added a commit
that referenced
this pull request
May 19, 2026
New builtin pet template targeting the "much harder" slot above Sun.
The image set (10 sprites from black-stone egg through cyborg
legend) lives at templates/builtin/kingkong/stages/stage_*/sprite.png.
# Difficulty design β ~2.5Γ Sun, achieved through rules + curve
The rule_mult clamp floor of 0.5 means usage rules can't go any
lower than Sun's. So extra difficulty comes from two compounding
levers:
1. Activity rules β most cut to zero, milestones halved:
session_start 2 β 1
user_prompt 1 β 0 β chat doesn't reward
assistant_stop 0 (unchanged from Sun)
tool_success 0 (unchanged)
task_completed 7 β 3 β halved milestone
subagent_stop 5 β 2 β halved milestone
pre_compact 1 β 0 β eliminated
Idle-chat dominance is gone; only deliberate progress moves
the meter (tasks, subagents) plus AI token usage.
2. Level curve β 1.25Γ Sun via formula
cum(L) = 200 + 188Β·(L-1) + 3Β·(L-1)Β²
L1 = 200 (hatch anchor, consistent across all templates)
L99 = 47,436 (Sun: 37,950, ratio 1.250Γ)
Per-conv XP projection vs Sun (using PR #37 + #38 baselines):
Profile Sun XP/conv KingKong XP/conv Combined harder
Light (5t) 12 ~6 ~2.5Γ total time
Medium (10t) 29 ~14 ~2.6Γ
Heavy (15t) 44 ~21 ~2.5Γ
Stage triggers identical to Unicorn/Sun (L0/1/7/15/25/40/55/72/86/99).
# Display ordering
Adds `display_order: Option<i32>` to TemplateMeta. Builtin ladder:
unicorn display_order=1 (Easy)
sun display_order=2 (Medium)
kingkong display_order=3 (Hard)
EggPicker.tsx sorts by source β display_order β name. Templates
without `display_order` (user-imported community ones) fall to the
tail and sort by name there.
# Visual + flavor
Labels: "Hard" (black/gold) + "Challenge" (gold/black) β gives the
row an immediately ominous chip pair vs Sun's warm Medium/Wukong.
Stage flavor reads as an evolution arc: obsidian egg β young
primate β silverback β armoured stride β first circuits β cyborg
legend. Default pet name "KingKong"; species flavor "Forged in
obsidian, ascending in titanium."
# Coverage
- `builtin_dirs_exist` now asserts kingkong/ is bundled in
include_dir!
- new `pick_template_kingkong_loads_and_creates_pet` integration
test pins end-to-end load + snapshot (matches the unicorn /
sun canaries β every builtin gets its own load smoke test)
- 310/310 lib tests pass; desktop bundle + tsc clean
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.
Summary
Live audit on a heavy η¨θ§ηΈ (Unicorn) session showed:
sum(xp_event)= 988pet_state.total_xp= 910Root cause:
StateManager::apply_deltadoes a non-atomic read-modify-write onpet_state. Concurrent ingest pairs race β both tasks read the same prior xp, both writeprior + own_delta, the later write overwrites the earlier. Thexp_eventrow is still written (unique by UUID, no dedup conflict), so the events database disagrees with the state row.Fix
Single
tokio::sync::Mutex<()>field onStateManager, locked across the whole read-modify-write in bothapply_deltaandrebuild. petpet runs at most one active pet at a time, so a global mutex is the right granularity β contention is bounded by live ingest rate (a few events/sec worst-case).If we ever support multi-pet concurrent ingest, swap for a per-pet mutex map (
HashMap<pet_id, Arc<Mutex<()>>>behind a parentRwLock).Why not
BEGIN IMMEDIATEtransaction?Cleaner long-term, but
DbHandleexposes single-statementexecuteshapes, not multi-statement transaction blocks. The Mutex is mechanically equivalent here β oneDbHandle, one active pet, write contention = mutex contention. Worth revisiting if we ever:Regression test
apply_delta_serialises_concurrent_ingest:apply_delta(+1)tasks against one petpet_state.total_xpPre-fix: probabilistic failure (some tasks would race and lose XP). Post-fix: 5/5 reruns clean, 309/309 lib tests pass.
π€ Generated with Claude Code