Skip to content

fix(datastore): signal cache writes to sync service fast path#1225

Merged
stack72 merged 1 commit intomainfrom
worktree-gleaming-soaring-bee
Apr 24, 2026
Merged

fix(datastore): signal cache writes to sync service fast path#1225
stack72 merged 1 commit intomainfrom
worktree-gleaming-soaring-bee

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 24, 2026

Summary

The s3/gcs zero-diff fast path in DatastoreSyncService maintains a
local-dirty watermark that it flips during its own pushFile-equivalent.
Swamp core writes into the cache directly from the persistence
repositories (UnifiedDataRepository, YamlOutputRepository,
YamlWorkflowRunRepository, YamlEvaluatedDefinitionRepository), which
bypasses that hook — the next pushChanged short-circuits past the
write and the data lives only in the local cache until eviction. Single
writer setups against a low-traffic bucket are most exposed; high
traffic masks the bug because unrelated remote mutations invalidate the
sidecar.

Fix

  • Add a required markDirty() method to DatastoreSyncService (mirrored
    in packages/testing/datastore_types.ts).
  • Every public mutation on the four datastore-tier repositories calls
    notifyDirty() before any cache write begins, so a crash mid-write
    still leaves the watermark dirty — per-method (not per-atomicWrite)
    keeps the repositories clean and the hook is idempotent anyway.
  • Single sync service instance threads through
    requireInitializedRepoUnlockedacquireModelLocks and through the
    serve context (OpenServerState, ConnectionContext,
    WebhookServiceDeps), so an implementation that caches the sidecar
    flag in memory stays coherent across the markDirty hook and the
    pull/push fast-path read. The `acquireModelLocks` param is optional
    for backward compatibility; every caller in the repo now passes one.
  • Document the contract under the Zero-Diff Fast Path section in
    `design/datastores.md`.
  • Filesystem datastores wire no service; `notifyDirty` is a no-op for
    them.

Extension follow-up

The required interface change surfaces as a compile-time signal to
extension authors (`@swamp/s3-datastore`, `@swamp/gcs-datastore`,
swamp-club/166) that they need to implement `markDirty()` — trivial:
it's the same primitive as their internal `pushFile` hook's dirty-flip.

Test Plan

  • New repository contract tests pin `markDirty` invocation on every
    public mutation, including the negative cases (reads, dry-run GC,
    no-op delete) where it must not fire.
  • Existing `datastore_sync_service_test.ts` still passes; 12 stub
    sync services across tests updated to include the new field.
  • `testing_package_compat_test.ts` structurally verifies the
    `@systeminit/swamp-testing` mirror still matches core.
  • `deno check`, `deno lint`, `deno fmt --check`,
    `deno run test` (4854 passed, 0 failed, 1 ignored),
    `deno run compile` all clean.

🤖 Generated with Claude Code

The s3/gcs zero-diff fast path in DatastoreSyncService keeps a local-dirty
watermark that it flips during its own pushFile-equivalent. Swamp core
writes into the cache directly from the persistence repositories
(UnifiedDataRepository, YamlOutputRepository, YamlWorkflowRunRepository,
YamlEvaluatedDefinitionRepository), which bypasses that hook — the next
pushChanged short-circuits past the write and the data lives only in the
local cache until eviction.

Add a required markDirty() method to DatastoreSyncService. Every public
mutation on the four datastore-tier repositories calls it before any
cache write begins, so a crash mid-write still leaves the watermark
dirty. A single sync service instance threads through
requireInitializedRepoUnlocked → acquireModelLocks and through the serve
context (OpenServerState, ConnectionContext, WebhookServiceDeps), so an
implementation that caches the sidecar flag in memory stays coherent
across the markDirty hook and the pull/push fast-path read.

Document the contract in design/datastores.md and mirror the interface
change in packages/testing/datastore_types.ts. Filesystem datastores
wire no service, so notifyDirty is a no-op for them.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

None.

Verdict

PASS — this PR makes no user-visible changes.

Every changed file is internal plumbing: the markDirty() hook is wired through four persistence repositories, threaded via requireInitializedRepoUnlocked / acquireModelLocks into the CLI commands and serve layer, and stubbed in tests. There are no new flags, no changes to help text, no changes to output format (log or JSON), and no changes to error messages. The design doc update (design/datastores.md) is developer-facing only. From the perspective of a CLI user, this fix is invisible — it only affects whether S3/GCS cache writes are correctly flushed on the next push.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Code Review

Clean, well-structured fix for a real data-loss bug. The design is sound: a thin MarkDirtyHook decouples repositories from the full DatastoreSyncService, and the shared sync-service instance threaded through requireInitializedRepoUnlockedacquireModelLocks → serve context keeps the in-memory watermark coherent.

Blocking Issues

None.

Suggestions

  1. Test gap for append and removeLatestMarker — the mutations call markDirty before writing test in unified_data_repository_test.ts covers save, allocateVersion/finalizeVersion, rename, delete, and collectGarbage, but append and removeLatestMarker are omitted despite being listed in the design/datastores.md contract. The code is wired correctly (lines 826 and 1012), but pinning them in the test would close the coverage gap.

  2. MarkDirtyHook drops the options parameterDatastoreSyncService.markDirty accepts options?: DatastoreSyncOptions (carrying an AbortSignal), but the MarkDirtyHook type alias is () => Promise<void> and the wiring calls syncService!.markDirty() without forwarding options. Since markDirty is documented as "idempotent and cheap" this is likely fine, but the asymmetry could confuse extension authors who see the parameter on the interface but never receive a signal. Consider aligning the two signatures (either drop options from the interface or thread it through the hook).

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Adversarial Review

Traced every code path across 28 changed files. The fix is structurally sound — single sync-service instance semantics are maintained correctly through all wiring paths (locked, unlocked, serve, open, webhook, connection, retry), and every public mutation method on all four datastore-tier repositories calls notifyDirty() before the first cache write.

Critical / High

None found.

Medium

  1. markDirty has no timeout or cancellation pathMarkDirtyHook is () => Promise<void> with no AbortSignal. The DatastoreSyncService.markDirty interface accepts options?: DatastoreSyncOptions (which includes a signal), but the hook wrapper discards it. If a misbehaving extension does I/O in markDirty() and hangs, every repository mutation hangs with no timeout — unlike pullChanged/pushChanged which get Promise.race protection from the coordinator. The contract says "must be idempotent and cheap" but there's no enforcement. Not blocking because: (a) the typical implementation flips a boolean, (b) repository methods don't carry a signal to forward anyway, and (c) the design doc makes the obligation clear. But worth noting for extension SDK hardening.

Low

  1. Non-null assertion on let binding in closure (src/cli/repo_context.ts:414, :543) — syncService ? () => syncService!.markDirty() : undefined captures the let binding. Currently safe because nothing reassigns syncService after the closure is created, but fragile under future edits. A local const copy (const svc = syncService; markDirty: svc ? () => svc.markDirty() : undefined) would let TypeScript narrow without the !. Cosmetic — no runtime risk today.

  2. collectGarbage marks dirty even when GC finds nothing to remove (src/infrastructure/persistence/unified_data_repository.ts:1558) — when dryRun is false, notifyDirty() fires before scanning. If the scan finds zero versions to collect, the watermark was dirtied unnecessarily. Harmless per the idempotency contract, but a theoretical extra push cycle for an empty GC run.

Verification performed

  • Confirmed all mutation methods (save, append, delete, rename, allocateVersion, finalizeVersion, removeLatestMarker, collectGarbage, clearAll, deleteAllByWorkflowId) call notifyDirty() before their first write.
  • Confirmed all read methods (findByName, findAllForModel, listVersions, getContent, findById, findAll, findAllGlobal, sync variants) do NOT call notifyDirty().
  • Confirmed deleteAllByWorkflowId correctly skips notifyDirty() when count is 0 (no-op path).
  • Confirmed collectGarbage skips notifyDirty() for dry-run.
  • Confirmed the acquireModelLocks retry path at line 803 propagates customSyncService (preserving single-instance semantics across retries).
  • Confirmed the null/undefined dance in OpenServerState (syncService: DatastoreSyncService | null) correctly converts to undefined via ?? undefined when passed to acquireModelLocks.
  • Confirmed all 12+ test stub sync services are updated with markDirty: () => Promise.resolve().
  • Confirmed the testing package compat test structurally verifies the new method.

Verdict

PASS — Clean, well-structured fix. The markDirty contract is correctly implemented across all four repository types, single-instance semantics are preserved through every wiring path, and tests pin both positive and negative cases. No blocking issues.

@stack72 stack72 merged commit ce11756 into main Apr 24, 2026
10 checks passed
@stack72 stack72 deleted the worktree-gleaming-soaring-bee branch April 24, 2026 22:45
keeb added a commit to keeb/swamp-mongodb-datastore that referenced this pull request Apr 24, 2026
swamp core systeminit/swamp#1225 made markDirty() a required third
method on DatastoreSyncService. Implementing it as a no-op: pushChanged
always walks the cache, so there's no fast-path watermark to invalidate.
The hook is in place for a future zero-diff optimization.

Bumps root manifest 2026.04.24.1 → 2026.04.24.2 and extension manifest
0.2.0 → 0.2.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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