diff --git a/apps/survey.immich.app/backend/src/durable-objects/index.ts b/apps/survey.immich.app/backend/src/durable-objects/index.ts index 142d8d33..241e6d03 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/index.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/index.ts @@ -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 { return new Response('This worker only hosts Durable Objects', { status: 404 }); diff --git a/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts b/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts index e5e591b6..189af2b5 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts @@ -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 }); } diff --git a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts index 877fbb23..c60b9c03 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts @@ -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 {}; } diff --git a/apps/survey.immich.app/backend/wrangler-dev.jsonc b/apps/survey.immich.app/backend/wrangler-dev.jsonc index e42fd588..63e4816b 100644 --- a/apps/survey.immich.app/backend/wrangler-dev.jsonc +++ b/apps/survey.immich.app/backend/wrangler-dev.jsonc @@ -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", diff --git a/apps/survey.immich.app/backend/wrangler-do.jsonc b/apps/survey.immich.app/backend/wrangler-do.jsonc index 26116b88..43827adb 100644 --- a/apps/survey.immich.app/backend/wrangler-do.jsonc +++ b/apps/survey.immich.app/backend/wrangler-do.jsonc @@ -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"], }, ], diff --git a/deployment/modules/cloudflare/workers/survey/d1.tf b/deployment/modules/cloudflare/workers/survey/d1.tf index b257ef7d..f632d25e 100644 --- a/deployment/modules/cloudflare/workers/survey/d1.tf +++ b/deployment/modules/cloudflare/workers/survey/d1.tf @@ -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. diff --git a/deployment/modules/cloudflare/workers/survey/worker.tf b/deployment/modules/cloudflare/workers/survey/worker.tf index e9f6ecad..8921ff9c 100644 --- a/deployment/modules/cloudflare/workers/survey/worker.tf +++ b/deployment/modules/cloudflare/workers/survey/worker.tf @@ -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" {