Einführung
Ein Workshop hat noch vier freie Plätze, aber zehn Personen können fast gleichzeitig auf Reserve klicken. Wenn jede Anfrage zuerst reserved = 0 liest, pausiert und anschließend reserved = 1 schreibt, gehen erfolgreiche Reservierungen verloren. Ein anderes fehlerhaftes Design kann mehr Plätze freigeben, als der Workshop tatsächlich besitzt. Diese Überschneidung noch nicht abgeschlossener asynchroner Aufgaben wird Interleaving genannt.
In diesem Lab wählt jeder validierte Workshopname ein eigenes Durable Object aus. Dieses Objekt besitzt eine Kapazitätszeile und einen dauerhaften Datensatz für jeden Versuch. Seine Reservierungsmethode führt die Kapazitätsprüfung, die Aktualisierung des Zählers und den Datensatz des Versuchs innerhalb einer synchronen SQLite-Transaktion aus. Gleichzeitige Aufrufer können gleichzeitig eintreffen, aber keiner von ihnen sieht einen halb abgeschlossenen Zustandswechsel.
Sie lernen drei zusammengehörige Abgrenzungen kennen:
- Nebenläufigkeit bedeutet, dass mehrere Vorgänge im selben Zeitraum ausgeführt werden; dafür sind keine mehreren JavaScript-Threads erforderlich.
- Atomarität bedeutet, dass andere Vorgänge entweder die vollständige Zustandsänderung oder gar keine Änderung sehen.
- Sichere Initialisierung erstellt eine fehlende Zeile, ohne eine Zeile zu überschreiben, die bereits Reservierungen enthält.
Sie senden gleichzeitig lokale und Cloud-Fixtures, vergleichen angenommene und abgelehnte Summen mit dem dauerhaften Zustand, starten die lokale Laufzeit neu, deployen den Cloud-Worker erneut, prüfen das Dashboard und entfernen alle temporären Ressourcen.
Bevor Sie direkt mit diesem Kurs beginnen, absolvieren Sie LabEx mit Ihrem Cloudflare-Konto verbinden. Jede neue VM benötigt eine eigene Wrangler-Autorisierung. Sie sollten stabile Durable-Object-Namen, RPC und SQLite-gestützten Zustand aus O01–O02 bereits verstehen.
Cloudflare unterstützt derzeit SQLite-gestützte Durable Objects im Workers Free-Tarif. Dieses Lab erstellt einen temporären Klassen-Namespace, mehrere kleine benannte Objekte und begrenzte Anfragebatches. Das Setup installiert Node.js 22.22.0 und Wrangler 4.132.0 lokal im Projekt unter /home/labex/project/concurrent-reservations; es autorisiert Cloudflare nicht, erstellt keinen Namespace, deployt keinen Worker und nimmt keine Reservierung vor.
VM autorisieren und Workshop-Namespace konfigurieren
In diesem Schritt autorisieren Sie die neue VM und deklarieren eine SQLite-gestützte Durable-Object-Klasse. Jeder Workshopname wählt ein anderes Objekt in diesem Namespace aus.
Wechseln Sie in das Projekt, prüfen Sie die festgelegte Wrangler-Version und starten Sie die Geräteautorisierung:
cd /home/labex/project/concurrent-reservations
npx wrangler --version
npx wrangler login --device --browser=false
Erwartet wird Wrangler 4.132.0. Öffnen Sie die angezeigte Cloudflare-URL im Browser, geben Sie den kurzen Code ein, bestätigen Sie das vorgesehene Lernkonto und autorisieren Sie den Zugriff. Kehren Sie erst zurück, wenn Wrangler den Erfolg meldet. Geben Sie niemals ein Passwort oder Token in das Lab ein.
Lesen Sie sichere Identitätsfelder aus, wählen Sie die bestätigte Konto-ID aus, ohne sie auszugeben, und erzeugen Sie einen eindeutigen Worker-Namen:
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-o03-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
Wenn Ihr dediziertes Lernkonto einen anderen Anzeigenamen hat, 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": "WORKSHOPS", "class_name": "WorkshopReservations" }
]
},
"exports": {
"WorkshopReservations": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
WORKSHOPS ist das Namespace-Binding des vorgeschalteten Workers. Der Klassenexport gibt jedem benannten Workshop eine private SQLite-Datenbank. Vor dem Deployment existiert noch keine Cloud-Ressource.
Eine atomare Reservierungsänderung implementieren
In diesem Schritt erstellen Sie dauerhafte Kapazitäts- und Versuchstabellen und implementieren anschließend eine atomare Reservierungsänderung.
Der Konstruktor wird ausgeführt, sobald Cloudflare eine In-Memory-Instanz der Klasse erstellt oder neu startet. CREATE TABLE IF NOT EXISTS erstellt ein fehlendes Schema sicher erneut. Die Anweisung INSERT ... ON CONFLICT DO NOTHING fügt die Kapazitätszeile mit vier Plätzen nur ein, wenn sie noch nicht vorhanden ist. Ein vorhandener Wert für reserved wird niemals auf null zurückgesetzt.
Erstellen Sie den Worker-Einstiegspunkt:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
export class WorkshopReservations extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS workshop_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
capacity INTEGER NOT NULL CHECK (capacity > 0),
reserved INTEGER NOT NULL CHECK (reserved >= 0 AND reserved <= capacity)
)
`);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS reservation_attempts (
request_id TEXT PRIMARY KEY,
seats INTEGER NOT NULL CHECK (seats > 0),
status TEXT NOT NULL CHECK (status IN ('accepted', 'rejected')),
reserved_after INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
this.ctx.storage.sql.exec(`
INSERT INTO workshop_state (singleton, capacity, reserved)
VALUES (1, 4, 0)
ON CONFLICT(singleton) DO NOTHING
`);
});
}
reserve(requestId, seats) {
return this.ctx.storage.transactionSync(() => {
const previous = this.ctx.storage.sql.exec(
`SELECT request_id AS requestId, seats, status, reserved_after AS reserved
FROM reservation_attempts WHERE request_id = ?`,
requestId
).toArray()[0];
if (previous) {
const state = this.ctx.storage.sql.exec(
`SELECT capacity FROM workshop_state WHERE singleton = 1`
).one();
return { ...previous, capacity: state.capacity, replayed: true };
}
const updated = this.ctx.storage.sql.exec(
`UPDATE workshop_state
SET reserved = reserved + ?
WHERE singleton = 1 AND reserved + ? <= capacity
RETURNING capacity, reserved`,
seats,
seats
).toArray();
const accepted = updated.length === 1;
const state = accepted ? updated[0] : this.ctx.storage.sql.exec(
`SELECT capacity, reserved FROM workshop_state WHERE singleton = 1`
).one();
const status = accepted ? "accepted" : "rejected";
this.ctx.storage.sql.exec(
`INSERT INTO reservation_attempts
(request_id, seats, status, reserved_after, created_at)
VALUES (?, ?, ?, ?, ?)`,
requestId,
seats,
status,
state.reserved,
Date.now()
);
return { requestId, seats, status, capacity: state.capacity, reserved: state.reserved, replayed: false };
});
}
getStatus() {
return this.ctx.storage.sql.exec(`
SELECT
s.capacity,
s.reserved,
COUNT(CASE WHEN a.status = 'accepted' THEN 1 END) AS acceptedRequests,
COUNT(CASE WHEN a.status = 'rejected' THEN 1 END) AS rejectedRequests,
COALESCE(SUM(CASE WHEN a.status = 'accepted' THEN a.seats ELSE 0 END), 0) AS acceptedSeats
FROM workshop_state AS s
LEFT JOIN reservation_attempts AS a ON 1 = 1
WHERE s.singleton = 1
GROUP BY s.capacity, s.reserved
`).one();
}
}
function json(data, status = 200) {
return Response.json(data, { status });
}
function workshopRoute(pathname) {
const match = pathname.match(/^\/workshops\/([^/]+)\/(reservations|status)$/);
if (!match) return { error: "not_found", status: 404 };
let workshop;
try {
workshop = decodeURIComponent(match[1]);
} catch {
return { error: "invalid_workshop_name", status: 400 };
}
if (!/^[a-z][a-z0-9-]{0,31}$/.test(workshop)) {
return { error: "invalid_workshop_name", status: 400 };
}
return { workshop, action: match[2] };
}
function validReservation(value) {
return value &&
/^[a-z][a-z0-9-]{2,47}$/.test(value.requestId) &&
Number.isInteger(value.seats) &&
value.seats >= 1 && value.seats <= 4;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return json({ status: "ok" });
}
const parsed = workshopRoute(url.pathname);
if (parsed.error) return json({ error: parsed.error }, parsed.status);
if (request.method === "GET" && parsed.action === "status") {
const stub = env.WORKSHOPS.getByName(parsed.workshop);
const state = await stub.getStatus();
return json({ workshop: parsed.workshop, ...state });
}
if (request.method === "POST" && parsed.action === "reservations") {
let body;
try {
body = await request.json();
} catch {
return json({ error: "invalid_json" }, 400);
}
if (!validReservation(body)) return json({ error: "invalid_reservation" }, 400);
const stub = env.WORKSHOPS.getByName(parsed.workshop);
const result = await stub.reserve(body.requestId, body.seats);
console.log(JSON.stringify({ event: "reservation_decided", workshop: parsed.workshop, requestId: body.requestId, status: result.status, reserved: result.reserved }));
return json({ workshop: parsed.workshop, ...result }, result.status === "accepted" ? 201 : 409);
}
return json({ error: "method_not_allowed" }, 405);
}
};
JS
transactionSync() akzeptiert ausschließlich synchrone Speicheroperationen. Das geschützte UPDATE erhöht den Zähler nur, wenn die angeforderte Anzahl an Plätzen noch in die Kapazität passt. RETURNING liest den Wert, den genau diese Anweisung erzeugt hat. Der Datensatz des Versuchs wird in derselben Transaktion festgeschrieben. Eine wiederholte requestId gibt die ursprüngliche Entscheidung zurück, ohne die Kapazität ein zweites Mal zu verbrauchen.
Führen Sie die deterministischen Routing-Tests und anschließend eine echte Bundle-Prüfung aus:
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run
Erwartet werden zwei erfolgreiche Tests und ein erfolgreicher Dry Run. Es wird keine Remote-Ressource erstellt.
Zehn lokale Anfragen gleichzeitig senden
In diesem Schritt werden zehn Reservierungsbefehle gleichzeitig für einen Workshop mit vier Plätzen ausgeführt. xargs -P 10 startet bis zu zehn Shell-Prozesse parallel. Die Reihenfolge, in der sie beendet werden, ist absichtlich nicht festgelegt.
Starten Sie die lokale Laufzeit mit einem expliziten Persistenzverzeichnis:
npx wrangler dev --port 8787 --persist-to .labex/local-state > .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/health && break
sleep 1
done
Senden Sie zehn Versuche mit jeweils einem Platz an dasselbe stabile Objekt. Jeder Prozess schreibt seine Antwort in eine eigene Datei. Dadurch können sich die Ausgaben paralleler Terminals nicht vermischen:
rm -f .labex/local-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
curl --silent \
--request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
--header "content-type: application/json" \
--data "{\"requestId\":\"request-$1\",\"seats\":1}" \
> ".labex/local-response-$1.json"
' _ {}
HTTP 409 ist die erwartete Anwendungsantwort für eine abgelehnte Reservierung. curl --silent speichert den JSON-Body trotzdem. So können Sie jede Entscheidung prüfen, ohne dass ein voller Workshop als Transportfehler behandelt wird. Fassen Sie alle Entscheidungen zu einem Array zusammen:
jq -s 'sort_by(.requestId)' .labex/local-response-*.json
jq -s '{
accepted: map(select(.status == "accepted")) | length,
rejected: map(select(.status == "rejected")) | length,
highestReserved: map(.reserved) | max
}' .labex/local-response-*.json
Welche Request-IDs angenommen werden, kann variieren, da die Reihenfolge des Eintreffens nicht garantiert ist. Die Invariante bleibt jedoch gleich: Genau vier Anfragen werden angenommen, sechs abgelehnt, und keine Antwort meldet mehr als vier reservierte Plätze.
Lesen Sie die dauerhaften Summen aus dem Objekt:
curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq
Erwartet werden die Kapazität 4, reserved gleich 4, vier angenommene Anfragen, sechs abgelehnte Anfragen und vier angenommene Plätze. Die Antwortdateien erklären die einzelnen Ergebnisse. Die Statuszeile weist nach, dass ihre Summen mit dem dauerhaften Zustand übereinstimmen.
Neustarten, ohne die Kapazität zurückzusetzen
In diesem Schritt entfernen Sie die In-Memory-Instanz der Klasse, indem Sie Wrangler stoppen. Danach starten Sie eine neue Laufzeit mit derselben Datenbank und weisen nach, dass die Initialisierung die Kapazität nicht wiederherstellt.
Stoppen Sie den Prozess und starten Sie ihn neu:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/health && break
sleep 1
done
Lesen Sie den Workshop, bevor Sie eine weitere Entscheidung treffen:
curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq
Die Ausgabe muss weiterhin reserved: 4 melden. Der Konstruktor wurde erneut ausgeführt, aber ON CONFLICT DO NOTHING hat die vorhandene Zeile beibehalten.
Spielen Sie die erste Request-ID erneut ab und senden Sie anschließend eine neue Anfrage, während der Workshop voll ist:
curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
--header 'content-type: application/json' \
--data '{"requestId":"request-1","seats":1}' | jq
curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
--header 'content-type: application/json' \
--data '{"requestId":"request-after-restart","seats":1}' | jq
curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq
Die wiederholte Anfrage enthält replayed: true und fügt keinen weiteren Versuch hinzu. Die neue ID wird einmal abgelehnt. Die endgültigen Summen bleiben bei vier angenommenen Plätzen und steigen auf sieben abgelehnte Anfragen.
Nebenläufigkeit in der Cloud testen
In diesem Schritt stoppen Sie die lokale Laufzeit, deployen den Namespace und wiederholen den begrenzten Nebenläufigkeitstest gegen Cloudflare.
Stoppen Sie den lokalen Prozess und deployen Sie:
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"
Die Worker-Route und der neu abgeglichene Durable-Object-Namespace können zu unterschiedlichen Zeitpunkten verfügbar werden. Fragen Sie wiederholt ein echtes Objekt ab und prüfen Sie dabei den erwarteten JSON-Vertrag. Warten Sie anschließend das getestete kurze Stabilisierungsintervall ab, bevor Sie ein weiteres Objekt erstellen:
for attempt in $(seq 1 30); do
if curl --silent --fail "$APP_URL/workshops/readiness/status" |
jq -e '.capacity == 4 and .reserved == 0' >/dev/null; then
break
fi
sleep 1
done
curl --silent --fail "$APP_URL/workshops/readiness/status" |
jq -e '.capacity == 4 and .reserved == 0'
sleep 5
Senden Sie zehn gleichzeitige Cloud-Versuche an cloud-launch:
rm -f .labex/cloud-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
curl --silent \
--request POST "$0/workshops/cloud-launch/reservations" \
--header "content-type: application/json" \
--data "{\"requestId\":\"cloud-request-$1\",\"seats\":1}" \
> ".labex/cloud-response-$1.json"
' "$APP_URL" {}
Vergleichen Sie die Summen der Antworten mit dem dauerhaften Zustand:
jq -s '{
accepted: map(select(.status == "accepted")) | length,
rejected: map(select(.status == "rejected")) | length,
highestReserved: map(.reserved) | max
}' .labex/cloud-response-*.json
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq
Die Cloud-Invariante entspricht dem lokalen Ergebnis: vier angenommen, sechs abgelehnt und reserved: 4. Führen Sie die unabhängige Prüfung aus. Sie untersucht das verwendete Binding und den Namespace, überprüft cloud-launch und sendet anschließend zwölf gleichzeitige Anfragen an einen separaten, für diesen Durchlauf eindeutigen Workshop:
python3 .labex/verify.py deployed
Erneut deployen und die Reservierungskoordination prüfen
In diesem Schritt deployen Sie den unveränderten Worker erneut. Dadurch kann die In-Memory-Instanz der Klasse ersetzt werden und der Konstruktor erneut ausgeführt werden. Die dauerhafte Kapazitätszeile muss weiterhin voll sein.
Deployen Sie erneut und lesen Sie cloud-launch über eine neue Anfrage aus:
npx wrangler deploy
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq
Erwartet werden weiterhin die Kapazität 4, reserved gleich 4, vier angenommene Anfragen und sechs abgelehnte Anfragen. Diese Prüfung des Cloud-Neustarts führt zum selben Ergebnis wie der lokale Neustart: Die sichere Initialisierung erstellt fehlende Zustände, überschreibt aber niemals bereits vorhandene Zustände.
Öffnen Sie das Cloudflare Dashboard und wählen Sie dasselbe Konto aus. Gehen Sie zu Workers & Pages, öffnen Sie den exakten Worker labex-c10-o03-... und wählen Sie Bindings. Prüfen Sie, dass WORKSHOPS auf den getesteten WorkshopReservations-Namespace verweist.

Das im Screenshot angezeigte Suffix gehört zum akzeptierten Authoring-Durchlauf. Ihr erzeugtes Suffix ist anders. Der Binding-Name, der Typ und die Zielklasse müssen jedoch übereinstimmen.
Öffnen Sie den Namespace und wählen Sie Overview. Storage: SQL kennzeichnet das Backend, in dem die Kapazitäts- und Versuchstabellen gespeichert sind.

Öffnen Sie nun Logs. Erfolgreiche Zeilen mit WorkshopReservations.jsrpc sind die Objektmethodenaufrufe der parallelen Batches und des Verifizierers. Mehrere Objekt-IDs erscheinen, weil readiness, cloud-launch und die eindeutigen Verifier-Workshops absichtlich getrennt sind. Die Logs zeigen Aufrufe und Fehler. Die HTTP-Summen bleiben der maßgebliche Nachweis dafür, dass die Kapazität eingehalten wurde.

Führen Sie die unabhängige Cloud-Prüfung nach dem erneuten Deployment noch einmal aus:
python3 .labex/verify.py deployed
Reservierungsnamespace löschen und abmelden
In diesem Schritt entfernen Sie den temporären Namespace und seine Workshop-Datenbanken endgültig, löschen den verbleibenden Worker und widerrufen die Autorisierung dieser VM.
Bestätigen Sie, dass $RUN mit labex-c10-o03- 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. Der Tombstone state: "deleted" entfernt dauerhaft nur den Namespace der Klasse WorkshopReservations:
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": {
"WorkshopReservations": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
Die Reconciliation-Ausgabe sollte Deleted: WorkshopReservations melden. Löschen Sie den verbleibenden zustandslosen Worker und bestätigen Sie, dass nur der exakt erzeugte Name betroffen ist:
npx wrangler delete --config wrangler.cleanup.jsonc
Weisen Sie das authentifizierte Fehlen der Ressourcen nach, bevor Sie sich abmelden:
python3 .labex/verify.py deleted
Erst nach PASS: deleted widerrufen Sie die Autorisierung der VM und prüfen den strukturierten Zustand:
npx wrangler logout
npx wrangler whoami --json
Das abschließende JSON muss "loggedIn": false enthalten. Ein Netzwerk- oder Authentifizierungsfehler ist kein Nachweis für eine erfolgreiche Bereinigung.
Zusammenfassung
Sie haben einen Reservierungsdienst mit begrenzter Kapazität erstellt, bei dem jeder Workshopname ein eigenes Durable Object auswählt. Eine synchrone SQLite-Transaktion hat die Kapazitätsprüfung, die Änderung des Zählers und den Datensatz des Versuchs zu einer unteilbaren Änderung verbunden. Zehn gleichzeitige Aufrufer konnten in beliebiger Reihenfolge fertig werden, aber genau vier Plätze wurden angenommen und keine Antwort überschritt die Kapazität.
Außerdem haben Sie die Initialisierung mit ON CONFLICT DO NOTHING sicher gemacht, eine stabile Request-ID wiederholt, ohne doppelt zu buchen, und dieselben dauerhaften Summen nach einem lokalen Neustart und einem erneuten Cloud-Deployment nachgewiesen. Abschließend haben Sie die Laufzeitnachweise mit dem Binding, dem SQL-Namespace und den RPC-Logs im Dashboard verknüpft und anschließend genau den Namespace und den Worker gelöscht, bevor Sie sich abgemeldet haben.



