Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cf9329b
feat(migrations): apply a catalog's pending migrations at startup
LeeroyHannigan Oct 30, 2026
5d7e9b1
feat(npm): count the startup timeout from the last migration progress…
LeeroyHannigan Oct 30, 2026
46333c7
ci: check serve's startup policy in the catalog lock scripts
LeeroyHannigan Oct 30, 2026
9ee12f3
docs: serve --migrate, catalog_upgrade and the deployment recipes
LeeroyHannigan Oct 30, 2026
de4b0bf
fix(mongodb): release the migration lock with the lock's own client
LeeroyHannigan Oct 31, 2026
1642e83
fix(migrations): serve a catalog found current under the lock without…
LeeroyHannigan Oct 31, 2026
9df3f5d
fix(serve): check the catalog before the bind, log during it, bound i…
LeeroyHannigan Oct 31, 2026
b23da5e
test(migrations): rename the two-run startup tests, and add the missi…
LeeroyHannigan Oct 31, 2026
b0d5906
fix(npm): stop a server that fails to start, and report its exit code
LeeroyHannigan Oct 31, 2026
1b0386f
ci: bound the lock waits and clean up daemons in the catalog lock scr…
LeeroyHannigan Oct 31, 2026
7fff9d6
docs: describe the startup policy as the server applies it
LeeroyHannigan Oct 31, 2026
205017f
test(app): give the startup tests' applied steps an outcome
LeeroyHannigan Nov 2, 2026
3dde176
fix(devtools): the PostgreSQL lock check calls pid_file under another…
LeeroyHannigan Nov 2, 2026
c36d717
Merge branch 'main' into feat/catalog-startup-policy
LeeroyHannigan Nov 2, 2026
d0154a6
Merge origin/main into feat/catalog-startup-policy
LeeroyHannigan Nov 5, 2026
d8428eb
test(storage-mongodb): count migration 6 in the startup tests
LeeroyHannigan Nov 5, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ jobs:
- name: Build release (SQLite backend)
run: cargo build --release -p extenddb --no-default-features --features sqlite

# The migration lock between serve and migrate, on a catalog of its own
# (port 18450), before the suite's server starts.
# The migration lock between serve and migrate, on a catalog of its own,
# before the suite's server starts. The script serves on ports 18450 and
# 18451; the next step uses 18451 after this one has ended.
- name: Check serve and migrate on one catalog
run: devtools/check-sqlite-catalog-lock ./target/release/extenddb 18450

Expand Down Expand Up @@ -516,6 +517,7 @@ jobs:
cargo test --release -p extenddb-storage-postgres
--test migrations_fresh --test migrations_released --test migrations_crash
--test migrations_golden --test migrations_guards --test migrations_runs
--test migrations_startup

# The daemonized server logs to syslog; dump it so server-side failures
# are diagnosable from the job log.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/launcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ jobs:
set -euo pipefail
cd packaging/npm
node test/launcher.test.js
node test/start.test.js
1 change: 1 addition & 0 deletions .github/workflows/npm-build-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ jobs:
set -euo pipefail
cd packaging/npm
node test/launcher.test.js
node test/start.test.js

- name: Portability gate - the binary must run on the oldest libc we support
if: contains(matrix.target, 'musl')
Expand Down
22 changes: 15 additions & 7 deletions crates/app/src/cmd_migrate_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use extenddb_config as config;
use extenddb_storage::migrations::advice::{self, Advice, AdviceFor};
use extenddb_storage::migrations::{
AdminRole, CatalogMigrations, CatalogStatus, Listed, MigrationError, MigrationId, Pending,
RunKind, RunReport, Target, before_lock, behind_line, exit, forget_locked, run_locked,
stores_behind,
RunKind, RunReport, Target, WhenCurrent, before_lock, behind_line, exit, forget_locked,
run_with_lock, stores_behind,
};

use crate::cmd_migrate::MigrateArgs;
Expand Down Expand Up @@ -305,17 +305,25 @@ async fn apply(
kind: RunKind,
lock_timeout: Duration,
) -> Result<u8, MigrationError> {
for note in before_lock(migrations, target, kind).await? {
for note in before_lock(migrations, target, kind, "migrate --yes").await? {
println!("Note: {}", note.text);
}
println!(
"--- Taking the migration lock (waiting up to {} s for a server or another migration)...",
lock_timeout.as_secs()
);
let lock = migrations.lock(target, lock_timeout).await?;
let report = run_locked(migrations, target, &lock, release, kind).await;
migrations.release(lock).await;
let report = report?;
// On a current catalog the backend's run still goes ahead: it makes the
// steps the backend takes beside the ledger (PostgreSQL's pgvector
// attempt), which an operator who installs pgvector later relies on.
let report = run_with_lock(
migrations,
target,
release,
kind,
lock_timeout,
WhenCurrent::Run,
)
.await?;
print_report(&report, kind);
if report.applied.is_empty() && kind == RunKind::Migrate {
// A catalog ahead by additive migrations has nothing to apply and
Expand Down
1 change: 1 addition & 0 deletions crates/app/src/cmd_migrate_runner_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const ADVICE: Advice<'static> = Advice {
backup: "back up db",
config: Some("extenddb.toml"),
assignment: None,
serving: false,
};

fn behind() -> CatalogStatus {
Expand Down
72 changes: 59 additions & 13 deletions crates/app/src/cmd_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,24 @@ pub struct ServeArgs {
/// always writes one.
#[arg(long)]
write_pid_file: bool,

/// Apply the catalog's pending migrations before serving, for this run
/// only, as `[server] catalog_upgrade = "apply"` does.
///
/// The server takes the migration lock, waiting up to
/// `catalog_upgrade_lock_timeout` seconds (60 by default) for another
/// migration or a running server, and exits 6 if it is not free in time.
/// A catalog that needs a person, or that this binary cannot use, is
/// refused as without the flag.
#[arg(long)]
migrate: bool,
}

/// Bind the listening socket, daemonize, then start the tokio runtime.
/// Binding before forking ensures port conflicts are reported to stderr
/// before the parent process exits (D-4).
/// Check the catalog, bind the listening socket, daemonize, then start the
/// tokio runtime. The catalog check comes first, so the port accepts no
/// connection while the catalog migrates; binding before forking ensures
/// port conflicts are reported to stderr before the parent process exits
/// (D-4).
pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> {
// Load config early so bind address is known before fork. A dev-mode
// build serves with built-in defaults when no config file exists
Expand Down Expand Up @@ -94,12 +107,27 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> {
);
}

crate::serve_catalog::lock_timeout(&app_config.server)?;

// Fail fast, before binding the port, when no backend is installed.
let backend = &app_config.storage.backend;
extenddb_storage::backend_name()
.ok_or_else(|| anyhow::anyhow!("no storage backend installed"))?;
let catalog_version = extenddb_storage::migrations::binary_catalog_label();

// Daemon mode (double fork, PID file, syslog) is POSIX-only. On Windows
// the server must be run with `--foreground` under an external
// supervisor (a terminal, a service wrapper, or the npm launcher, which
// always passes `--foreground`). Refused before the catalog is read, so
// a server that cannot start migrates nothing.
#[cfg(not(unix))]
if !args.foreground {
anyhow::bail!(
"daemon mode is not supported on this platform; \
run `extenddb serve --foreground`"
);
}

let port = args.port.unwrap_or(app_config.server.port);

// Dev mode serves plain HTTP with relaxed authorization; confine it to
Expand Down Expand Up @@ -141,6 +169,23 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> {

let bind_addr = format!("{}:{}", app_config.server.bind_addr, port);

// The catalog's status, and under `apply` its migrations: before the
// port is bound, so the port accepts no connection while the catalog
// migrates, and before the fork, so that in daemon mode a refusal's exit
// code and message reach the invoking shell. The server checks the
// catalog again when it registers.
let code = crate::serve_catalog::check_catalog(&crate::serve_catalog::ServeStart {
server: &app_config.server,
location: app_config.storage.connection_config(),
migrate: args.migrate,
release: build.version,
config_file: config_exists.then_some(args.config.as_str()),
daemonizes: !args.foreground,
})?;
if code != extenddb_storage::migrations::exit::OK {
std::process::exit(i32::from(code));
}

// Bind in sync context — errors go to stderr before daemonizing.
let std_listener = TcpListener::bind(&bind_addr)
.map_err(|e| anyhow::anyhow!("Failed to bind {bind_addr}: {e}"))?;
Expand Down Expand Up @@ -213,16 +258,7 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> {
// can be supervised by Docker, Kubernetes, systemd Type=simple, etc.
// Graceful shutdown on SIGINT/SIGTERM still works.
if !args.foreground {
// Daemon mode (double fork, PID file, syslog) is POSIX-only. On
// Windows the server must be run with `--foreground` under an
// external supervisor (a terminal, a service wrapper, or the npm
// launcher, which always passes `--foreground`).
#[cfg(not(unix))]
anyhow::bail!(
"daemon mode is not supported on this platform; \
run `extenddb serve --foreground`"
);

// Daemon mode is POSIX-only; other platforms were refused above.
#[cfg(unix)]
{
let pid_file = pid_file
Expand Down Expand Up @@ -450,6 +486,16 @@ mod tests {
assert!(args.foreground);
}

/// `--migrate` applies the catalog's pending migrations for this run;
/// without it the server follows `[server] catalog_upgrade`.
#[test]
fn migrate_flag_is_recognized_and_off_by_default() {
assert!(!parse(&["extenddb-serve"]).migrate);
let args = parse(&["extenddb-serve", "--migrate", "--foreground"]);
assert!(args.migrate);
assert!(args.foreground);
}

#[test]
fn no_daemon_alias_is_recognized() {
// The issue proposed either `--foreground` or `--no-daemon`; make
Expand Down
6 changes: 6 additions & 0 deletions crates/app/src/init_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ pub(crate) fn generate_config(
let tls_cert = extenddb_config::expand_tilde("~/.extenddb/tls/cert.pem");
let tls_key = extenddb_config::expand_tilde("~/.extenddb/tls/key.pem");
let run_dir = extenddb_config::expand_tilde("~/.extenddb/run");
// `refuse`, or `apply` in a dev build.
let catalog_upgrade = extenddb_config::ServerConfig::default()
.catalog_upgrade
.as_str();

// Compute docs_dir line before the template so it lands in the top-level
// TOML section (before any [section] header).
Expand Down Expand Up @@ -250,6 +254,8 @@ bind_addr = "{bind_addr}"
# region = "us-east-1" # AWS region for ARN generation
# run_dir = "{run_dir}" # Directory for PID file
# throttling_enabled = false # Enable provisioned throughput throttling
# catalog_upgrade = "{catalog_upgrade}" # "apply" migrates a catalog behind this binary at startup
# catalog_upgrade_lock_timeout = 60 # Seconds serve waits for the migration lock under "apply"

[server.tls]
# TLS is mandatory. The server refuses to start with enabled = false.
Expand Down
1 change: 1 addition & 0 deletions crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ mod cmd_verify;
mod init_helpers;
mod manage_http;
mod manage_types;
mod serve_catalog;
mod serve_helpers;
mod util;

Expand Down
Loading
Loading