OwnSona keeps its database and stored data current with the deployed code automatically, at server startup, with no manual SQL step. A deploy is just a WAR swap; the running server detects that the database is behind the code and brings it forward itself.
This document describes that facility: what it is, the three stages it runs, how each one behaves under success and failure, and how to add a new auto-applied change.
There is no facility that updates the application code / WAR itself (no phone-home, no self-downloading binary). "Auto-update" here means the server, after a normal WAR redeploy, automatically migrates the schema and data it finds to match the new code. Code updates are operator-driven deploys (see
Deploy.md/INSTALL.md).
When the /mcp servlet class loads, its static initializer runs three
startup walkers in a fixed order:
| # | Stage | Class | Granularity | Tracks | Gated by |
|---|---|---|---|---|---|
| 1 | Schema migration | ai.ownsona.migrations.DbMigrator |
whole database | db_version table |
always runs |
| 2 | Per-row upgrade | ai.ownsona.memory.RecordMigrator |
each memories row |
record_version column |
always runs |
| 3 | Re-embedding | ai.ownsona.embeddings.ReembedJob |
each memories row |
(embedding_provider, embedding_model) |
REEMBED_ON_STARTUP=true |
The order is not arbitrary:
- Schema first — migrations may create the very columns
(
record_version,salience, …) that the later stages read or write. - Per-row upgrade next — fills new columns on existing rows from data those rows already hold.
- Re-embedding last — only meaningful once the schema and row shape are current; it is also the only optional / cost-bearing stage.
// MCPServer.java
@WebServlet(urlPatterns = "/mcp", loadOnStartup = 1)
public class MCPServer extends MCPServerBase {
static {
Configurator.setLevel("ai.ownsona", Level.INFO); // verbose during init
...
try {
DbMigrator.runOnStartup(); // stage 1
new RecordMigrator(repo).runOnStartup(); // stage 2
new ReembedJob(repo, provider).runOnStartup(); // stage 3
} finally {
Configurator.setLevel("ai.ownsona", Level.ERROR); // quiet afterward
}
}
}loadOnStartup = 1 forces the static initializer (and therefore the
whole auto-update sequence) to run at Tomcat startup, not lazily on
the first /mcp request. The point is that a failed migration surfaces
in catalina.out / journalctl -u ownsona.service at deploy time,
not when a real client hits the endpoint.
ai.ownsona logging is held at INFO for the duration of the static
initializer so the startup banner and every migrator line are visible in
the deploy log, then dropped to ERROR before the initializer returns
(in a finally, so the drop happens even if a stage threw). WARN/ERROR
always appear.
Don't conflate them:
db_version— an integer for the whole database (one row per applied migration in thedb_versiontable). Owned byDbMigrator. Expected value =MigrationRegistry.CURRENT_DB_VERSION(currently 8).record_version— an integer column on eachmemoriesrow. Owned byRecordMigrator. Expected value =RecordUpgraderRegistry.CURRENT_RECORD_VERSION(currently 2).
A schema change that adds a column is a migration; backfilling that
column on existing rows from data they already have is a record
upgrader. The two often ship together (e.g. Migration006AddSalience
adds the salience column; SalienceSeedUpgrader seeds it from
importance).
Brings the database from whatever version it is at up to
CURRENT_DB_VERSION by applying registered Migrations in order.
Algorithm (DbMigrator.runOnStartup):
- Validate the registry (
MigrationRegistry.validate()) before touching the DB — fail fast on programmer error. - Bootstrap the
db_versiontable:CREATE TABLE IF NOT EXISTS, then seed version 1 (the001_init.sqlbaseline) withINSERT ... ON CONFLICT (version) DO NOTHING. Read backMAX(version)ascurrent. - Refuse if the DB is ahead of the code (
current > target) — throws, so an older WAR can't run against a newer schema (rollback guard). The message tells the operator to either deploy code with a high enoughCURRENT_DB_VERSIONor restore a snapshot. - Nothing to do if
current == target— log and return. - Apply each registered migration whose
version() > current, in order, advancingcurrentafter each.
Per-migration transaction (applyOne): each migration runs on its
own fresh connection / transaction. The migration's DDL and the
INSERT INTO db_version row that records it commit together. Any
failure rolls both back, so db_version is never bumped past a
migration that didn't fully apply — the next startup retries from the
same point.
Failure = servlet refuses to load. runOnStartup throws
IllegalStateException on any failure; MCPServer.<clinit> lets it
propagate, so the servlet fails to load and Tomcat surfaces the error.
We never serve traffic on a half-migrated DB.
The Migration contract (migrations/Migration.java):
version()— the version this migration brings the DB to. Must be exactly one greater than the previous registered migration. Version 1 is the001_init.sqlbaseline and is never aMigrationobject; the first registered migration is version 2.apply(Connection db)— runs inside theDbMigrator-managed transaction; throw to abort.- Additive only (invariant #2): add columns, add indexes, insert
seed rows. Never
DROP,RENAME, or rewrite existing values. (A destructive one-time data rewrite is a manual operation under "Procedure B," not a migration.) - Idempotent: every migration uses
IF NOT EXISTS/ADD COLUMN IF NOT EXISTSso a retry after a partial failure is safe. - Permanent: once shipped, a migration class stays in the codebase forever (some old database somewhere may still need it). Don't edit a shipped migration — write a new one.
The registry (MigrationRegistry): an ordered, unmodifiable list
plus CURRENT_DB_VERSION. validate() enforces that the registered
versions are exactly 2, 3, …, CURRENT_DB_VERSION — contiguous, in
order, no gaps, no duplicates, no empty names — and that the count is
CURRENT_DB_VERSION - 1. The current chain:
v2 Migration002AddRecordVersion (adds the record_version column itself)
v3 Migration003AddFreshness
v4 Migration004AddTombstones
v5 Migration005AddKeep
v6 Migration006AddSalience
v7 Migration007AddContextVector
v8 Migration008AddRelations
Bootstrap privilege note: the first deploy with the auto-migrator
needs the application role to be able to CREATE TABLE on schema
public. On Postgres 15+ that privilege isn't granted by default;
DbMigrator.bootstrap() wraps the failure with a hint pointing at
GRANT CREATE ON SCHEMA public TO <role> (fresh installs get this via
001_init.sql; existing installs run sql/migrator_prep.sql once).
Walks every memories row whose record_version is below
CURRENT_RECORD_VERSION and applies the registered RecordUpgrader
chain to bring it forward.
Algorithm (RecordMigrator.runOnStartup):
RecordUpgraderRegistry.validate()— fail fast on misconfiguration.- If the registry is empty (target 1), no row can be below 1 — log and return.
- Paginate by ascending id,
CHUNK_SIZE = 500(repo.findIdsBelowVersion). A partial chunk means end-of-table. - For each id, upgrade that row's chain. Per-row failures are caught,
logged, and counted (
failed++) — they never throw and never block startup.
Per-row transaction (upgradeOne): each row is upgraded on its own
connection / transaction. The row is loaded by id; then while
current < target, the upgrader registered for current is looked up
and applied, advancing current to its toVersion(). After the chain
succeeds, repo.bumpVersion sets the row's record_version to the
target — in the same transaction, so the data change and the version
bump commit together. A row hard-deleted between the scan and the
upgrade is treated as already done.
The RecordUpgrader contract (memory/RecordUpgrader.java):
fromVersion()/toVersion()— the upgrader at registry indexN-1takes a row from versionNtoN+1. Multi-version rows advance by chaining upgraders.- Strictly additive (invariant #3): fill a new column / metadata key from existing data, set a default, enrich a row. Never overwrite, transform, or delete existing values — original text, tags, embeddings, and timestamps are immutable from an upgrader's view.
- Idempotent: re-running on the same row must produce the same
outcome. (
SalienceSeedUpgraderonly fillssaliencewhere itIS NULL, so it can never clobber a value the user's feedback already moved.) - Per-row isolation: a thrown upgrade rolls back just that row.
- Permanent: don't edit a shipped upgrader; write a new one.
- An upgrader must not bump
record_versionitself — the migrator does that after the chain completes.
The registry (RecordUpgraderRegistry): ordered list plus
CURRENT_RECORD_VERSION (2). validate() enforces a contiguous
fromVersion sequence 1, 2, …, target-1 with each toVersion = fromVersion + 1. Current chain:
v1 -> v2 SalienceSeedUpgrader (seed salience from importance)
Re-embeds rows whose stored (embedding_provider, embedding_model)
doesn't match the active config, or whose embedding is NULL (which
happens after an embedding-dimension column resize). This is how a
provider/model switch is rolled across the existing store.
Gated and self-disabling:
- Runs only when
REEMBED_ON_STARTUP=trueinapplication.ini; otherwise it logs "skipping" and returns 0. - On clean completion, it flips
REEMBED_ON_STARTUPback tofalse(viaApplicationIniWriter.setKey, an atomic stage-temp-then-ATOMIC_MOVEwrite) so a later restart doesn't re-run a no-op walker. If it can't write the file, it warns rather than failing.
Algorithm (ReembedJob.runOnStartup):
- Read the active provider/model/dims.
- Page the table (
BATCH_SIZE = 50) viarepo.findStaleEmbeddingIds— rows still showing the old provider/model. - Per batch: read the texts, embed them all in one provider call, write each new vector + provider + model back. Each batch is its own connection / transaction, so a crash loses at most the current batch.
- Resumable by SELECT filter: the loop terminates only when a page comes back empty. Kill the JVM mid-run and the next startup picks up exactly the rows still showing the old provider/model.
Failure policy differs from stage 2 on purpose: a per-batch failure
aborts the whole job (and, being thrown, refuses the servlet load).
A partial run that silently skipped broken rows would leave the store in
a mixed-model state with no operator visibility. See REEMBED.md for
the full operational runbook.
Operator caveat:
ApplicationIniWriterwrites the unpacked deployment'sapplication.ini, not the source tree. A future WAR that shipsREEMBED_ON_STARTUP=truewill re-enable the walker on next restart — update the source-tree copy separately before the next build.
| Stage | Per-unit failure | Effect on startup | Recovery |
|---|---|---|---|
DbMigrator |
a migration throws | servlet refuses to load; db_version not bumped |
fix + redeploy; next startup retries from same version |
DbMigrator |
DB ahead of code | servlet refuses to load | deploy newer code or restore snapshot |
RecordMigrator |
one row throws | logged + counted; startup continues | next startup retries just the rows still below target |
ReembedJob |
one batch throws | whole job aborts → servlet refuses to load | fix provider/connectivity; next startup resumes via SELECT filter |
Common threads across all three:
- Each unit of work (migration / row / batch) is its own connection/transaction; the data change and its version/marker bump commit atomically.
- Kiss connections are
autoCommit = false; the framework'scloseConnection(db, ok)commits ontrue, rolls back onfalse. - All three walkers are idempotent and resumable — re-running after a crash never double-applies and never skips.
- Operational stance (single-user prod): take a fresh backup before
any deploy that bumps
CURRENT_DB_VERSION("Procedure B"); the backup is the rollback net. A no-migration deploy ("Procedure A") is a pure WAR swap.
- Create
src/main/precompiled/ai/ownsona/migrations/MigrationNNN_descriptive_name.javaimplementingMigration; do the additive DDL inapply(db)withIF NOT EXISTS. - Register it:
m.add(new MigrationNNN_...());inMigrationRegistry. - Bump
CURRENT_DB_VERSIONtoNNN. - Commit all three together. (Pre-write the
DROP COLUMNrollback SQL in the rollout-plan ship checklist, not in the class.)
- Create a
RecordUpgraderinsrc/main/precompiled/ai/ownsona/memory/;upgrade(db, row)fills new fields only (additive + idempotent). SetfromVersion()to the row's current version andtoVersion() = fromVersion() + 1. - Register it in
RecordUpgraderRegistry's list, in order. - Bump
CURRENT_RECORD_VERSIONto match. - Commit all three together.
Invariant #5 — lockstep.
CURRENT_DB_VERSION↔ migration registry andCURRENT_RECORD_VERSION↔ upgrader registry must change in the same commit. Bothvalidate()methods fail fast at startup on a mismatch, but the right place to catch it is code review.
No code change — set REEMBED_ON_STARTUP=true in the deployed
application.ini and restart. The job runs once and disables itself.
See REEMBED.md.
src/main/precompiled/ai/ownsona/
MCPServer.java # <clinit> drives all three stages (loadOnStartup=1)
migrations/
Migration.java # interface + contract
MigrationRegistry.java # ordered list + CURRENT_DB_VERSION + validate()
DbMigrator.java # stage 1: schema walker
Migration002..008*.java # the applied schema changes
memory/
RecordUpgrader.java # interface + contract
RecordUpgraderRegistry.java # ordered list + CURRENT_RECORD_VERSION + validate()
RecordMigrator.java # stage 2: per-row walker
SalienceSeedUpgrader.java # v1 -> v2 backfill
MemoryRepository.java # findIdsBelowVersion / bumpVersion / findStaleEmbeddingIds
embeddings/
ReembedJob.java # stage 3: embedding refresh walker
ApplicationIniWriter.java # atomic single-key application.ini rewrite (flag flip)
sql/
001_init.sql # the version-1 baseline (fresh installs)
migrator_prep.sql # one-time CREATE-privilege grant for existing installs
Related reading: AI/ApplicationDetails.md (design invariants #2–#5,
the "How to add a schema change / per-record upgrader" recipes),
OwnSona-rollout-plan.md (auto-migration framework rationale and the
Procedure A/B deploy model), REEMBED.md (re-embed runbook),
INSTALL.md §15 (upgrading an existing install).