DynamoDB Transactions: Atomic Tool-Ledger Writes for Coding Agents

0 views

Your coding agent just invoked apply_patch twice for the same tool_call_id. The model did not “decide” to double-spend — Lambda retried after a timeout, Step Functions retried the Task, and your three separate PutItem calls (debit quota, mark effect applied, bump session cursor) partially committed. One path wrote the side-effect row; another path charged 50k tokens again. DynamoDB Transactions (TransactWriteItems) are the unfair advantage: bind quota debit + idempotent effect ledger + session stamp into one atomic unit so retries are safe by construction. Pair with DynamoDB Streams session expiry for cleanup and S3 Conditional Writes for artifact uploads — this post is the ledger layer.

⚡ TL;DR: Model each tool side effect as a ledger row keyed by tenant#session#tool_call_id. Use TransactWriteItems to debit quota, insert the ledger row with a condition attribute_not_exists(pk), and update session state together. On TransactionCanceledException with ConditionalCheckFailed, treat as success (already applied). Related: Streams session expiry, Step Functions agent graphs, Verified Permissions Cedar.

Why separate PutItems fail under agent retries

Coding-agent runtimes retry aggressively: API Gateway timeouts, Lambda 15s cold paths, Bedrock throttles, Step Functions Retry on States.TaskFailed. If your tool runner does:

  1. UpdateItem quota − cost
  2. PutItem effect applied
  3. UpdateItem session cursor

…then a crash between 1 and 2 leaves a debited quota with no effect, and a crash between 2 and 3 leaves an applied effect with a stale cursor that the planner re-issues. Idempotency keys in Redis help for caches (ElastiCache scratchpads), but the source of truth for spend and side effects belongs in DynamoDB with transactional semantics.

Pattern Atomic? Retry-safe? When it breaks
Three Put/Update Any mid-flight timeout
Conditional Put only on effect Partial Mostly Quota still double-debits
TransactWriteItems (quota+ledger+session) Only on true business conflict
Saga + compensating debit Soft Complex Compensations fail too

Schema: tool ledger + quota + session

Keep three item shapes in one table (single-table friendly) or three tables — transactions work across tables in the same account/region (up to 100 items).

typescript
// ✅ Keys designed for idempotent tool side effects
// Ledger: PK = TENANT#acme#SESSION#s1  SK = TOOL#call_abc123
// Quota:  PK = TENANT#acme             SK = QUOTA#2026-09
// Session:PK = TENANT#acme#SESSION#s1  SK = META

type ToolLedgerItem = {
  pk: string;
  sk: string;
  tool_name: string;
  status: "applied" | "rejected";
  cost_tokens: number;
  created_at: string;
  ttl?: number;
};

type QuotaItem = {
  pk: string;
  sk: string;
  remaining_tokens: number;
  hard_cap: number;
};
typescript
// ❌ Soft "mark done" without uniqueness
await ddb.put({
  TableName: "agents",
  Item: { pk: sessionId, sk: `effect#${Date.now()}`, tool: name },
});
// Same tool_call_id can insert forever under retries

TransactWriteItems for debit + ledger + session

Authorize the tool first (Verified Permissions), then commit the ledger atomically before or immediately after the external side effect depending on your risk model. Prefer ledger-first with pending → applied for irreversible tools (git push, ticket create); for reversible tools, apply then ledger.

typescript
import {
  DynamoDBClient,
  TransactWriteItemsCommand,
  TransactionCanceledException,
} from "@aws-sdk/client-dynamodb";

const ddb = new DynamoDBClient({});

export async function commitToolLedger(opts: {
  tenantId: string;
  sessionId: string;
  toolCallId: string;
  toolName: string;
  costTokens: number;
  nextCursor: string;
}) {
  const ledgerPk = `TENANT#${opts.tenantId}#SESSION#${opts.sessionId}`;
  const ledgerSk = `TOOL#${opts.toolCallId}`;
  const month = new Date().toISOString().slice(0, 7); // YYYY-MM

  try {
    await ddb.send(
      new TransactWriteItemsCommand({
        TransactItems: [
          {
            Update: {
              TableName: "AgentRuntime",
              Key: {
                pk: { S: `TENANT#${opts.tenantId}` },
                sk: { S: `QUOTA#${month}` },
              },
              UpdateExpression:
                "SET remaining_tokens = remaining_tokens - :c",
              ConditionExpression: "remaining_tokens >= :c",
              ExpressionAttributeValues: {
                ":c": { N: String(opts.costTokens) },
              },
            },
          },
          {
            Put: {
              TableName: "AgentRuntime",
              Item: {
                pk: { S: ledgerPk },
                sk: { S: ledgerSk },
                tool_name: { S: opts.toolName },
                status: { S: "applied" },
                cost_tokens: { N: String(opts.costTokens) },
                created_at: { S: new Date().toISOString() },
              },
              ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)",
            },
          },
          {
            Update: {
              TableName: "AgentRuntime",
              Key: {
                pk: { S: ledgerPk },
                sk: { S: "META" },
              },
              UpdateExpression: "SET cursor = :cur, updated_at = :ts",
              ExpressionAttributeValues: {
                ":cur": { S: opts.nextCursor },
                ":ts": { S: new Date().toISOString() },
              },
            },
          },
        ],
      })
    );
    return { applied: true, duplicate: false };
  } catch (err) {
    if (err instanceof TransactionCanceledException) {
      const reasons = err.CancellationReasons ?? [];
      // Index 1 = ledger Put conditional — already applied
      if (reasons[1]?.Code === "ConditionalCheckFailed") {
        return { applied: true, duplicate: true };
      }
      if (reasons[0]?.Code === "ConditionalCheckFailed") {
        return { applied: false, duplicate: false, reason: "quota_exhausted" };
      }
    }
    throw err;
  }
}

Wire this into your Step Functions tool Task (agent graphs): on duplicate: true, return the cached tool result from Redis/S3 instead of re-running the side effect.

Pending → applied for irreversible tools

For git_push, create_pr, or paid API calls, use a two-phase ledger inside one or two transactions:

  1. Reserve: Transact Put ledger status=pending + debit quota (condition not exists).
  2. Perform external side effect.
  3. Finalize: Update ledger pending → applied (condition status=pending).

If step 2 fails, a sweeper (or DynamoDB Streams on TTL) refunds quota for stale pending rows older than N minutes. Do not leave orphan debits without a sweeper — that is how tenants “lose” budget mysteriously.

typescript
// ✅ Finalize only if still pending (second writer loses cleanly)
UpdateExpression: "SET #s = :applied",
ConditionExpression: "#s = :pending",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: {
  ":applied": { S: "applied" },
  ":pending": { S: "pending" },
},

Limits, IAM, and failure modes you must handle

  • 100 actions / 4 MB per transaction — keep ledger rows small; put patch blobs in S3 with If-None-Match.
  • Idempotent retries: same ClientRequestToken (optional) + stable tool_call_id.
  • IAM: grant dynamodb:ConditionCheckItem, PutItem, UpdateItem, TransactWrite on the table ARNs only — not *.
  • Hot partitions: TENANT#id quota keys can throttle under bursty agents; shard quota by hour (QUOTA#YYYY-MM-DD#HH) if a single tenant burns thousands of tools/min.
  • Observability: log tool_call_id, duplicate, reason as structured JSON for Logs Insights forensics.
Symptom Likely cause Fix
Double PR created Ledger after side effect, no pending Pending-first + sweeper
Quota jumps by 2× Non-transactional debit Move debit into TransactWrite
Frequent TransactionConflict Hot session META Soft cursor in Redis; META less often
ConditionalCheckFailed on quota Cap hit Surface to planner; AppConfig kill switch

Production checklist

  • [ ] Every irreversible tool has a stable tool_call_id from the planner (not uuid() inside the tool Lambda).
  • [ ] Quota debit + ledger Put + session update are one TransactWriteItems (or pending/finalize pair).
  • [ ] ConditionalCheckFailed on ledger Put is treated as success/duplicate, not error.
  • [ ] Sweeper refunds stale pending rows; Streams TTL hooks clean sessions.
  • [ ] IAM least-privilege on table; no god-key env credentials (KMS grants pattern).
  • [ ] Metrics: ToolLedgerDuplicate, QuotaExhausted, TxnConflict per tenant.
  • [ ] Load-test retry storms (Lambda + Step Functions Retry) prove zero double side effects.

Transactions do not make bad tool design safe — they make honest retries safe. Put the ledger first, name the tool_call_id, and stop treating DynamoDB like three lucky PutItems.

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.