Skip to main content
← Back to list
01Issue
FeatureTriagedSwamp CLI
Assigneesstack72

Relationships

#1451 Add rollbackOnFailure for method-level write atomicity

Opened by stack72 · 7/28/2026

Summary

Add an opt-in rollbackOnFailure flag on method definitions that provides all-or-nothing write semantics. When a method declares rollbackOnFailure: true, its writes use a deferred-latest mode: data persists to disk immediately but the latest marker is not advanced. On success, markers advance. On failure, version directories are deleted. No TOCTOU window.

This is the direct fix for #1430 — the method-level complement to the workflow-level guard (#1437, shipped in #1993) and resume --from (#1444).

Motivation

writeResource() commits per-call. A method that writes 96 resources and fails at resource 60 leaves 59 resources persisted as latest with no trace of the failed execution. For batch operations (instrument readings, bulk data transforms, multi-resource provisioning), the incomplete set looks complete to any consumer reading data.latest().

The workflow layer now handles this via guards — downstream steps won't consume partial data. But the data layer still has the problem for standalone method run and for any direct data.latest() consumer outside a workflow context.

Design

The rollbackOnFailure flag

An optional boolean on MethodDefinition. Defaults to false (current behavior unchanged).

methods: {
  readAll: {
    description: "Read all 96 wells",
    rollbackOnFailure: true,
    arguments: z.object({}),
    execute: async (args, ctx) => {
      for (const well of wells) {
        const reading = await instrument.read(well);
        await ctx.writeResource("reading", well, reading);
      }
      await ctx.writeResource("scan", "complete", { wellCount: 96 });
      return {};
    },
  },
}

Deferred-latest write mode

When rollbackOnFailure: true:

  1. During execution: writeResource() persists the version directory and content to disk but skips the latest marker update and the catalog is_latest flag. Read-after-write within the method returns the previous version — the method author has the data in variables already.

  2. On success: Advance all latest markers for the collected handles. Data becomes visible to data.latest().

  3. On failure: Delete the version directories for all collected handles. Safe — latest markers were never advanced, no concurrent reader saw the data. No TOCTOU window.

Why deferred-latest, not rollback-after-write

We considered advancing markers during writes and rolling back on failure (delete + restore markers). This has a TOCTOU window: between the first write and the rollback completion, concurrent readers can see data that will be deleted. Deferred-latest eliminates this entirely — markers are never advanced, so there's nothing to roll back.

Two completely independent write paths

CRITICAL: The default path (rollbackOnFailure not set) must be completely unchanged:

  • writeResource() persists to disk AND updates the latest marker immediately
  • readResource() sees the write via the marker
  • On failure, writes persist (current behavior)
  • Zero behavioral change for existing methods

The fork point is in createResourceWriter — check the flag, choose the write mode. The two paths share no mutable state.

Implementation approach

  • Add a saveDeferred() method to UnifiedDataRepository (separate from save(), not a flag — prevents accidental misuse in the default path)
  • createResourceWriter checks the rollbackOnFailure flag and calls saveDeferred() instead of save() when set
  • Same treatment for createFileWriterFactory
  • InProcessExecutor on success: call a new advanceLatestMarkers() for collected handles
  • InProcessExecutor on failure: call repo.delete(type, modelId, name, version) for each handle
  • DefaultMethodExecutionService: thread the rollbackOnFailure flag through to the executor
  • Marker advancement on success should handle partial failure gracefully — log and continue if one marker fails to advance

Remote worker path

The deferred-latest mode must apply on the orchestrator's data plane when the method declares rollbackOnFailure. The worker's HTTP write requests need a flag indicating the write mode so the orchestrator skips the latest marker on its end.

The method author's contract

rollbackOnFailure: true means:

  • "My writes are a batch — commit them all on success, discard them all on failure"
  • "I don't need to read back my own writes during execution — I have the data in variables"
  • "Partial writes from my method are garbage, not a partial view of reality"

Methods that interact with external systems and write data reflecting real-world state should NOT set this flag — their writes may be the most accurate picture of reality even if the method failed.

Files affected

File Change
src/domain/models/model.ts Add rollbackOnFailure?: boolean to MethodDefinition
src/domain/models/data_writer.ts Check flag, call saveDeferred() when set. Same for createFileWriterFactory
src/infrastructure/persistence/unified_data_repository.ts Add saveDeferred() — writes version dir + content, skips latest marker and is_latest catalog flag
src/domain/models/in_process_executor.ts On success: advance markers. On failure: delete version dirs
src/domain/models/method_execution_service.ts Thread rollbackOnFailure flag to executor
src/worker/remote_method_context.ts Deferred-latest mode for remote writes
src/worker/exec_dispatch.ts Thread flag through remote dispatch
design/models.md Document rollbackOnFailure semantics
  • #1430 — parent issue (this is the direct fix)
  • #1437 — guard field (shipped in #1993)
  • #1444 — resume --from (shipped)
  • #1446 — docs update for guard and resume
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW

Triaged

7/29/2026, 12:09:35 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack727/29/2026, 12:07:00 AM

Sign in to post a ripple.