Server-Side Applications & Databases

Time, State & Idempotency


Learning Objectives

  • You can use status and a deadline to decide whether an operation is allowed.
  • You can test time-dependent logic by supplying the current time.
  • You can preserve a completed transition when an operation is repeated.
  • You can explain why a deadline check must use the time after acquiring a row lock.

An operation can depend on both a record’s status and the current time. We will expire pending invitations, test the exact deadline, and preserve the result if expiry is attempted again. Then we will add a deadline to Chapter 8’s workshop registration service.

Expire only pending invitations

An invitation has a status and a deadline. Attempting to expire it follows these rules:

Invitation statusCurrent timeResult of attempting expiry
PendingBefore the deadlineLeave unchanged
PendingAt or after the deadlineMark expired
AcceptedAny timeLeave unchanged
ExpiredAny timeLeave unchanged

Passing the deadline does not automatically change a stored status. An invitation can still say pending after its deadline. An operation that accepts invitations must therefore check both its status and its deadline.

Pass the current time as an argument

Give expireInvitation the invitation and the current time as arguments. A test can then choose an exact time without waiting for it to pass. The function works on values and does not need a database.

Create api/src/services/invitation-service.ts. The Ms suffix means milliseconds; processedAtMs records when the invitation was marked expired:

export type Invitation = {
id: number;
status: "pending" | "accepted" | "expired";
expiresAtMs: number;
processedAtMs: number | null;
};
export const expireInvitation = (invitation: Invitation, nowMs: number): Invitation => {
if (invitation.status !== "pending" || nowMs < invitation.expiresAtMs) {
return invitation;
}
return { ...invitation, status: "expired", processedAtMs: nowMs };
};

Assume both times are finite millisecond values. The condition checks that the invitation is still pending and that its deadline has been reached. When both hold, the function returns an expired copy; it leaves the input unchanged.

Repeating expiry should preserve the result

Suppose an invitation is marked expired at time 1000. Attempting expiry again at time 2000 should leave processedAtMs at 1000: the invitation expired once. The status check in expireInvitation preserves that result.

An operation is idempotent when repeating it has the same intended effect as performing it once. Here, repeated expiry preserves both the status and its recorded time.

Preserve an archival result

0 / 5 points

An export was archived at time 1000. A retry at time 2000 returns the same status but replaces archivedAt with 2000. Which assertion identifies the lost repeat-safety property?

Test the boundary, not a long sleep

For a deadline of 1000, test times 999, 1000, and 1001. Expiry is allowed at the deadline, so equality belongs on the expired side of the comparison. Also check repeated expiry and an already accepted invitation.

Create api/tests/invitation-service_test.ts:

import { assertEquals } from "@std/assert";
import { expireInvitation, type Invitation } from "@src/services/invitation-service.ts";
const pending: Invitation = {
id: 7,
status: "pending",
expiresAtMs: 1000,
processedAtMs: null,
};
Deno.test("expiry is inclusive at the deadline", () => {
assertEquals(expireInvitation(pending, 999).status, "pending");
assertEquals(expireInvitation(pending, 1000).status, "expired");
assertEquals(expireInvitation(pending, 1001).status, "expired");
assertEquals(pending.status, "pending");
});
Deno.test("repeating expiry does not rewrite its recorded result", () => {
const once = expireInvitation(pending, 1000);
assertEquals(once.processedAtMs, 1000);
const twice = expireInvitation(once, 2000);
assertEquals(twice, once);
});
Deno.test("accepted invitations are not expired afterward", () => {
assertEquals(
expireInvitation({ ...pending, status: "accepted" }, 2000).status,
"accepted",
);
});

The chosen times exercise the boundary directly. Sleeping for several seconds would make the test slower without reliably testing the exact deadline.

Run these pure tests from practice/ without database services or runtime permissions:

Terminal window
docker compose run --build --rm --no-deps api deno test --cached-only tests/invitation-service_test.ts

Activate a pass and repair repeated expiry

0 / 20 points

Files in the editor

The editor below contains all 2 files for this exercise, although only one opens initially. Use the Files panel to expand api/src/ and its folders, then select a filename to open it. If the panel is collapsed, choose Show file tree in the editor toolbar.

Edit:

  • api/src/services/access-pass-service.ts

These supporting files are also in the editor. You may read them; keep their supplied implementations unchanged:

  • api/src/schemas/access-pass-schema.ts

Task

Implement activatePass and repair the faulty expirePass in api/src/services/access-pass-service.ts. The time schema and error class are supplied. A Pass contains integer id, status (pending, activated, cancelled, or expired), expiresAtMs, and nullable processedAtMs. Use the supplied time arguments, not a hidden system clock.

Requirements

activatePass(pass, nowMs) first rejects nonfinite times with PassError('INVALID_TIME'), then a non-pending status with NOT_PENDING, then nowMs >= expiresAtMs with EXPIRED. Otherwise return a new activated value with processedAtMs = nowMs.

expirePass(pass, nowMs) validates the two times, then expires only a pending pass at or after its deadline, setting status: "expired" and processedAtMs = nowMs. Invalid times give PassError("INVALID_TIME") in this function too. Preserve all other states. Repeated expiration must preserve the original processing time rather than rewrite it. Both functions leave their inputs unchanged. Return the entire pass shape, retaining its ID and deadline. No database, timer, or scheduler belongs in these pure functions.

Check and submit

Choose Submit solution below the editor to run the grading checks and read their feedback. All 2 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control.

The grader supplies zod and maps @src/ to api/src/. Four checks award 5 points each: just-before/exact-deadline activation, terminal-state rules, repeated expiration, and invalid-time/input preservation. These checks concern pure functions; database concurrency is tested separately in the materials.

Use the time after waiting for a lock

A registration request can arrive before a deadline and wait for another transaction’s lock until after the deadline. Under our rule, registration is allowed only if the deadline has not been reached when the service makes its decision after acquiring the lock. Figure 1 shows why arrival time is insufficient.

Course diagram
Figure 1. Time is sampled after the lock is acquired; an expired request is rejected without changing the resource.

PostgreSQL’s now() returns the transaction’s start time, which may be before the wait. Use clock_timestamp() in a query after acquiring the lock to read the current database time. See PostgreSQL’s date/time functions.

Continue with lab_workshops from Chapter 3.8 in the same practice/ database. Add database-migrations/005_workshop_deadlines.sql (or the next unused version after the workshop migration). Existing workshops remain valid with no deadline:

-- migrate:up
ALTER TABLE lab_workshops ADD COLUMN closes_at TIMESTAMPTZ(3);
-- migrate:down
ALTER TABLE lab_workshops DROP COLUMN closes_at;

A null deadline means registration has no time limit. Apply the migration and recreate the separate test project with the three commands in Chapter 3.8. The new migration depends on the workshop table; it does not create it again. Extend lockWorkshop’s SELECT to include closes_at. Create api/src/repositories/database-clock.ts:

import type postgres from "postgres";
export const readDatabaseNow = async (tx: postgres.TransactionSql): Promise<Date> => {
const [row] = await tx<{ now: Date }[]>`
SELECT date_trunc('milliseconds', clock_timestamp()) AS now
`;
return row.now;
};

The column and helper use millisecond precision to match JavaScript Date values.

In workshop-service.ts, import readDatabaseNow from this module and add "CLOSED" to RegistrationError’s code union. Keep the service’s existing arguments. Immediately after the locked workshop’s missing-row check, before the remaining-seat check, insert:

const now = await readDatabaseNow(tx);
if (workshop.closes_at !== null && now >= workshop.closes_at) {
throw new RegistrationError("CLOSED");
}

Read the database time after the locking query completes. The client does not supply this time. If another operation also changes whether registration is allowed, it must coordinate through the same workshop row.

Compare two clock placements

0 / 5 points

A deadline is 09:15:00. Both implementations start at 09:14:57 and acquire the row lock at 09:15:04. A samples time before locking; B samples it afterward. The rule accepts only if the time sampled after acquiring the lock is strictly before the deadline. Under this contract, which outcome exposes A’s defect?

Check Your Understanding

  1. Why might an invitation still say pending after its deadline, and what must an acceptance operation check?
  2. How does passing the current time as an argument make the expiry function easier to test?
  3. Why must a deadline check use the time after acquiring a lock rather than the request’s arrival time?
  4. Why should repeated expiry preserve the original processedAtMs value?
  5. What stored state must remain unchanged when a registration is rejected after its deadline?