Skip to content

Recipe: Reservation with auto release

Eugene Lazutkin edited this page Apr 23, 2026 · 1 revision

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 nightly DELETE FROM reservations WHERE held_until < now() cron.

Pattern: one row per held reservation; post is first-writer-wins; patch / delete guard on holder identity; a periodic resumable sweep reaps expired rows via asOf against the stamped createdAt.

Why DynamoDB makes you compose

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.

The pattern

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.

Reserve

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:

  • holderId in the condition defends against ABA. Using -version = :observed alone isn't enough — the stolen row's version counter starts fresh at 1 after 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.
  • -createdAt is reset explicitly. The stamp hook only runs when the field is undefined (not on takeover of an existing row), so passing -createdAt: now overrides the original createdAt. Without this, the sweep would pick the stolen row up immediately — it was created before asOf.

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.

Extend

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.

Release

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.

Check availability

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.

The cleanup sweep

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
    }
  );
};
  • asOf AND-merges <createdAtField> <= :asOf into the Scan FilterExpression. Requires createdAtField on the adapter (throws CreatedAtFieldNotDeclared otherwise).
  • maxItems is a page-boundary soft cap — the current page finishes, the result carries a cursor.
  • Re-invoke with {resumeToken: previous.cursor} to continue. Safe to re-run with the same params — DynamoDB Delete is 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).

Alternative: DynamoDB TTL

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 heldUntil either has to be in seconds or have a parallel -ttl field in seconds — Math.floor(heldUntil / 1000) stamped by a second hook or adjusted in prepare.

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.

Cost and capacity

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).

When this pattern fits

  • One active hold per resource (exclusive reservation semantics).
  • Holds are short-lived (minutes to hours) — the asOf sweep 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.

When it doesn't fit

  • 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.

Related

Clone this wiki locally