-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe: Reservation with auto release
The SQL-equivalent question this answers: let a user hold a resource (rental car, meeting room, seat, flash-sale item) for a fixed window (say, 10 minutes); if they don't finalize, the hold releases automatically so someone else can take it. Classically implemented in SQL as
SELECT ... FOR UPDATE+ an app-level timer + a nightlyDELETE FROM reservations WHERE held_until < now()cron.Pattern: one row per held reservation;
postis first-writer-wins;patch/deleteguard on holder identity; a periodic resumable sweep reaps expired rows viaasOfagainst the stampedcreatedAt.
DynamoDB has none of the SQL primitives this pattern leans on:
- No
SELECT ... FOR UPDATE/ row lock — every write is optimistic only. - No triggers — expired rows don't self-delete on schedule.
- No native cron — you have to schedule the sweep yourself (EventBridge, cron Lambda, etc).
The toolkit composes the pattern from three primitives shipped in 3.2.0–3.7.0:
| Need | Primitive |
|---|---|
| First-writer-wins reserve |
adapter.post(item) → ConditionalCheckFailedException on collision. |
| Safe release / extend under concurrent steals |
patch / delete with options.conditions (holder identity) + optional expectedVersion (OC). |
| Reap expired rows on a schedule |
deleteListByParams(params, {asOf, maxItems, resumeToken}) — scope-freeze + resumable pagination. |
Native DynamoDB TTL is the fourth piece — free, eventually-consistent cleanup. Use both: your sweep is the authoritative one; TTL is the backstop. See Alternative: DynamoDB TTL below.
import {Adapter, stampCreatedAtEpoch} from 'dynamodb-toolkit';
const TTL_MS = 10 * 60 * 1000; // 10 min
const reservations = new Adapter({
client: docClient,
table: 'reservations',
keyFields: ['resourceId'], // one row per held resource
technicalPrefix: '-',
versionField: '-version', // CAS counter for extend / release
createdAtField: '-createdAt', // epoch ms, enables asOf sweep
hooks: {
prepare: stampCreatedAtEpoch('-createdAt') // writes createdAt on post only
}
});Reservation shape when held:
{
resourceId: 'car-123',
holderId: 'user-42',
heldUntil: 1714428000000, // epoch ms
'-version': 1,
'-createdAt': 1714427400000
}The toolkit's built-in prepare step initialises -version on post; the user-supplied stampCreatedAtEpoch hook stamps -createdAt on first insert only (patches and round-tripped reads are untouched). Both fields pass through revive unstripped — technicalPrefix fields are normally stripped, but versionField / createdAtField are preserved so callers can round-trip them into subsequent conditions.
First-writer-wins via post:
const reserve = async (resourceId, holderId) => {
try {
await reservations.post({
resourceId,
holderId,
heldUntil: Date.now() + TTL_MS
}, {returnFailedItem: true});
return {ok: true};
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
const existing = err.Item; // from returnFailedItem
const expired = existing && existing.heldUntil < Date.now();
return {ok: false, existing, expired};
}
throw err;
}
};returnFailedItem: true sets ReturnValuesOnConditionCheckFailure: 'ALL_OLD'; the thrown error carries the colliding row on .Item. One round-trip tells you whether a steal is viable.
Stealing an expired reservation (optional) — atomic take-if-stale via put({force}) with conditions that pin both "still expired" and "still the holder we saw":
const reserveOrSteal = async (resourceId, holderId) => {
const first = await reserve(resourceId, holderId);
if (first.ok || !first.expired) return first;
// Stale holder; try to take it over. Both conditions must pass together —
// defends against a race where another caller stole, or the original holder
// extended, between our read and our write.
const now = Date.now();
try {
await reservations.put({
resourceId,
holderId,
heldUntil: now + TTL_MS,
'-createdAt': now // reset so the sweep doesn't immediately reap
}, {
force: true, // bypass the built-in OC check; ours are explicit
conditions: [
{path: 'heldUntil', op: '<', value: now}, // current row still expired
{path: 'holderId', op: '=', value: first.existing.holderId} // still the same stale holder
]
});
return {ok: true, stolen: true};
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') return {ok: false, raced: true};
throw err;
}
};Two notes on correctness:
-
holderIdin the condition defends against ABA. Using-version = :observedalone isn't enough — the stolen row's version counter starts fresh at1after any delete-then-post cycle, so a stale observed version can collide with a freshly-reposted row. Pinning on the old holder's ID is the atomic "still stale" guard. -
-createdAtis reset explicitly. The stamp hook only runs when the field isundefined(not on takeover of an existing row), so passing-createdAt: nowoverrides the original createdAt. Without this, the sweep would pick the stolen row up immediately — it was created beforeasOf.
With force: true the built-in _applyVersionToItem still runs — the stolen row gets a fresh -version (starting at 1 since we don't carry a prior observed value). Subsequent extend / release calls see it via revive and include expectedVersion as usual.
Holder wants more time. Guard on holderId (defends against stealing) AND on expectedVersion (defends against any mid-flight concurrent write):
const extend = async (resourceId, holderId, observedVersion) => {
await reservations.patch(
{resourceId},
{heldUntil: Date.now() + TTL_MS},
{
expectedVersion: observedVersion,
conditions: [{path: 'holderId', op: '=', value: holderId}]
}
);
};On ConditionalCheckFailedException the caller learns "not yours any more" — either expired-and-stolen or otherwise overwritten. Typical response: abort the in-flight work and abandon the hold.
Holder is done. Same guards — otherwise a stale release could delete somebody else's reservation:
const release = async (resourceId, holderId, observedVersion) => {
await reservations.delete(
{resourceId},
{
expectedVersion: observedVersion,
conditions: [{path: 'holderId', op: '=', value: holderId}]
}
);
};Idempotent-safe: re-running after a successful release throws CCF ("not yours"); caller can treat it as no-op.
Reads don't need conditions — availability is a post-read decision:
const isAvailable = async resourceId => {
const held = await reservations.getByKey({resourceId});
return !held || held.heldUntil < Date.now();
};The versionField and createdAt come back via revive (preserved by the built-in step), so callers can round-trip them into a subsequent extend or release without a second read.
DynamoDB won't delete expired rows for you (native TTL aside — see below). Run a periodic sweep via deleteListByParams with asOf scoped to the creation time of the oldest reservation that could still be live:
// Periodic job — EventBridge + Lambda, every 5 min, 15 min budget:
const sweepExpired = async (cursor) => {
const asOf = Date.now() - TTL_MS; // everything created before this is expired
return reservations.deleteListByParams(
{TableName: 'reservations'},
{
asOf,
maxItems: 5000, // Lambda-budget-sized chunk
resumeToken: cursor
}
);
};-
asOfAND-merges<createdAtField> <= :asOfinto theScanFilterExpression. RequirescreatedAtFieldon the adapter (throwsCreatedAtFieldNotDeclaredotherwise). -
maxItemsis a page-boundary soft cap — the current page finishes, the result carries acursor. - Re-invoke with
{resumeToken: previous.cursor}to continue. Safe to re-run with the same params — DynamoDBDeleteis idempotent on missing items. - Returns
MassOpResult:{processed, skipped, failed, conflicts, cursor?}— failed rows preserve the SDK error for logging.
Pair with a cron that re-invokes until cursor is absent. For most reservation systems one sweep every half-TTL is sufficient (≤ 2× the TTL of any stale row before it's reaped).
Why asOf and not a heldUntil < now() FilterExpression? Both work. asOf (on createdAt) is monotonic — items only become eligible, never un-become. heldUntil can move (extensions), so a filter keyed on it may miss rows that were expiring but got extended mid-scan. asOf on createdAt reaps exactly the rows that were created early enough to be guaranteed-expired assuming fixed TTL. Pair with a holderId filter if you want to preserve certain holders (e.g., system-held rows).
DynamoDB's native TTL feature deletes items whose epoch-seconds TTL attribute is in the past. Enable it on the reservations table keyed on heldUntil:
// Table setting (via IaC or AWS Console):
TimeToLiveSpecification: {AttributeName: 'heldUntil', Enabled: true}- Free. No read / write capacity for TTL deletes.
- Eventually consistent. Up to 48 hours of lag on the documented SLA; typically minutes, but do not build on "deleted within N minutes of expiry."
-
No authoritative guarantee. If TTL hasn't run yet, the row is still visible. Read-side code still has to check
heldUntil < now(). -
Units matter. TTL takes epoch seconds, not milliseconds. Your stored
heldUntileither has to be in seconds or have a parallel-ttlfield in seconds —Math.floor(heldUntil / 1000)stamped by a second hook or adjusted inprepare.
When to use TTL alone: consumer-grade systems where eventually-consistent cleanup is fine and storage bloat from ~48h of stale rows is acceptable.
When to layer the sweep on top of TTL: anywhere the sweep can also observe / audit / cascade the deletion (log retention count, fire cleanup side-effects, delete related rows in other tables within the same run). The two compose cleanly — TTL reaps most of the long tail for free; the sweep enforces bounded lag for the rest.
Per reservation:
| Operation | Cost |
|---|---|
| Reserve (fast path) | 1 WCU. |
| Reserve (collided + steal) | 1 WCU for the attempt + 1 WCU for the steal (put-with-condition). |
| Extend | 1 WCU (patch is proportional to attributes touched; heldUntil is a scalar). |
| Release | 1 WCU. |
| Availability check | 1 RCU (eventually consistent — strong reads cost 2). |
Per sweep iteration:
- 1 RCU per 4 kB of data scanned, pre-filter.
- 1 WCU per deleted row.
- Network: a few kB per page, metadata only for the delete batch.
Partition key choice. Single-field resourceId partitions reservations per resource. If you have many resources and low per-resource contention (typical), this is fine. If you're modelling a flash-sale-style workload where one resource (concert-tickets-front-row) holds thousands of simultaneous reservation attempts, you'd want a compound pk like resourceId#holderId so attempts fan out across partitions — but that changes the pattern fundamentally (many-reservations-per-resource model rather than one-active-hold-per-resource).
- One active hold per resource (exclusive reservation semantics).
- Holds are short-lived (minutes to hours) — the
asOfsweep runs cheaply at a cadence < TTL. - Concurrent reservations on the same resource are the exception, not the norm.
- You have a periodic scheduler (EventBridge, cron Lambda) to run the sweep.
-
Many simultaneous holds per resource (e.g. ticketing). Switch to a counter-plus-claims pattern where reservations are per-
(resource, holder)rows. - Second-scale TTLs with zero tolerance for stale reads. DynamoDB's eventual consistency + sweep-cadence both lag; you need a lower-latency store for the auth-check.
- Cross-region strong consistency required. DynamoDB Global Tables are eventually consistent across regions; a holder in us-east might still appear available in eu-west for a second or two.
-
Reservation audit / history required (need to see who held what when). This pattern overwrites the row on each new reservation; for history, use a composite key
(resourceId, createdAt)and keep expired rows until an audit sweep consumes them.
-
Adapter: Constructor options —
versionField,createdAtField,technicalPrefix. -
Adapter: Hooks — Canned prepare builders —
stampCreatedAtISO/stampCreatedAtEpoch. - Concepts → Optimistic concurrency and Scope-freeze.
-
Concepts → Mass-op envelope —
MassOpResult,maxItems,resumeToken. -
Mass operations — the primitive catalogue
deleteListByParamssits in.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History