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 GatewayDeleteConnection, 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$disconnectalone.
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
- Pick a numeric attribute (
ttl) — epoch seconds - Enable TTL on that attribute in the table
- Enable Streams with
NEW_AND_OLD_IMAGES(you need OLD image on REMOVE to know what to clean) - 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:
- Do not use TTL as a security boundary alone — revoke grants on
$disconnectand on TTL REMOVE - Idle UX — if you must cut sockets at exactly 30 minutes, run a Scheduler/EventBridge sweep; use TTL as the backstop
- Cost — delayed deletes mean scratch S3 may live longer; set bucket lifecycle rules as a second backstop
- Reconnect race — user reconnects while TTL pending; your write path should refresh
ttlon 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
REMOVEwith OldImage; detects TTL viauserIdentity - [ ] 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.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- LLM context windows: Tokens, Context Windows, and Why Models Forget Mid-Task
- PostgreSQL Performance Tuning: The Configuration Changes That Actually Matter
- Java Virtual Threads vs Traditional Threads: What Nobody Tells You
- LLM evaluation harness: Eval Harness Day One
Newly added
- DynamoDB Streams: Session Expiry Hooks for Multi-Turn Coding Agents
- S3 Conditional Writes: Idempotent Agent Artifact Uploads With If-None-Match
- Bedrock ApplyGuardrail API: Pre/Post Filters for Tool I/O in Coding Agents
- Lambda Destinations: Route Failed Agent Tool Invokes Without Silent Drops
- AppConfig Feature Flags: Kill Switches for Agent Tools Without Redeploy
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.