Einführung
Eine temporäre Reservierung sollte sich selbst freigeben, auch wenn niemand die Anwendung erneut aufruft. Ein In-Memory-JavaScript-Timer ist unsicher, weil ein Worker in den Leerlauf wechseln oder neu starten kann, bevor der Timer ausgelöst wird. Ein Durable-Object-Alarm speichert dagegen zusammen mit dem persistenten Zustand des Objekts einen zukünftigen Aufwachzeitpunkt. Cloudflare weckt das Objekt zu diesem Zeitpunkt auf und ruft seine Methode alarm() auf.
Alarme werden mindestens einmal ausgeführt: Cloudflare wiederholt einen fehlgeschlagenen Handler, sodass dieselbe beabsichtigte Wirkung erneut versucht werden kann. Der Ablaufvorgang muss daher idempotent sein – eine mehrfache Ausführung muss denselben Endzustand erzeugen wie eine einmalige Ausführung. Sie verwenden ein bedingtes SQLite-Update, sodass nur eine Reservierung mit Status held zu expired werden kann. Der Zähler wird im selben Update erhöht und kann bei einer Wiederholung nicht erneut steigen.
In diesem Lab verwenden Sie ein benanntes Durable Object pro Reservierung. Dadurch besitzt jede Reservierung den einen verfügbaren Alarmslot ihres Objekts. Sie planen kurze und lange Reservierungen, starten die lokale Laufzeit neu, bevor ein Alarm ausgelöst wird, wiederholen den Ablaufpfad absichtlich zweimal, testen einen echten Alarm erneut auf Cloudflare, prüfen das Dashboard, führen eine erneute Bereitstellung durch und räumen anschließend auf.
Bevor Sie diesen Kurs direkt beginnen, schließen Sie LabEx mit Ihrem Cloudflare-Konto verbinden ab. Jede neue VM benötigt eine eigene Wrangler-Autorisierung. Sie sollten stabile Objektnamen, RPC, SQLite-gestützten Zustand und begrenzte Nebenläufigkeit aus O01–O03 bereits verstehen.
Ein erneuter Aufruf von setAlarm() ersetzt den Alarm für dasselbe Objekt; andere benannte Objekte behalten ihre eigenen Alarme. Das Setup installiert Node.js 22.22.0 und Wrangler 4.132.0 als Projektabhängigkeit in /home/labex/project/reservation-expiry. Es autorisiert Cloudflare nicht, erstellt keinen Alarm und stellt keinen Worker bereit.
VM autorisieren und den Alarm-Namespace deklarieren
In diesem Schritt autorisieren Sie die neue VM und deklarieren eine SQLite-gestützte Durable-Object-Klasse für geplante Reservierungen.
Wechseln Sie in das Projekt, prüfen Sie die festgelegte Wrangler-Version und autorisieren Sie diese VM:
cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false
Wrangler sollte 4.132.0 ausgeben. Öffnen Sie die angezeigte Cloudflare-URL im Browser, geben Sie den kurzen Code ein, bestätigen Sie das vorgesehene Lernkonto und autorisieren Sie es. Fügen Sie niemals ein Passwort oder Token in das Terminal oder Lab ein.
Lesen Sie nur sichere Identitätsfelder aus, wählen Sie das bestätigte Konto aus und erstellen Sie einen eindeutigen Worker-Namen für die temporäre Verwendung:
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o04-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
Wenn Ihr eigenes Lernkonto einen anderen Anzeigenamen besitzt, ersetzen Sie den Namen durch den von Ihnen bestätigten Namen. Erstellen Sie die Konfiguration:
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"durable_objects": { "bindings": [
{ "name": "RESERVATIONS", "class_name": "ReservationExpiry" }
] },
"exports": {
"ReservationExpiry": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
Mit RESERVATIONS kann der Front-Door-Worker ein Objekt anhand der Reservierungs-ID auswählen. Der Klassenexport gibt jedem ausgewählten Objekt einen privaten SQLite-Speicher und einen Alarmslot. In der Cloud existiert noch nichts, bis Sie die Bereitstellung durchführen.
Persistenten und idempotenten Ablauf implementieren
In diesem Schritt implementieren Sie den persistenten Reservierungszustand, die Alarmplanung und einen wiederholungssicheren Zustandswechsel beim Ablauf.
Erstellen Sie die Anwendung. Entscheidend ist das bedingte UPDATE, nicht die HTTP-Anbindung:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/;
const json = (body, status = 200) => Response.json(body, { status });
async function readBody(request) {
try { return await request.json(); } catch { return null; }
}
function parsePath(pathname) {
const match = pathname.match(/^\/reservations\/([^/]+)(?:\/(replay-alarm))?$/);
if (!match || !NAME_PATTERN.test(match[1])) return null;
return { reservationId: match[1], action: match[2] ?? null };
}
export class ReservationExpiry extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS reservation (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL CHECK (status IN ('held', 'expired')),
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
expired_at INTEGER,
expiration_count INTEGER NOT NULL DEFAULT 0
)
`);
});
}
row() {
return this.ctx.storage.sql.exec(`
SELECT status, created_at, expires_at, expired_at, expiration_count
FROM reservation WHERE singleton = 1
`).one();
}
async createReservation(ttlSeconds) {
const createdAt = Date.now();
const expiresAt = createdAt + ttlSeconds * 1000;
this.ctx.storage.sql.exec(`
INSERT INTO reservation
(singleton, status, created_at, expires_at, expired_at, expiration_count)
VALUES (1, 'held', ?, ?, NULL, 0)
ON CONFLICT(singleton) DO UPDATE SET
status = 'held', created_at = excluded.created_at,
expires_at = excluded.expires_at, expired_at = NULL,
expiration_count = 0
`, createdAt, expiresAt);
await this.ctx.storage.setAlarm(expiresAt);
return this.getStatus();
}
async getStatus() {
const record = this.row();
if (!record) return { status: "missing", alarmAt: await this.ctx.storage.getAlarm() };
return {
status: record.status,
createdAt: record.created_at,
expiresAt: record.expires_at,
expiredAt: record.expired_at,
expirationCount: record.expiration_count,
alarmAt: await this.ctx.storage.getAlarm()
};
}
async processExpiry(now = Date.now()) {
const result = this.ctx.storage.sql.exec(`
UPDATE reservation
SET status = 'expired', expired_at = ?,
expiration_count = expiration_count + 1
WHERE status = 'held' AND expires_at <= ?
`, now, now);
const changed = result.rowsWritten === 1;
if (changed) await this.ctx.storage.deleteAlarm();
return { ...(await this.getStatus()), changed };
}
async alarm(alarmInfo) {
console.log(JSON.stringify({
event: "reservation-alarm",
isRetry: alarmInfo?.isRetry ?? false,
retryCount: alarmInfo?.retryCount ?? 0
}));
await this.processExpiry(Date.now());
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/") return json({ service: "reservation-expiry" });
const parsed = parsePath(url.pathname);
if (!parsed) return json({ error: "Use a lowercase reservation ID containing 3-40 letters, digits, or hyphens." }, 400);
let body = null;
if (request.method === "POST") {
body = await readBody(request);
if (!body) return json({ error: "Send a JSON request body." }, 400);
}
if (!parsed.action && request.method === "POST") {
if (!Number.isInteger(body.ttlSeconds) || body.ttlSeconds < 5 || body.ttlSeconds > 3600) {
return json({ error: "ttlSeconds must be an integer from 5 through 3600." }, 400);
}
} else if (parsed.action === "replay-alarm" && request.method === "POST") {
if (!Number.isSafeInteger(body.now) || body.now < 1) return json({ error: "now must be a positive integer timestamp." }, 400);
} else if (parsed.action || request.method !== "GET") {
return json({ error: "Method not allowed." }, 405);
}
const stub = env.RESERVATIONS.getByName(parsed.reservationId);
if (request.method === "GET") return json({ reservationId: parsed.reservationId, ...(await stub.getStatus()) });
if (parsed.action === "replay-alarm") return json({ reservationId: parsed.reservationId, ...(await stub.processExpiry(body.now)) });
return json({ reservationId: parsed.reservationId, ...(await stub.createReservation(body.ttlSeconds)) }, 201);
}
};
JS
setAlarm(expiresAt) speichert einen absoluten Unix-Zeitstempel. Mit getAlarm() können Sie die Planung überprüfen. Wenn der Zeitpunkt erreicht ist, ändert eine SQL-Anweisung held in expired und erhöht den Zähler. Bei einer Wiederholung steht der Status bereits auf expired, sodass die Bedingung WHERE status = 'held' auf keine Zeile zutrifft.
Die Route /replay-alarm ist eine absichtliche Test-Schnittstelle: Sie ruft dieselbe Methode auf, die auch alarm() verwendet, und erhält dabei eine explizite Uhrzeit. So lässt sich die Wiederholungssicherheit sofort nachweisen, ohne einen Cloudflare-Fehler zu erzeugen.
Führen Sie deterministische Routing-Tests und einen Wrangler-Build aus:
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run --outdir /tmp/o04-dry-run
Nachweisen, dass ein Alarm einen lokalen Neustart übersteht
In diesem Schritt planen Sie zwei lokale Reservierungen, starten Wrangler neu und beobachten, dass nur die fällige Reservierung abläuft.
Starten Sie Wranglers lokale Durable-Object-Laufzeit und warten Sie, bis sie bereit ist:
rm -rf .wrangler/state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | jq
Erstellen Sie eine Reservierung für 30 Sekunden und eine unabhängige Reservierung für eine Stunde. Die längere kurze Reservierung gibt Ihnen genug Zeit, Wrangler zu stoppen, bevor ihr Alarm fällig wird:
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-expiring \
--header 'content-type: application/json' --data '{"ttlSeconds":30}' | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-safe \
--header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
Beide Antworten zeigen held, expirationCount: 0 und ein numerisches alarmAt. Stoppen Sie die Laufzeit, bevor der erste Alarm fällig wird, und öffnen Sie anschließend denselben lokalen persistenten Speicher erneut:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
sleep 1
done
for attempt in $(seq 1 50); do
STATE="$(curl --silent --fail http://127.0.0.1:8787/reservations/local-expiring)"
test "$(printf '%s' "$STATE" | jq -r .status)" = expired && break
sleep 1
done
printf '%s\n' "$STATE" | jq
curl --silent --fail http://127.0.0.1:8787/reservations/local-safe | jq
Die kurze Reservierung ist genau einmal abgelaufen und ihr Alarm ist null; das unabhängige Objekt bleibt mit seinem eigenen Alarm auf held. Daran erkennen Sie den Unterschied zwischen einem persistenten Alarm und setTimeout().
Ablauf wiederholen, ohne ihn zweimal anzuwenden
In diesem Schritt rufen Sie den Ablaufpfad zweimal mit derselben logischen Frist auf und vergleichen beide Ergebnisse.
Erstellen Sie eine lange Reservierung, damit ihr echter Alarm diese deterministische Prüfung nicht beeinflussen kann. Erfassen Sie die gespeicherte Frist:
REPLAY="$(curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof \
--header 'content-type: application/json' --data '{"ttlSeconds":180}')"
printf '%s\n' "$REPLAY" | jq
REPLAY_NOW="$(printf '%s\n' "$REPLAY" | jq '.expiresAt + 1')"
Rufen Sie dieselbe Ablaufmethode zweimal mit einer Uhrzeit kurz nach der Frist auf:
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
--header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
| tee .labex/replay-first.json | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
--header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
| tee .labex/replay-second.json | jq
Die erste Antwort enthält changed: true, die zweite changed: false. Beide enden mit expired und expirationCount: 1. Das ist praktische Idempotenz: Eine Wiederholung ist sicher, selbst wenn die Plattform nicht feststellen kann, ob ein früherer Versuch bereits abgeschlossen wurde.
Den echten Alarm auf Cloudflare ausführen
In diesem Schritt stellen Sie den temporären Namespace bereit und überprüfen unabhängig davon einen echten Cloudflare-Alarm.
Stoppen Sie die lokale Laufzeit und stellen Sie den temporären Worker bereit:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
Die Worker-Route und der Namespace können zu leicht unterschiedlichen Zeitpunkten verfügbar werden. Prüfen Sie zunächst durch wiederholte harmlose Lesezugriffe die Verfügbarkeit. Erstellen Sie dann eine kurze und eine lange Reservierung:
for attempt in $(seq 1 30); do
curl --silent --fail "$APP_URL/" >/dev/null && break
sleep 1
done
curl --silent --fail --request POST "$APP_URL/reservations/cloud-expiring" \
--header 'content-type: application/json' --data '{"ttlSeconds":12}' | jq
curl --silent --fail --request POST "$APP_URL/reservations/cloud-safe" \
--header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
for attempt in $(seq 1 50); do
CLOUD_STATE="$(curl --silent --fail "$APP_URL/reservations/cloud-expiring")"
test "$(printf '%s' "$CLOUD_STATE" | jq -r .status)" = expired && break
sleep 1
done
printf '%s\n' "$CLOUD_STATE" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq
Die kurze Cloud-Reservierung muss genau einmal ablaufen; die unabhängige lange Reservierung bleibt gültig. Die folgende Prüfung erstellt außerdem neue, für diesen Lauf eindeutige Objekte und wiederholt die Tests mit echtem Alarm und Wiederholung unabhängig voneinander.
Namespace, Binding und Alarmprotokolle prüfen
In diesem Schritt ordnen Sie Laufzeitnachweise den Ansichten im Dashboard zu und überprüfen den Zustand nach einer erneuten Bereitstellung.
Öffnen Sie im Cloudflare-Dashboard Workers & Pages, wählen Sie den Worker mit dem exakten Namen aus, der in $RUN gespeichert ist, und öffnen Sie Settings > Bindings. Die Zeile RESERVATIONS sollte auf ReservationExpiry verweisen. Ein Binding ist der Einstiegspunkt des Front-Door-Workers in den Namespace, nicht eine einzelne Reservierung.

Öffnen Sie Durable Objects, wählen Sie den Namespace aus, der zu demselben Worker gehört, und bestätigen Sie, dass er SQLite-Speicher verwendet. Der Namespace ist die Sammlung aller benannten Reservierungsobjekte, die dieses Lab erstellt.

Öffnen Sie im Namespace den Tab Logs und wählen Sie eine Zeile aus, deren Details eventType: "alarm" melden. Das getestete Ereignis enthält außerdem entrypoint: "ReservationExpiry" und outcome: "ok". Damit wird die geplante Aktivierung mit der von Ihnen geschriebenen Klasse verknüpft. Die eigenen Protokollfelder retryCount und isRetry des Handlers können bei der Fehlersuche helfen. Die Korrektheit beruht dennoch auf dem persistenten bedingten Zustand, nicht auf der Annahme, dass der erste Versuch erfolgreich ist.

Dashboard-Daten können nach der Anfrage eintreffen. Die Prüfungen der Laufzeit und der API bleiben maßgeblich. Die Bilder dienen als Orientierung und stammen aus dem getesteten temporären Lauf; Ihr Suffix, Ihre Zeitstempel und Ihre Traffic-Zähler werden abweichen.
Stellen Sie den unveränderten Code erneut bereit und weisen Sie nach, dass beide Zustände erhalten bleiben:
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/reservations/cloud-expiring" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq
Den Alarm-Namespace löschen
In diesem Schritt löschen Sie den exakten temporären Namespace und den Worker, solange die VM noch autorisiert ist. Wenn die Autorisierung bis zur Backend-Prüfung bestehen bleibt, kann LabEx zwischen einer nachgewiesenen Löschung und einem Netzwerk- oder Authentifizierungsfehler unterscheiden.
Bestätigen Sie, dass $RUN mit labex-c10-o04- beginnt. Erstellen Sie einen zustandslosen Cleanup-Einstiegspunkt:
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
Erstellen Sie eine Cleanup-Konfiguration für genau denselben Worker und dasselbe Konto. Das Tombstone state: "deleted" entfernt nur den Klassen-Namespace dieses Labs einschließlich seiner temporären Objekte und noch ausstehender langer Alarme:
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": {
"ReservationExpiry": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
Die Ausgabe der Abstimmung sollte Deleted: ReservationExpiry melden. Löschen Sie anschließend den verbleibenden zustandslosen Worker. Wrangler bittet um eine Bestätigung, weil die Löschung nicht rückgängig gemacht werden kann. Bestätigen Sie erst, wenn der angezeigte Name exakt mit dem Wert von $RUN übereinstimmt:
npx wrangler delete --config wrangler.cleanup.jsonc
Geben Sie an der Eingabeaufforderung y ein und drücken Sie die Eingabetaste. Der Befehl sollte mit Successfully deleted gefolgt vom generierten Worker-Namen enden.
Lassen Sie diese VM für die Prüfung am Ende dieses Schritts autorisiert. Bestätigen Sie, dass Wrangler weiterhin eine authentifizierte Sitzung meldet:
npx wrangler whoami --json | jq '{loggedIn, authType}'
Das JSON muss "loggedIn": true enthalten. LabEx kann nun das ausgewählte Konto abfragen und nachweisen, dass sowohl der Worker als auch sein Durable-Object-Namespace nicht mehr vorhanden sind. Ein Netzwerk- oder Authentifizierungsfehler ist kein Nachweis für eine erfolgreiche Bereinigung.
Die Wrangler-Autorisierung dieser VM widerrufen
In diesem Schritt widerrufen Sie die OAuth-Autorisierung, die nur in dieser neuen VM gespeichert ist, nachdem die Löschung der Cloud-Ressourcen überprüft wurde.
wrangler logout entfernt die lokale Autorisierung. Die strukturierte Prüfung mit whoami --json ist wichtig, weil die normale menschenlesbare Ausgabe mehrdeutig sein kann. Das Feld loggedIn liefert das maßgebliche Ergebnis:
npx wrangler logout
npx wrangler whoami --json
Das abschließende JSON muss "loggedIn": false enthalten. Dadurch wird Ihr Cloudflare-Lernkonto im Browser weder gelöscht noch abgemeldet. Nur diese VM kann anschließend keine weiteren authentifizierten Wrangler-Anfragen mehr senden.
Zusammenfassung
Sie haben für jedes benannte Durable Object eine eigene temporäre Reservierung erstellt und jedem Objekt einen persistenten Alarm zugewiesen. Sie haben beobachtet, dass ein geplanter Ablauf einen Neustart der lokalen Laufzeit übersteht, nachgewiesen, dass eine andere Reservierung gültig bleibt, und mit einem bedingten SQLite-Zustandswechsel wiederholte Ablaufverarbeitung sicher gemacht. Anschließend haben Sie das Verhalten echter Alarme auf Cloudflare wiederholt, Binding, Namespace und Protokolle geprüft, den Zustand über eine erneute Bereitstellung hinweg verifiziert, den exakten temporären Namespace entfernt und sich abgemeldet.
Die übertragbare Designregel lautet: Planen Sie zukünftige Arbeit persistent, gehen Sie davon aus, dass sie erneut versucht werden kann, und speichern Sie genügend Zustand, damit die Wirkung selbst entscheiden kann, ob sie bereits eingetreten ist.



