Skip to content

fix: atomically preserve reconciliation snapshots - #42

Merged
codeforester merged 2 commits into
mainfrom
bug/30-20260918-bug-preserve-the-previous-reconciliation-snapshot-when-a-sta
Sep 19, 2026
Merged

codeforester merged 2 commits into
mainfrom
bug/30-20260918-bug-preserve-the-previous-reconciliation-snapshot-when-a-sta

Conversation

@codeforester

Copy link
Copy Markdown
Contributor

Fixes #30

Comment thread src/base_cli_demo/cli.py
staged_path = Path(staged.name)
staged.write(serialized)
staged.flush()
os.fsync(staged.fileno())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: file-permission regression on every reconcile

tempfile.NamedTemporaryFile creates the staged file with mode 0o600 (owner-only), and os.replace moves that file (with its own permission bits) onto last-reconciliation.json — it does not preserve whatever mode the destination previously had. Before this PR, state_path.write_text(...) created/kept the file at the umask-derived mode (typically 0o644) and that mode was stable across rewrites.

Reproduced locally:

existing file mode before second reconcile: 0o644
existing file mode after atomic replace:    0o600

So the very first reconcile after this change (and every one after it) silently narrows last-reconciliation.json to owner-only, breaking any other user/process/service that previously could read it (a monitoring sidecar, a different service account, a CI artifact collector, etc.), with no message or migration path.

Fix: either os.chmod(staged_path, 0o644) (or read+reapply the previous file's mode) before the _replace_state call, or make the intended permissions explicit/documented if 0600 is actually desired.

Comment thread src/base_cli_demo/cli.py
def _replace_state(staged_path: Path, state_path: Path) -> None:
"""Atomically publish a complete snapshot from the same filesystem."""

os.replace(staged_path, state_path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Durability gap: rename isn't fsynced, so the "atomic" guarantee is incomplete

os.fsync(staged.fileno()) (line ~113) only guarantees the staged file's content is durable before the rename. os.replace itself (here) updates the directory entry, but nothing calls os.fsync on state_path.parent afterward. On a crash/power-loss right after the rename but before the directory entry is flushed, some filesystems/mount options can still lose the rename and leave the previous (stale) snapshot — or in rarer cases an inconsistent directory entry — despite the docstring's claim to "atomically publish a complete snapshot."

Notably, this codebase's own base_cli._private_files.write_private_json (the framework's internal equivalent for private JSON state) does exactly this extra step via _sync_directory/os.fsync(parent_fd) after the rename — so the fuller pattern is already established practice here, just not applied in this new consumer-side implementation.

Suggested fix: after _replace_state, open and fsync state_path.parent (best-effort, ignoring OSError since directory fsync isn't supported everywhere).

Comment thread src/base_cli_demo/cli.py Outdated
staged_path = None
except (OSError, TypeError, ValueError) as exc:
raise click.ClickException(
"Could not persist the reconciliation snapshot; the previous "

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: misleading error message on the very first reconcile

The ClickException unconditionally states "the previous snapshot was left unchanged," but on the first-ever reconcile in a fresh state_dir there is no previous snapshot — last-reconciliation.json simply doesn't exist yet. A user hitting a failure on first run (e.g. disk full, permission denied on the parent dir) gets a message implying a prior snapshot exists and was preserved, which is inaccurate and could confuse debugging.

Consider branching the message on state_path.exists() (checked before entering the try, or captured before mutating anything), e.g. "...; no previous snapshot existed" vs "...; the previous snapshot was left unchanged."

@codeforester
codeforester merged commit 303b65d into main Sep 19, 2026
11 checks passed
@codeforester
codeforester deleted the bug/30-20260918-bug-preserve-the-previous-reconciliation-snapshot-when-a-sta branch September 19, 2026 11:05
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.

bug: preserve the previous reconciliation snapshot when a state write fails

1 participant