Found during an adversarial dogfooding pass on main @ 6a40258 (release binary, Linux).
Repro
d=$(mktemp -d)
APS_HOME=$d aps set profile '{"name":"base","version":1}' >/dev/null
( APS_HOME=$d aps set profile '{"name":"parent","version":99}' >/dev/null ) &
( APS_HOME=$d aps set profileName "slice-name" >/dev/null ) &
wait
APS_HOME=$d aps get profile
Observed over 12 trials:
- 7 ended
{"name":"slice-name","version":1}: the parent write of version:99 was silently lost
- 2 ended
{"name":"parent","version":99}: the slice write was lost
- 3 landed both updates
Both writers exit 0 and report success in every trial, so callers cannot detect the loss.
Why
A Slice set is a read-modify-write over the whole parent document: StateStore.set(.profileName) refreshes FileState from disk, mutates name, and rewrites profile.json; DynamicKeyStorage.sliceSet does the same for user-defined slices. #91 fixed the stale in-process cache, but there is no cross-process serialization on the document file (the SchemaFileLock only covers schema.json), so a parent writer and a slice writer interleave read/write and one update is dropped.
Two concurrent aps set profile calls are plain last-writer-wins on the whole document, which is defensible. The slice case is worse: partial updates amplify the loss, and the version-preservation guard added in #91 (expectedVersion) only compares against the post-refresh read, so it cannot see a peer that committed in between.
Suggestions
- Take a per-file lock (reuse the
SchemaFileLock pattern) around FileState/Slice read-modify-write, or
- Detect the interleaving (compare the on-disk document again at write time) and fail with a conflict error instead of silently dropping the peer update, and/or
- Document the semantics in specs/state-store if silent last-writer-wins is the intended contract.
Found during an adversarial dogfooding pass on main @ 6a40258 (release binary, Linux).
Repro
Observed over 12 trials:
{"name":"slice-name","version":1}: the parent write ofversion:99was silently lost{"name":"parent","version":99}: the slice write was lostBoth writers exit 0 and report success in every trial, so callers cannot detect the loss.
Why
A Slice set is a read-modify-write over the whole parent document:
StateStore.set(.profileName)refreshes FileState from disk, mutatesname, and rewritesprofile.json;DynamicKeyStorage.sliceSetdoes the same for user-defined slices. #91 fixed the stale in-process cache, but there is no cross-process serialization on the document file (theSchemaFileLockonly coversschema.json), so a parent writer and a slice writer interleave read/write and one update is dropped.Two concurrent
aps set profilecalls are plain last-writer-wins on the whole document, which is defensible. The slice case is worse: partial updates amplify the loss, and the version-preservation guard added in #91 (expectedVersion) only compares against the post-refresh read, so it cannot see a peer that committed in between.Suggestions
SchemaFileLockpattern) around FileState/Slice read-modify-write, or