DynamoDB Streams: Session Expiry Hooks for Multi-Turn Coding Agents

0 views

This guide focuses on DynamoDB Streams for production systems, with practical trade-offs for reliability, security, and cost.
A coding agent session dies when the user closes the IDE tab — or six hours later when nobody does. You stored connectionId, scratchpad S3 prefixes, and short-lived KMS/tool grants keyed by sessionId with a TTL attribute. TTL deletes the item. Nothing cleans the WebSocket, the scratch objects, or the grant — unless a DynamoDB Stream REMOVE event wakes a Lambda that finishes the job. Session expiry hooks are how multi-turn agents fail closed on time.

⚡ TL;DR: Enable TTL + Streams on the session table; handle REMOVE (and distinguish TTL vs explicit delete). Cleanup: API Gateway DeleteConnection, revoke grants, delete scratch prefixes. Mind TTL’s best-effort delay. Pair with API Gateway WebSockets for multi-turn agents, KMS Decrypt Grants, and kill switches in AppConfig feature flags. Never rely on $disconnect alone.

DynamoDB Streams: production guidance

API Gateway WebSocket $disconnect fires on clean close and many network drops — not on “user walked away with the laptop open,” not on half-open sockets you never noticed, and not when you only stored state in DynamoDB for a reconnectable sessionId. TTL-based expiry is the backlog sweeper; Streams make expiry actionable.

// Session item shape
type SessionItem = {
  pk: string; // session#${sessionId}
  sk: "meta";
  connectionId?: string;
  scratchPrefix: string; // s3://.../sessions/abc/
  grantId?: string;
  tenantId: string;
  ttl: number; // epoch seconds — DynamoDB TTL attribute
};

Enable TTL + Streams

  1. Pick a numeric attribute (ttl) — epoch seconds
  2. Enable TTL on that attribute in the table
  3. Enable Streams with NEW_AND_OLD_IMAGES (you need OLD image on REMOVE to know what to clean)
  4. Event source mapping → cleanup Lambda
// ✅ Stream handler — session expiry cleanup
import type { DynamoDBStreamHandler } from "aws-lambda";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import {
  ApiGatewayManagementApiClient,
  DeleteConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import {
  S3Client,
  ListObjectsV2Command,
  DeleteObjectsCommand,
} from "@aws-sdk/client-s3";
import {
  KMSClient,
  RetireGrantCommand,
} from "@aws-sdk/client-kms";

const apigw = new ApiGatewayManagementApiClient({
  endpoint: process.env.WS_CALLBACK_URL!,
});
const s3 = new S3Client({});
const kms = new KMSClient({});
const BUCKET = process.env.SCRATCH_BUCKET!;

export const onSessionStream: DynamoDBStreamHandler = async (event) => {
  for (const rec of event.Records) {
    if (rec.eventName !== "REMOVE") continue;
    const oldImage = rec.dynamodb?.OldImage;
    if (!oldImage) continue;

    const userIdentity = (rec as any).userIdentity;
    const ttlExpiry =
      userIdentity?.type === "Service" &&
      userIdentity?.principalId === "dynamodb.amazonaws.com";

    const session = unmarshall(oldImage as any) as SessionItem;

    // ✅ Always cleanup; optionally metric-tag TTL vs explicit delete
    await cleanupSession(session, ttlExpiry ? "ttl" : "explicit");
  }
};

async function cleanupSession(
  session: SessionItem,
  reason: "ttl" | "explicit"
) {
  if (session.connectionId) {
    try {
      await apigw.send(
        new DeleteConnectionCommand({ ConnectionId: session.connectionId })
      );
    } catch {
      // already gone — fine
    }
  }

  if (session.grantId && process.env.KMS_KEY_ID) {
    try {
      await kms.send(
        new RetireGrantCommand({
          KeyId: process.env.KMS_KEY_ID,
          GrantId: session.grantId,
        })
      );
    } catch (e) {
      console.error("retire_grant_failed", session.pk, e);
    }
  }

  await deletePrefix(session.scratchPrefix);
  console.log(
    JSON.stringify({
      msg: "session_cleaned",
      pk: session.pk,
      reason,
    })
  );
}

async function deletePrefix(prefix: string) {
  // prefix like "sessions/abc/" under BUCKET
  let token: string | undefined;
  do {
    const listed = await s3.send(
      new ListObjectsV2Command({
        Bucket: BUCKET,
        Prefix: prefix,
        ContinuationToken: token,
      })
    );
    const keys = (listed.Contents ?? [])
      .map((o) => o.Key)
      .filter((k): k is string => !!k);
    if (keys.length) {
      await s3.send(
        new DeleteObjectsCommand({
          Bucket: BUCKET,
          Delete: { Objects: keys.map((Key) => ({ Key })) },
        })
      );
    }
    token = listed.IsTruncated ? listed.NextContinuationToken : undefined;
  } while (token);
}

REMOVE events and TTL identity

DynamoDB marks TTL deletions in the stream record via userIdentity (type=Service, principalId=dynamodb.amazonaws.com). Use that to distinguish:

  • TTL expiry — normal idle timeout → full cleanup
  • Explicit DeleteItem — user logout / admin purge → same cleanup, different metric
  • REMOVE from TTL on a GSI? — TTL only deletes from the base table; design keys so the stream on the base table is authoritative
// ❌ Ignoring OldImage — nothing to clean
if (rec.eventName === "REMOVE") {
  // Stream view type KEYS_ONLY → you only have pk/sk
}

✅ Always use NEW_AND_OLD_IMAGES (or at least OLD_IMAGE) on session tables.

Pitfalls with TTL delay

TTL is not a precise scheduler. Items can linger for a window after ttl passes (often minutes; treat as best-effort within ~48 hours worst case historically — design for delay). Implications for agents:

  1. Do not use TTL as a security boundary alone — revoke grants on $disconnect and on TTL REMOVE
  2. Idle UX — if you must cut sockets at exactly 30 minutes, run a Scheduler/EventBridge sweep; use TTL as the backstop
  3. Cost — delayed deletes mean scratch S3 may live longer; set bucket lifecycle rules as a second backstop
  4. Reconnect race — user reconnects while TTL pending; your write path should refresh ttl on activity with a conditional update
// ✅ Heartbeat extends TTL on activity
import { UpdateCommand } from "@aws-sdk/lib-dynamodb";

export async function touchSession(sessionId: string) {
  const now = Math.floor(Date.now() / 1000);
  await ddb.send(
    new UpdateCommand({
      TableName: process.env.SESSION_TABLE!,
      Key: { pk: `session#${sessionId}`, sk: "meta" },
      UpdateExpression: "SET #ttl = :ttl, lastTouch = :now",
      ConditionExpression: "attribute_exists(pk)",
      ExpressionAttributeNames: { "#ttl": "ttl" },
      ExpressionAttributeValues: {
        :ttl: now + 60 * 60, // +1h idle
        :now: now,
      },
    })
  );
}

Ordering, partial failure, and idempotency

Stream Lambda can retry. Cleanup must be idempotent: DeleteConnection on a missing id, RetireGrant on an already-retired grant, S3 deletes on missing keys — all OK. Batch failures: use ReportBatchItemFailures so one bad session does not block the shard.

Also emit EMF: SessionExpired with reason=ttl|explicit and tenantId. If TTL lag grows, you will see it in “expired but connection still posting” errors on the callback API.

Checklist

  • [ ] Session table: TTL attribute enabled + Streams NEW_AND_OLD_IMAGES
  • [ ] Handler processes REMOVE with OldImage; detects TTL via userIdentity
  • [ ] Cleanup: WebSocket delete, grant retire, scratch prefix delete
  • [ ] Idempotent cleanup; partial batch failure reporting
  • [ ] Activity heartbeat refreshes TTL
  • [ ] S3 lifecycle backstop on scratch prefixes
  • [ ] Do not treat TTL as a precise timer or sole auth revoke path
  • [ ] Alarm on cleanup Lambda errors and iterator age

$disconnect cleans the happy path. TTL + DynamoDB Streams clean the abandoned path — closing sockets, retiring grants, and wiping scratch so yesterday’s agent session cannot haunt today’s tenants.

Related: DynamoDB Streams Outbox: Domain Events Without Dual-Write Failures; Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes; AWS Multi-Tenant SaaS Control Plane Architecture: API Gateway, ECS, DynamoDB, Step Functions, and EventBridge.


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.