Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- ORM: **each migration now runs in a real transaction, and the runner is safe to race.** `Migrator::run`/`rollback` used to fake atomicity with `execute("BEGIN") … execute("COMMIT")` — but both stores are connection *pools*, so under any concurrent traffic those statements land on different connections: measured on the old runner, **7 of 60 failing migrations left their half-applied schema behind** (and on Postgres the parked `BEGIN` poisoned a pooled connection). Each migration's body and its history row now commit in one single-connection transaction via the `Transactional` seam, so `run`, `rollback`, `sync`, and `Model::migrate` now require `Backend + Transactional` — which also makes it a compile error to run migrations from inside an open transaction handle. Concurrent runners serialize on the backend's named advisory lock (`sutegi:migrations`: a dedicated crash-released session on Postgres, the process registry on SQLite), waited for by **polling** rather than a server-side `pg_advisory_lock()` wait — a parked waiter holds a snapshot, which deadlocks against a `CREATE INDEX CONCURRENTLY` holder — and each migration re-checks the history table *inside its own write transaction* (`BEGIN IMMEDIATE` on SQLite), so even two OS processes on one SQLite file — where no shared lock exists — skip instead of double-applying. Verified by a reliability suite that races 8 runners on a pooled file DB, 6 fresh handles on Postgres, and replays the exact old failure under load.

### Added

- ORM: **migration preflight guards — nothing runs until the whole plan is validated.** Duplicate versions (two files, or a file shadowing a coded migration), empty or non-portable version/name strings (also closes `write_migration_file` writing outside its directory via a `../`-shaped version), an applied migration that was *renamed* in code (the checksum guard already caught edits; `repair` now re-stamps names too), and an **out-of-order pending migration** — one sorting before a version already applied, the merged-stale-branch hazard whose DDL would run against a schema later migrations already reshaped. Out-of-order is a hard error naming the versions; teams that genuinely interleave opt in with `Migrator::allow_out_of_order()`. The guard anchors only on versions the migrator itself defines, so two apps sharing one database (and one `_sutegi_migrations` table) don't read each other's history as staleness.
- ORM: **`Migrator::rollback` preflights the whole batch before undoing anything.** It used to discover a forward-only or code-deleted migration *mid-batch*, erroring with the newer half already rolled back — the one state with no clean way forward or back. Now that discovery happens up front and the database is untouched.
- ORM: **`Migrator::plan_run(&db)` — the dry run.** The pending migrations in apply order, each with the exact SQL a declarative migration would execute (rendered for the backend's dialect against the live schema, table-rebuild expansions included); closure bodies report `None` rather than pretending. Read-only, so it's safe to wire into a deploy pipeline's review step.
- ORM: **`Migration::no_transaction()`** for DDL that refuses to run inside a transaction — Postgres `CREATE INDEX CONCURRENTLY` being the canonical case. The trade is explicit and documented: a crash between the body and its history row re-runs the body next time, so such migrations must be idempotent. `Migrator::lock_timeout(...)` tunes how long a runner waits for a busy migration lock (default 300 s) instead of hanging a deploy forever, and `MigrationOps` gained `dialect()` so a closure migration can write dialect-specific SQL without guessing.

### Fixed

- ORM: **dev-mode `sync` (and `Model::migrate`) is now atomic.** A SQLite column widening is a four-statement table rebuild (create-new / copy / drop / rename); a failure or crash mid-rebuild could previously strand the copy — or worse, sit between the drop and the rename. The whole sync now runs inside one transaction.

### Added

- Web: **`App::listener` — non-HTTP socket loops as first-class app citizens.** A UDP ingest port, a raw TCP protocol, a discovery beacon: `std::net` could always run them on a hand-spawned thread, but that thread was invisible to the app — it outlived graceful drains, saw none of the shared state, and appeared nowhere an agent could discover it. `app.listener(name, doc, run)` registers a closure that runs on its own named thread for the life of the server and receives a `ListenerCtx`: `should_stop()` (the same flag `run_graceful` flips on SIGTERM) plus the typed `state::<T>()` / `db::<B>()` access handlers and tools already have. Shutdown is now whole-app: `run`/`run_until` stop accepting, drain in-flight HTTP requests, then **join listener threads before returning**, so a rolling deploy waits for your loop's last iteration. The contract is cooperative — block with a socket read timeout and poll `should_stop()`, because the join waits for the loop to notice. A panicking listener is caught and reported on stderr instead of dying silently; `/__introspect` gains a `listeners` block (name + doc), keeping the non-HTTP surface agent-discoverable; `App::service()` never spawns listeners, so in-process tests and benches stay socket-free. See `docs/LISTENERS.md`.
Expand Down
7 changes: 4 additions & 3 deletions crates/sutegi-orm/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,10 @@ pub trait Model {
/// **add any columns/indexes/foreign keys the model gained** — the fix for
/// the old create-if-missing behaviour that silently ignored new fields.
/// Additive and non-destructive; it errors (pointing at `migrate gen`) on a
/// change that needs a real migration. Use a [`Migrator`](crate::migrate)
/// for production.
fn migrate<B: Backend>(conn: &B) -> Result<(), String> {
/// change that needs a real migration, and runs inside one transaction so
/// a failure never leaves a table half-rebuilt. Use a
/// [`Migrator`](crate::migrate) for production.
fn migrate<B: Backend + Transactional>(conn: &B) -> Result<(), String> {
crate::migrate::sync_table(conn, &Self::schema())
}

Expand Down
Loading
Loading