Skip to content

Harden JSON-backed persistence: atomic writes + per-path serialization - #62

Merged
ttpears merged 1 commit into
mainfrom
hardening/atomic-json-persistence
Jul 22, 2026
Merged

Harden JSON-backed persistence: atomic writes + per-path serialization#62
ttpears merged 1 commit into
mainfrom
hardening/atomic-json-persistence

Conversation

@ttpears

@ttpears ttpears commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Problem

Every JSON-backed service persisted state with a read-modify-write followed by a direct fs.writeFile, with no locking and no atomic write. Two concrete risks:

  1. Torn/corrupt files. fs.writeFile truncates the target and streams bytes in place. A crash (or container kill) mid-write leaves a truncated file that fails JSON.parse on the next startup — losing users, TSIG keys, tokens, or backups.
  2. Lost updates. Services do read-modify-write against in-memory maps and then persist the whole set. Overlapping writers to the same file interleave and clobber each other. The clearest case: apiTokenService.verifyToken() fires a throttled lastUsedAt "touch" fire-and-forget, which can race a concurrent createToken/revokeToken and drop that change.

Affected services: tsigKeyService, apiTokenService, userService, backupService, plus webhookConfigService and ssoConfigService (same pattern).

Fix

New shared helper backend/src/utils/atomicJson.ts:

  • writeFileAtomic / writeJsonAtomic — write to a uniquely-named temp file in the same directory, fsync, then fs.rename over the target. Rename is atomic on the same filesystem, so a reader only ever observes the old complete file or the new complete file, never a partial one. The temp file is cleaned up on failure.
  • withFileLock — serializes all operations for a given absolute path through a per-path promise chain, so concurrent callers apply one at a time in call order. A rejecting task never wedges the queue for later callers, and the map entry is dropped once the chain drains (no unbounded growth).
  • removeFileLocked — deletes under the same lock (used by backup eviction, so a delete cannot interleave with a write to the same zone file).
  • flushFileLock — drains in-flight work for a path (graceful shutdown / test teardown).

Each service's write path now goes through the helper. backupService still writes/deletes multiple zone files per operation — each is now individually atomic and serialized, while distinct files stay concurrent.

Behavior is unchanged: same JSON.stringify(value, null, 2) on-disk shape; only how the bytes hit disk changed. Read paths and data shapes are untouched.

Tests

  • New backend/src/utils/__tests__/atomicJson.test.ts: asserts the final write is a rename onto the target (not in-place); a failed write leaves the existing target intact and complete JSON with no stray .tmp files; 50 concurrent writes to one path all serialize with the last write winning (no lost update); same-path tasks run strictly ordered and never overlap; a rejecting task does not wedge the queue; removeFileLocked serializes against writes.
  • Existing service test teardown for apiTokenService now drains the fire-and-forget write via flushFileLock before removing its temp dir.

Verification:

  • cd backend && npm run build — clean.
  • cd backend && npx jest39 suites, 369 tests, all green.

🤖 Generated with Claude Code

The JSON-backed services (tsigKey, apiToken, user, backup, webhookConfig,
ssoConfig) persisted with a read-modify-write plus a direct fs.writeFile.
Two failure modes:

- Torn writes: fs.writeFile truncates and streams in place, so a crash
  mid-write leaves a truncated/corrupt file that fails to parse on restart.
- Lost updates: overlapping writers to the same file (e.g. apiTokenService's
  fire-and-forget lastUsedAt touch racing a create/revoke) can interleave and
  clobber each other.

Add backend/src/utils/atomicJson.ts:
- writeFileAtomic/writeJsonAtomic write to a sibling temp file, fsync, then
  fs.rename over the target (atomic on the same filesystem), so a reader only
  ever sees the old or new complete file.
- withFileLock serializes all operations for a given absolute path through a
  per-path promise chain; a rejecting task never wedges the queue and the map
  entry is dropped when the chain drains.
- removeFileLocked deletes under the same lock (backup eviction), flushFileLock
  drains in-flight work (graceful shutdown / test teardown).

Route every service write through the helper. On-disk shape is unchanged
(same JSON.stringify(value, null, 2) output); only how bytes hit disk changes.
Read paths are untouched.

Add focused unit tests for the helper (temp+rename, target intact on failed
write, no lost update under 50 concurrent writes, ordered non-overlapping
execution, queue survives a rejection) and drain the fire-and-forget write in
the apiTokenService test teardown.
@ttpears
ttpears merged commit d424dd5 into main Jul 22, 2026
3 checks passed
@ttpears
ttpears deleted the hardening/atomic-json-persistence branch July 22, 2026 03:47
@ttpears ttpears mentioned this pull request Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant