fix(survey): make the worker module work on a fresh environment (#703)

This commit is contained in:
Zack Pollard
2026-08-16 12:01:56 -04:00
committed by GitHub
parent f4b32da07a
commit 11c62c174d
7 changed files with 87 additions and 25 deletions
@@ -1,11 +1,5 @@
export { SurveyDO } from './survey-do';
// Legacy alias for the original KV-backed class (see wrangler-do.jsonc v1
// migration: `new_classes: ["SurveySession"]`). Cloudflare rejects a deploy
// that removes a class still declared in the migration chain, so the alias
// stays exported until a future `deleted_classes` migration retires v1.
export { SurveyDO as SurveySession } from './survey-do';
export default {
async fetch(): Promise<Response> {
return new Response('This worker only hosts Durable Objects', { status: 404 });
@@ -451,6 +451,13 @@ export class SurveyDO extends DurableObject {
answers: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
};
await this.respondentService.submitBatch(survey.slug!, respondentId, answers, survey);
// The unload beacon submits over HTTP even in WebSocket mode, so without this
// the cached choice answers miss whatever was flushed on the way out and the
// live tallies under-count once the respondent completes.
for (const a of answers) {
this.cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
}
return new Response(null, { status: 204 });
}
@@ -262,10 +262,6 @@ async function handleOp(
if (error) throw new ServiceError(error, 400);
}
for (const a of answers) {
cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
}
// answer_ms is clamped via the shared helper so it stays in lockstep with the
// HTTP path in respondent.service.
const now = new Date().toISOString();
@@ -278,6 +274,13 @@ async function handleOp(
`INSERT OR REPLACE INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms) VALUES ${placeholders}`,
...values,
);
// Cache only after the write lands: a throw here leaves answers in
// choiceAnswers that were never persisted, and completion would fold them
// into the live tallies.
for (const a of answers) {
cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
}
return {};
}
@@ -18,9 +18,6 @@
},
],
},
// SurveyDO is the only Durable Object class and is SQLite-backed from the
// start. (The earlier v1 "SurveySession" class never existed — it only
// produced a "no such Durable Object class is exported" warning on boot.)
"migrations": [
{
"tag": "v1",
@@ -11,19 +11,12 @@
},
],
},
// The deployed sessions worker reached tag "v2" through this chain, so it must
// be preserved as-is: v1 created the original "SurveySession" class and v2
// introduced the SQLite-backed "SurveyDO". The Terraform deploy (worker.tf)
// asserts old_tag == new_tag == "v2" against this state. `durable-objects/
// index.ts` still exports `SurveyDO as SurveySession` so the v1 class name
// remains resolvable.
// This config only drives `wrangler build`; Terraform deploys the worker and
// applies the migration itself, so this must stay in sync with the block in
// deployment/modules/cloudflare/workers/survey/worker.tf.
"migrations": [
{
"tag": "v1",
"new_classes": ["SurveySession"],
},
{
"tag": "v2",
"new_sqlite_classes": ["SurveyDO"],
},
],
@@ -13,6 +13,10 @@ resource "null_resource" "d1_migrations" {
}
provisioner "local-exec" {
# local-exec defaults to /bin/sh, which is dash on the CI runners and has no
# pipefail.
interpreter = ["/bin/bash", "-c"]
# Re-running an already-applied migration is benign ("already exists" /
# "duplicate column"); every other error must exit non-zero so the failure is
# visible in the workflow instead of being masked.
@@ -109,6 +109,51 @@ resource "cloudflare_worker" "sessions" {
}
}
# Cloudflare applies a version's migration when the version is deployed, and
# rejects it unless old_tag matches the tag the Worker is already on. A fresh
# Worker therefore needs the class-creating migration with no old_tag, and every
# later deploy needs a no-op migration carrying that same tag — no single block
# is valid in both states. This version exists only to run the first migration
# and is frozen once created, so each new environment bootstraps itself and the
# rolling version below stays on the no-op tag forever after.
resource "cloudflare_worker_version" "sessions_bootstrap" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.sessions.id
compatibility_date = "2025-06-03"
main_module = "sessions.js"
modules = [{
name = "sessions.js"
content_file = "${var.dist_dir}/sessions.js"
content_type = "application/javascript+module"
}]
migrations = {
new_tag = "v1"
new_sqlite_classes = ["SurveyDO"]
}
lifecycle {
ignore_changes = all
}
}
resource "cloudflare_workers_deployment" "sessions_bootstrap" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.sessions.name
strategy = "percentage"
versions = [{
version_id = cloudflare_worker_version.sessions_bootstrap.id
percentage = 100
}]
lifecycle {
ignore_changes = all
}
}
resource "cloudflare_worker_version" "sessions" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.sessions.id
@@ -123,9 +168,11 @@ resource "cloudflare_worker_version" "sessions" {
}]
migrations = {
old_tag = "v2"
new_tag = "v2"
old_tag = "v1"
new_tag = "v1"
}
depends_on = [cloudflare_workers_deployment.sessions_bootstrap]
}
resource "cloudflare_workers_deployment" "sessions" {
@@ -137,6 +184,12 @@ resource "cloudflare_workers_deployment" "sessions" {
version_id = cloudflare_worker_version.sessions.id
percentage = 100
}]
lifecycle {
# Redeploying the frozen bootstrap would otherwise silently roll the Worker
# back to the code it was first created with.
replace_triggered_by = [cloudflare_workers_deployment.sessions_bootstrap]
}
}
resource "cloudflare_worker" "api" {
@@ -146,6 +199,11 @@ resource "cloudflare_worker" "api" {
observability = {
enabled = true
}
# Ordering only, so that destroy runs in reverse and tears this Worker down
# first: Cloudflare refuses to delete the sessions Worker while this one still
# binds its Durable Object namespace.
depends_on = [cloudflare_worker.sessions]
}
resource "cloudflare_worker_version" "api" {
@@ -184,16 +242,22 @@ data "cloudflare_zone" "immich_app" {
}
}
# A Worker with no deployed version does not exist as far as the routes API is
# concerned, and referencing only the name would let these race the deployment.
resource "cloudflare_workers_route" "survey_api_root" {
zone_id = data.cloudflare_zone.immich_app.zone_id
pattern = "${module.domain.fqdn}/api"
script = cloudflare_worker.api.name
depends_on = [cloudflare_workers_deployment.api]
}
resource "cloudflare_workers_route" "survey_api_wildcard" {
zone_id = data.cloudflare_zone.immich_app.zone_id
pattern = "${module.domain.fqdn}/api/*"
script = cloudflare_worker.api.name
depends_on = [cloudflare_workers_deployment.api]
}
module "domain" {