Idempotency Store

createIdempotencyStore(redis | pg, options?) is the cluster-wide store behind live.idempotent and the task runner. Three-state acquire(idempotencyKey):

  • acquired - first caller; run the handler.
  • pending - another caller is currently running the handler. Await its result.
  • result - the handler already completed. Return the cached result.

Setup

// Redis
import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/redis/idempotency';

const redis = createRedisClient();
const store = createIdempotencyStore(redis);
// Postgres
import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/postgres/idempotency';

const pg = createPgClient();
const store = createIdempotencyStore(pg);

Both implementations expose the same interface.

Wire into live.idempotent

import { live } from 'svelte-realtime/server';

export const charge = live.idempotent(
  async (ctx, amount) => stripe.charges.create({ amount, customer: ctx.user.id }),
  {
    keyFrom: (ctx, amount) => `${ctx.user.id}:${amount}`,
    store
  }
);

The wrapper namespaces the cache key as 'rpc:' + path + ':' + userKey before calling store.acquire(...).

API

MethodDescription
acquire(idempotencyKey, opts?)Returns { status: 'acquired' \| 'pending' \| 'result', result?, release? }.
purge(idempotencyKey)Force-evict the cached entry.
ready()Resolves once migrations are applied.

release(value, error?) is included on acquired. Call it with the handler’s return value (or error) to commit the result so subsequent retries see it.

Acquire shape

const slot = await store.acquire(key, { acquireTtlMs: 60_000, ttlMs: 48 * 60 * 60_000 });

if (slot.status === 'acquired') {
  try {
    const result = await runHandler();
    await slot.release(result);
    return result;
  } catch (err) {
    await slot.release(undefined, err);
    throw err;
  }
}

if (slot.status === 'pending') {
  return await slot.wait();
}

if (slot.status === 'result') {
  return slot.result;
}

Options

OptionDefaultDescription
acquireTtlMs60_000How long another caller waits on pending before falling through.
ttlMs48 * 60 * 60 * 1000 (48 h)How long result lives before eviction.
maxResultBytes256 * 1024 (256 KB)Reject release(result) payloads larger than this with IdempotencyResultTooLargeError. Pass Infinity to disable.
table (Postgres)'svti_idempotency'Override the table name.

Caps

idempotencyKey longer than 1024 characters throws (defense in depth). The wrappers calling into the store (live.idempotent, tasks.run / tasks.enqueue) apply a tighter 256-char cap at the API boundary.

release(result) payloads larger than maxResultBytes (default 256 KB) are rejected with a typed IdempotencyResultTooLargeError BEFORE writing to storage, leaving the slot in its pending state. The caller can then abort() to release immediately, or let acquireTtlMs expire it.

import { IdempotencyResultTooLargeError } from 'svelte-adapter-uws-extensions/redis/idempotency';
// or postgres/idempotency - the error class is re-exported from both

try {
  await slot.release(hugeResult);
} catch (err) {
  if (err instanceof IdempotencyResultTooLargeError) {
    // err.code === 'IDEMPOTENCY_RESULT_TOO_LARGE'
    // err.bytes      <- the actual size
    // err.maxBytes   <- the configured cap
    await slot.abort();
    throw new Error('handler returned too large; cannot cache for retry');
  }
  throw err;
}

The 256 KB default matches the operational shape both backends handle cleanly without metastable behavior (Redis pubsub fragmentation, Postgres NOTIFY caps, bus envelope cap). Past a few hundred KB the cache becomes a liability rather than an asset: handlers that legitimately return blobs that large should write them to object storage and cache the resulting URL instead.

Upgrading from 0.4.x: The first-arg parameter name is now idempotencyKey (was key). Transparent at positional call sites; code that destructures the parameter name from a wrapper signature must update. See Migration 0.4 to 0.5.

See also

Was this page helpful?