Database Migration Procedure

The rule: the schema moves before the image that expects it, every time, by the same mechanism, and a roll that cannot move the schema says so loudly instead of rolling anyway.

How the schema moves

There is one migration program (Memex.Database.Migration, built as memex-migration:<tag> beside memex-portal-ai:<tag> from the same commit β€” the two share DbVersion.Latest). It reads admin.mesh_nodes.db_version, applies every V##_* above it, writes the new version, then runs the always-on reconcile (partition access, doc backfill, embeddings, Orleans clustering, searchable schemas) and exits. It is idempotent: at latest it applies nothing and reconciles.

Two things mint the Job that runs it:

Path Job name When
helm upgrade memex-migration-<release revision> (deploy/helm/templates/memex-migration/job.yaml) Every chart upgrade
Self-update (SelfUpdateHostedService β†’ IDeploymentUpdater.RunMigrationAsync) memex-migration-su-<tag> Every automatic roll, before the portal image is patched

The portal's DbVersionGate is the backstop, not the mechanism: it refuses to start a pod whose build expects a db_version the database has not reached. That refusal is correct β€” and it is invisible from the front door: the crash-looping pod is out of the Service endpoints, the old ReplicaSet keeps serving, and https://…/ answers 200 the whole time.

🚨 The wedge this procedure exists for (2026-09-03)

Both AKS portals had been rolling images by kubectl set image (the self-updater) for days without a helm upgrade β€” memex since revision 28 (Aug 30), memex-cloud since revision 21 (Aug 30). Plugins PR #1216 added V55 (the pg_notify payload carries node_type; DbVersion.Latest = 55). The next self-update rolled memex-portal-ai to a build expecting 55; nothing minted a migration Job; the DB stayed at 54:

crit: Memex.Portal.Distributed.DbVersionGate[0]
      DB migration incomplete: admin.mesh_nodes.db_version=54 < expected 55. … Refusing to start.

memex: 45 restarts over ~7 h behind a 200. memex-cloud: the same, while its eight old pods kept running pre-#1216 code β€” the very fan-out storm #1216 fixes (1,917 slow 201-schema UNIONs per 30 min per pod) β€” so the site was up and unusable at the same time.

kubernetes.io/change-cause still read "roll to 3.0.0-rc8.ci.5083 … 2026-08-23" at deployment revision 641: set image never updates it. Read meshweaver.io/self-update-rolled-at and the container image instead.

The routine

1. Adding a migration (developer). Drop V##_*.cs under Memex.Database.Migration/Migrations and bump DbVersion.Latest; MigrationRegistry.VerifyComplete() refuses a mismatch at startup. Say so in the PR title β€” a reviewer should see "schema" without opening files. Nothing else: the self-updater runs it.

2. One-time grant per install (operator). The self-updater creates Jobs under memex-portal-sa; the chart's memex-portal/rbac.yaml grants batch/jobs create,get,list,delete. An install that has not been helm upgraded since that rule landed answers the Job POST with 403 β€” the self-updater logs, at Warning, that it is rolling without the migration and names this paragraph. Do the helm upgrade once and it stops.

3. Every automatic roll. RunMigrationAsync(tag) creates memex-migration-su-<tag> (same ConfigMap and Secret as the helm Job, same image tag as the portal it precedes), waits for status.succeeded within SelfUpdate:MigrationJobTimeout (30 min), and only then patches the portal image. Failed or timed out β‡’ the roll is refused and recorded as SelfUpdateOutcome.MigrationFailed on Admin/UpdatePolicy; the schema demonstrably did not move, so the image must not. NotSupported (a host whose IDeploymentUpdater predates the seam) or Forbidden (step 2 not done) β‡’ the roll proceeds as it always did, at Warning, with DbVersionGate as the only net.

4. Verifying. helm list -n <ns> against kubectl get deploy memex-portal-deployment -n <ns> -o jsonpath='{.spec.template.spec.containers[0].image}': divergence means the schema and the code are on different clocks. A migration Job's success line is literal: Database migration completed. Version: N. Anything else is not a pass.

Recovery when a pod is refusing on db_version

Do not helm upgrade with the values on file β€” they pin the image the chart last knew (rc8 on both portals that day), and an upgrade would roll the portal back. Mint the Job by hand at the tag the deployment is already on:

apiVersion: batch/v1
kind: Job
metadata: { name: memex-migration-v55-manual, namespace: <ns> }
spec:
  backoffLimit: 6
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: memex-migration
          image: meshweaver.azurecr.io/memex-migration:<the deployment's tag>
          envFrom:
            - configMapRef: { name: memex-migration-config }
            - secretRef: { name: memex-migration-secrets }

az aks command invoke … --file job.yaml --command "kubectl apply -f job.yaml", then wait for Database migration completed. Version: N. The gate is db_version < expected, so migrating forward never breaks the pods still serving on the older build. (Break-glass form: a HelmRelease deploy Hosting/InstanceAction runs the same Job through helm upgrade with no hand-applied manifest β€” OperatingFromThePortal.)

🚨 Why a migration deadlocks under load β€” and what to do until the fix ships

The migration runner's schema initialisation drives mw_auth_mirror_heal_batch β€” per partition schema it CREATE OR REPLACEs the access trigger functions, re-installs triggers on mesh_nodes, and re-runs rebuild_user_effective_permissions() (an ACCESS EXCLUSIVE rename-swap of the permission table). A live pod meanwhile holds ACCESS SHARE on those same tables across a multi-schema UNION, or a row lock on access inside the very trigger being replaced. Opposite orders β‡’ 40P01 deadlock detected, and Postgres kills the migration. On memex-cloud it died five times out of five at eight replicas, before it had even read db_version; at three replicas it completed in 11 minutes. Two consequences:

🚨 A long-running Job is not a stuck Job β€” measure it before you kill it

The schema steps finish in seconds to minutes; the always-on reconcile that follows (partition access, doc backfill, embeddings) is a loop over every partition schema, and on a large install it runs for hours. Elapsed time therefore says nothing about health, and "it has been running all day, it can never finish" is a conclusion that has been reached β€” and been wrong.

Measured on memex-cloud 2026-09-07: Job memex-migration-28 was described as a backfill that "can never finish within its own design" and slated for deletion after 9 h 50 m. Its own log said otherwise β€” Current DB version: 55 (the schema half had completed 90 s in), 165 of the 220 partition schemas the run had announced, 74,246 rows embedded, and a steady 200 rows per 81–86 s. It was left to run, and it finished on its own ~2 h later: Database migration completed. Version: 55, Job Complete 1/1, duration 10 h, then ttlSecondsAfterFinished removed it with no manual step. Nothing needed intervention at any point β€” both portals served HTTP 200 throughout.

Read these five before deciding, in this order β€” they are all in the Job itself:

Read Where What it settles
Current DB version: N the Job's log Whether the schema duty is already done. If it is, nothing is blocked on this Job: DbVersionGate passes and the portal serves.
rows per unit time two consecutive N/M… progress lines and their --timestamps Whether it is moving at all. A flat counter is a stall; a steady one is work.
schemas done over schemas total the [EmbeddingBackfill] N partition schema(s) line at the phase start is the denominator; count the <schema>: N embedded completion lines for the numerator The only honest progress figure. 🚨 Treat the numerator as a lower bound: a schema with nothing to embed completes without logging a count.
the schema name in flight the same lines A relative position only β€” the loop is alphabetical, so the name tells you every schema sorting before it is done, and nothing more. 🚨 Do not read an ETA off the letter: a late letter does not mean "nearly done" (there may be many w…–z… schemas) and schemas differ in size by orders of magnitude. Use the count above for how far along it is, and the rate for how fast.
activeDeadlineSeconds kubectl get job … -o jsonpath='{.spec.activeDeadlineSeconds}' Whether Kubernetes will let it finish. Empty means no deadline β€” it runs to completion, and ttlSecondsAfterFinished then removes it with no manual cleanup.

Deleting a reconcile that is progressing costs the un-embedded tail: those rows stay NULL and their partitions answer vector search poorly until some later migration reaches them again. The per-row writes are committed as they go, so a delete loses only the row in flight β€” but it also loses the remaining work, which nothing re-queues on its own.

When it genuinely is stuck, the tell is a counter that does not move (or 40P01 in the log, which is the deadlock above), never the elapsed time. And the durable fix for the long tail is not a kill: it is the budgeted, batched reconcile β€” a bounded slice per Job that reports its remainder and resumes on the next one β€” so no single Job has to finish the whole backfill.

🚨 The budget is not retroactive, so "it should have stopped after ten minutes" is not a reason to kill one. The migration image carries its own budget: memex-migration-28 ran 3.0.0-ci.8009, which predates both the ten-minute Job budget (MeshWeaver#3630) and the batched backfill (MeshWeaver.Plugins#1488). A Job only becomes time-bounded once its environment rolls onto a set built after those landed β€” until then a migration running for hours is behaving exactly as its image was built to, and the readings above are the only way to judge it.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.