Back to engineering

Engineering

Handing over a slot that is still booked

The Bookatu engineering team10 min read

Quick swap lets a client who would pay a late-cancel fee hand their time to someone on the waitlist instead. The time is never free, not even for a moment. Here is the one transaction that releases one booking and writes the other, how two claimers racing give one winner, and how a cancellation and a claim at the same moment give one outcome.

The short version

  • Quick swap lets a client inside the late-cancel window ask to swap out instead of cancelling. Bookatu offers that exact time to matching waitlist clients through a signed one-tap link, while the asking client still holds it.
  • A claim is one transaction. Under the same advisory locks every booking takes, it reads the booking again FOR UPDATE, releases it as "swapped", writes the claimer's booking for the same times, people and rooms, marks the ask claimed and fulfils the waitlist entry. The time is never free to anybody else.
  • Two claimers racing give one winner. The loser's transaction finds the ask already gone. Behind that sits the unique index that allows one live booking per person and start time, and a violation of it reads as "gone" too.
  • Each link carries a fingerprint of the booking as it was offered. If the booking moved, changed hands or lost a service since, the claim fails politely and writes nothing.
  • cancelAppointment closes an open ask before any money moves, and refuses to write over a row a claim has released. A cancellation and a claim at the same moment give one outcome, never both.
  • The hourly cron closes expired asks with a conditional update, so only the run that moved an ask tells anybody about it.

A late cancellation costs both sides. The business keeps the deposit or queues a late-cancel fee, and the client pays for a time they cannot use. Very often someone on the waitlist would have taken that exact time. Quick swap connects the two: the client asks to swap out, the time is offered to the waitlist, and if somebody takes it the client is released with no fee and their deposit back (or kept as credit, by the business's setting). If nobody takes it by the business's cutoff, the client is still booked and is told so.

Most of the work was not the offer or the emails. It was making sure the time changes hands without ever being free in between.

Why not cancel first and offer second

We already had a path for freed time. When a booking is cancelled, cancelAppointment offers the freed slot to the best matching waitlist clients. The tempting build was to reuse it: cancel the booking without a fee, then let that path offer it.

That gets the order wrong. Between the cancel and anyone claiming, the time is free. Anyone booking online can take it, which is fine for the business but means the client was let off a fee in exchange for nothing. Worse, if nobody claims, the client has already lost the booking they were willing to keep. So the rule we wrote at the top of lib/quick-swap.ts is that the old booking stops holding the time only inside the transaction that makes the new one hold it.

The asking client keeps the time until the moment somebody else has it, and not one statement earlier.

The state lives on the booking itself: a handful of nullable swap_* columns added by migration 0170, all NULL for a business that never switches Quick swap on. For a visit of several services the ask is kept on the first service, which speaks for the visit. Asking is a conditional update, so two taps make one ask:

ts
const asked = await db
  .update(appointments)
  .set({
    swapStatus: "requested",
    swapRequestedAt: now,
    swapStartAt: lead.startAt,
    swapCutoffAt: e.cutoffAt,
    // ...
  })
  .where(
    and(
      eq(appointments.orgId, orgId),
      eq(appointments.id, lead.id),
      eq(appointments.status, "confirmed"),
      eq(appointments.startAt, lead.startAt),
      isNull(appointments.checkedInAt),
      // Not while an ask on the booking as it is stands (a second tap).
      sql`(${appointments.swapStatus} is null or ${appointments.swapStatus} <> 'requested' or ${appointments.swapStartAt} is distinct from ${appointments.startAt})`,
    ),
  )
  .returning({ id: appointments.id });

swap_start_at records the start the ask was made for. That one column lets every later step tell an ask that still stands from an ask on a booking that has since moved.

One transaction

claimSwap does its cheap checks first, outside any transaction: the link verifies, the waiting client's entry is still open, the booking still looks as offered, the business still allows it. None of that writes anything. Then it opens one transaction and starts by taking the same locks every booking takes, in the same order:

ts
// The same locks every booking takes on its resources and people, in the
// same order (resources, then people), so a booking being made for any
// of them at this moment waits for this one, then sees it.
// ...
await lockResources(tx, orgId, [...booking.lines.map((l) => l.resourceId), ...otherRows.map((o) => o.resourceId)]);
await lockPeople(
  tx,
  orgId,
  [...booking.lines.map((l) => l.staffId), ...crewRows.map((c) => c.staffId)].filter((x): x is string => Boolean(x)),
);

// The booking again, held: it must still be exactly what was offered.
const [first] = await tx
  .select()
  .from(appointments)
  .where(and(eq(appointments.orgId, orgId), eq(appointments.id, booking.lead.id)))
  .for("update");
if (!first) throw new SwapGoneError();
// ...
if (!stillAsOffered(fresh, r.ctx.token, now)) throw new SwapGoneError();

Both lock helpers are transaction-scoped Postgres advisory locks, one per resource and one per person, taken in sorted order so two transactions can never wait on each other in a circle. The booking flow takes the same keys, which is what makes "at this moment" safe: an online booking for the same stylist at the same time waits for the claim, then sees the claimer's booking.

Holding the locks, the claim reads the booking again with FOR UPDATE and checks it once more. Everything read before the transaction was only a hint. Then come the writes:

ts
// Release the asking client's booking, every service of it, as swapped.
// Only while each one is still confirmed: anything else is a change.
const freed = await tx
  .update(appointments)
  .set({ status: "cancelled", cancelledAt: now, cancelReason: "swapped", updatedAt: now })
  .where(and(eq(appointments.orgId, orgId), inArray(appointments.id, fresh.lines.map((l) => l.id)), eq(appointments.status, "confirmed")))
  .returning();
if (freed.length !== fresh.lines.length) throw new SwapGoneError();

// The claimer's booking, in the same times, with the same people and rooms.
const inserted = await tx
  .insert(appointments)
  .values(
    fresh.lines.map((l, k) => ({
      orgId,
      customerId,
      staffId: l.staffId,
      resourceId: l.resourceId,
      // ...
      startAt: l.startAt,
      endAt: l.endAt,
      // ...
    })),
  )
  .returning();
// ...
// The ask is claimed, by this booking.
await tx
  .update(appointments)
  .set({ swapStatus: "claimed", swapClosedAt: now, swapClaimedAppointmentId: byStart[0].id })
  .where(and(eq(appointments.orgId, orgId), eq(appointments.id, fresh.lead.id)));

The release only matches rows that are still confirmed, and the count has to equal the number of services. If one of them changed under us, SwapGoneError rolls the whole thing back. The extra rooms and people a service holds are copied onto the new rows inside the same transaction, and the waiting client's entry is marked fulfilled with a conditional update that also throws if it matched nothing.

Everything that talks to the outside world happens after the commit: the deposit refund or credit, removing the old booking from the team's Google Calendar, webhooks, the audit rows and the emails. Each one is wrapped so that a failure is logged and never undoes the swap. A refund Stripe could not make is recorded as needing handling rather than quietly marked done.

Two claimers, one winner

Up to five waiting clients get a link for the same time, so two of them tapping within the same second is a case we had to plan for. Both pass the checks outside the transaction, because at that point nothing has changed. Inside, the second one waits on the first one's locks. When it gets them, it reads the booking FOR UPDATE and finds it cancelled with its ask claimed. The ask no longer stands:

ts
function askStands(lead: Appointment): boolean {
  return (
    lead.swapStatus === "requested" &&
    lead.status === "confirmed" &&
    lead.checkedInAt == null &&
    lead.swapStartAt != null &&
    lead.swapStartAt.getTime() === lead.startAt.getTime() &&
    lead.swapCutoffAt != null
  );
}

If some path ever got past that check, the schema has one more answer. Appointments carry a partial unique index that allows one live booking per staff member and start time, and the same for a resource:

ts
// One live appointment per staff member + start time (anti double-booking).
uniqueIndex("apt_staff_slot_uniq")
  .on(t.staffId, t.startAt)
  .where(sql`status in ('pending','confirmed') and staff_id is not null`),

The claim treats a violation of it exactly like a lost race:

ts
} catch (e) {
  if (e instanceof SwapGoneError || isUniqueViolation(e)) return { status: "gone" };
  log.error("quick_swap.claim_failed", e, { orgId, appointmentId: booking.lead.id });
  return { status: "gone" };
}

An unexpected error also answers "gone", after logging it. We chose that on purpose. The transaction has rolled back, so nothing was written, and a waiting client who tapped a link does not need a stack trace. The integration test races two real claims with Promise.all against the test database:

ts
const results = await Promise.all([claimSwap(s.b.orgId, tokenFor(a.id)), claimSwap(s.b.orgId, tokenFor(c.id))]);

expect(results.map((r) => r.status).sort()).toEqual(["gone", "swapped"]);
expect(await liveAt(s.b.orgId, TOMORROW_9)).toHaveLength(1);
const statuses = [(await entryRow(tdb, a.id)).status, (await entryRow(tdb, c.id)).status].sort();
expect(statuses).toEqual(["fulfilled", "offered"]);

The loser stays on the waitlist as "offered", so the next opening can still reach them. A sibling test races a claim against an ordinary online booking for the same stylist and start, and checks that the claim wins, the online booking is refused, and exactly one live booking exists afterwards.

A claim that fails politely

A link is offered for a booking as it was at that moment. If the team moves it an hour later, the link must not claim the new time. So every link carries a fingerprint of the booking's services:

ts
export function swapFingerprint(lines: FingerprintLine[]): string {
  const ordered = [...lines].sort((a, b) => a.startAt.getTime() - b.startAt.getTime() || a.id.localeCompare(b.id));
  const text = ordered
    .map((l) => [l.id, l.startAt.toISOString(), l.endAt.toISOString(), l.staffId ?? "", l.resourceId ?? ""].join("|"))
    .join(";");
  return crypto.createHash("sha256").update(text).digest("base64url").slice(0, 22);
}

stillAsOffered compares it, together with the start and the ask, both before the transaction and again on the locked rows inside it. A test suite pins five ways the booking can change after the offer: moved to another time, given to someone else, cancelled by the client the normal way, checked in, and the ask withdrawn. In each one the claim answers "gone", the original booking is untouched, the waiting client's entry is still open, no new booking exists and no release email went out.

The link's page is a GET that only reads. Claiming is a separate POST. Mail scanners and chat apps that fetch links to draw a preview can open the page as often as they like and never take the time.

When a cancellation and a claim cross

This was the case that needed the most care. The asking client can still cancel the normal way at any time, fee and all. cancelAppointment reads the booking, decides what happens to the deposit and the fee, and then writes. If a claim commits between that read and that write, a cancel that knew nothing about swaps would forfeit a deposit the swap had just refunded, count a late cancellation against the client, queue a fee, and write over the row that now records the swap.

So cancelAppointment's one Quick swap step comes before any of that. It only runs for a booking with an open ask:

ts
if (apt.swapStatus === "requested") {
  const closed = await db
    .update(appointments)
    .set({ swapStatus: "expired", swapClosedAt: new Date() })
    .where(
      and(
        eq(appointments.orgId, orgId),
        eq(appointments.id, appointmentId),
        eq(appointments.swapStatus, "requested"),
      ),
    )
    .returning({ id: appointments.id });
  if (closed.length) {
    // ...
  } else {
    const released = await bookingReleasedSince(orgId, appointmentId);
    if (released) return released;
  }
}

Whichever update moves the ask off "requested" first decides the outcome. If the cancel closes it, a claim arriving later finds the ask gone and the cancellation is exactly today's, fee and all. If the claim got there first, the cancel finds nothing to close, sees the booking already released, and returns the answer a repeat cancel gets, doing nothing more.

One more case was left. A visit's later services carry no ask of their own, so the step above never runs for them. The final cancel write therefore carries a narrow guard:

ts
.where(
  and(
    eq(appointments.id, appointmentId),
    // Never over a booking a Quick swap claim released after it was read
    // above (a later service of a visit carries no ask of its own to
    // close): that time is the claimer's now, and the swap settled it.
    // Narrowed to that on purpose: every other write lands as it always has.
    sql`not (${appointments.status} = 'cancelled' and ${appointments.cancelReason} is not distinct from 'swapped')`,
  ),
)

Testing a race between a read and a write needs a seam. The test file wraps loadOrgSettings so that the next call first runs a hook, and cancelAppointment calls it after reading the booking and before writing anything. The hook runs a full claim there. The tests then check that the claim stood, that nothing was forfeited, queued or counted, that the deposit was settled once (a card deposit kept as credit is not also refunded to the card), and that the claimer holds the time.

The hourly sweep

An ask with no taker lapses at its cutoff. The hourly reminders cron calls expireSwaps, which reads the open asks (a partial index on swap_cutoff_at covers exactly those rows) and moves each one with a conditional update:

ts
const [moved] = await db
  .update(appointments)
  .set({ swapStatus: "expired", swapClosedAt: now })
  .where(
    and(
      eq(appointments.orgId, apt.orgId),
      eq(appointments.id, apt.id),
      eq(appointments.swapStatus, "requested"),
      ...(stands ? [lte(appointments.swapCutoffAt, now)] : []),
    ),
  )
  .returning({ id: appointments.id });
if (!moved) continue;

Only the run that moved an ask sends anything, so two overlapping runs, or a retry, never tell a client twice that they are still booked. The test runs the sweep a minute before the cutoff (nothing happens), five minutes after (one ask expires, one email), and then again (nothing). An ask whose booking changed meanwhile is closed too, quietly, because telling someone "you are still booked" when they may not be is worse than saying nothing. The sweep has its own try/catch in the cron, and a test checks that a swap failure never costs the reminders that run before it.

The same hourly pass also offers asks that are still open to waiting clients who joined the list since. The waitlist already records each opening it offered to each client, so nobody hears about the same time twice.

The link itself

The link is an HMAC-signed token with its own signing purpose, separate from the Magic Fill claim link it was modelled on. A Magic Fill link books a free time. A swap link takes over a time somebody still holds. The two must never stand in for each other, and a test checks that a Magic Fill link is refused here. The payload names the business, the booking, the waiting client's entry, the start and the fingerprint, and it expires at the ask's cutoff. The claim checks that the business in the link is the business of the page.

Single use is enforced where it matters, inside the claim. The ask can leave "requested" once, and the waiting client's entry can be fulfilled once. A second tap on the same link is told the time has gone.

What we take from it

  • When something has to change hands, do the release and the take in one transaction. A gap between them, however short, is a window someone else can use.
  • Take the same locks as the rest of the system, in the same order. Locking was the easy part. Agreeing with every other writer on what to lock was the work.
  • Treat everything read before the transaction as a hint and check it again on locked rows. The fingerprint made "is this still what we offered?" one comparison.
  • When two operations can race, pick one column whose change decides the winner. Here it is swap_status leaving "requested", and both the claim and the cancel have to move it.
  • Build a seam to test the race between a read and a write. Without one, the most dangerous case in the feature would only ever have been tested by luck.
postgrestransactionsconcurrencywaitlistengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs