Reference · 05

Expiry, leases and timers.

Records that expire, jobs leased to one worker, and callbacks that run later.

Expiring records

expiringCollection from @flower-js/sdk/temporal stores records that stop being readable after a deadline.

APIContract
Expirationnull (never), {at}, {afterCreationMs} or {afterUpdateMs}. Nonnegative integer milliseconds.
ExpiringEntry<T>value, createdAt, updatedAt and expiresAt (or null).
expiringCollection<T = Json>(name, {expiration?} = {})Creates the helper. Default expiration is null. Names starting with $flower. are reserved.
helper.records: Collection<ExpiringEntry<T>>The backing collection. Reading it directly can return expired entries; use the helper methods instead.
entry(ctx, key): ExpiringEntry<T> | nullThe live entry with timestamps, or null once expired.
get(ctx, key): T | nullThe live value, or null.
scan(ctx): {key: string, value: T}[]All live records. Cost includes expired rows that haven’t been swept.
set(ctx, key, value, expiration?): ExpiringEntry<T>Writes with this call’s expiration or the default. Keeps createdAt if the old entry is still live; an expired key starts fresh.
delete(ctx, key): voidRemoves the record, live or expired.
sweep(ctx): numberDeletes expired records in bounded pages and returns the count. Call it from a mutation or maintenance handler. Nothing sweeps automatically.
  • Add helper.records to define({collections}).
  • A record is expired when now >= expiresAt. Reads enforce this even before a sweep.
  • Expiry doesn’t trigger anything. To act at a deadline, use the scheduler.

Worker leases

workQueue hands each job to one worker at a time and rejects results from a worker whose lease was lost. See the external workers guide for full patterns.

APIContract
workQueue<Payload = Json, Result = Json>(name, {maxLeaseMs, defaultLeaseMs?, scope?})Creates a queue. maxLeaseMs is required. defaultLeaseMs defaults to it and can’t exceed it. scope (default "") splits one backing collection into separate queues.
Leaseowner, token, expiresAt.
LeaseIdentityid, owner, token, and optional history. Pass it unchanged from the claim to complete or fail.
Claim<Payload = Json>A Lease plus id, payload and attempt.
Job<Payload = Json, Result = Json>The stored job: payload, scope, id, state (pending, leased, completed or failed), timestamps, attempts, lease, result, error.
queue.records: Collection<Job<Payload, Result>>The backing collection, covering all scopes. Writing it directly can break the queue.
enqueue(ctx, id, payload, {replaceFinished?} = {}): JobAdds a pending job. A duplicate ID fails, unless replaceFinished is set and the old job is completed or failed.
get(ctx, id): Job | nullThe job’s current state. An expired lease shows as pending with a LEASE_EXPIRED error.
scan(ctx): {key: string, value: Job}[]Every job in this scope, by ID.
claim(ctx, owner, leaseMs = defaultLeaseMs): Claim | nullLeases the oldest available job (including expired leases) with a new, higher token. Returns null if nothing is available.
complete(ctx, identity, result): JobStores the result if the lease is still current and unexpired.
fail(ctx, identity, error: Json): JobSame check, then marks the job failed with the error.
retry(ctx, id): JobMoves a failed job back to pending. Only failed jobs can be retried.
sweep(ctx): numberReturns expired leases to pending and returns the count. Finished jobs stay.
  • Call the writing methods from a mutation. Add queue.records to define({collections}).
  • A stale, expired or replaced lease fails with LEASE_LOST.
  • Leases can’t be renewed.
  • A retried claim returns its original deadline, which may have passed already. Check expiresAt before starting work.
  • Flower can’t make external side effects exactly-once. Pass the token to external systems and have them reject stale tokens.
  • Scopes separate queues. They don’t restrict access.

Delayed callbacks

scheduler from @flower-js/sdk/scheduler runs a mutation after a deadline. The timer commits in the same transaction as the write that schedules it.

APIContract
scheduler(name, handlers, options = {})Creates timers. handlers maps names to mutations; they don’t need to be public. Register collections: [timers.records] and maintenance: timers.maintenance in define.
SchedulerOptionsOptional maxAttempts (3), retryDelayMs (1,000) and maxRetryDelayMs (60,000).
ScheduledTimer<Args = Json>state (pending or failed), handler, args, dueAt, attempts, error, createdAt, updatedAt.
timers.records: Collection<ScheduledTimer>The backing collection. Don’t write it directly.
timers.maintenance: {run, onError}The handlers to register in define. Each run fires one due timer.
get(ctx, id): ScheduledTimer | nullReads one timer.
scan(ctx, state?): (ScheduledTimer & {id: string})[]Lists timers, optionally by state, ordered by dueAt then ID.
after(ctx, id, delayMs, handler, args): ScheduledTimerSchedules delayMs after ctx.now(). Reusing an ID replaces the earlier timer (debounce).
at(ctx, id, dueAt, handler, args): ScheduledTimerSchedules at an absolute time in milliseconds. A past time runs at the next chance.
cancel(ctx, id): booleanDeletes a timer. Returns whether it existed.
retry(ctx, id, delayMs = 0): ScheduledTimerReschedules a failed timer with a fresh attempt count, using the currently deployed handler.
MaintenanceFailureerror: {code, message} and failedAt, passed to the error handler after the failed run is rolled back.
MaintenanceContinuationReturn {$flower: {continue: true}} to run again right away while work remains.
MaintenanceHandlersrun and onError mutations.
MaintenanceManifestReadonly name, kind: "mutation", optional onError.
The order and its timer commit together
import { collection, define, mutation } from "@flower-js/sdk";
import { scheduler } from "@flower-js/sdk/scheduler";

const orders = collection<{ state: string }>("orders");
const bake = mutation("internal.bake", (ctx, id: string) => {
  const order = ctx.get(orders, id);
  if (order) ctx.set(orders, id, { ...order, state: "ready" });
  return null;
});
const timers = scheduler("oven", { bake });
const place = mutation("orders.place", (ctx, id: string) => {
  ctx.set(orders, id, { state: "baking" });
  timers.after(ctx, `bake:${id}`, 5_000, "bake", id);
  return { id };
});
export default define({ collections: [timers.records], http: { place }, maintenance: timers.maintenance });
  • A deadline means “not before”. Load, elections and outages can delay a timer.
  • If the handler succeeds, its writes and the timer’s removal commit together.
  • If it fails, its writes roll back and it retries with doubling delay, up to maxAttempts. Then it stays as a failed timer you can inspect and retry.
  • A handler can run more than once before it commits. Put external side effects in a work queue.
  • By default the server checks for due timers every 250 ms. That’s an operator setting.