Skip to content

feat: serve lib decoupling - #218

Merged
pdf-amzn merged 12 commits into
mainfrom
feat/serve-lib-decoupling
Jul 29, 2026
Merged

feat: serve lib decoupling#218
pdf-amzn merged 12 commits into
mainfrom
feat/serve-lib-decoupling

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What

Decouples the ExtendDB server into library crates so a backend can be added crate-only, with no edits to any core crate. Four commits:

  1. refactor(storage) replace inventory link-time registration with an explicit BackendRegistry installed once into a process-global OnceLock (set_registry). Backends now expose one register(&mut BackendRegistry) instead of six inventory::submit! blocks. Lookup free-function signatures are unchanged, so all call sites are untouched.
  2. refactor(config) extract AppConfig + loading into a new backend-agnostic extenddb-config crate (breaks the would-be server ↔ app cycle).
  3. refactor(server) promote the server orchestration to a public extenddb_server::serve(config, listener, port, run_dir, foreground, git_hash); move the generic background workers into extenddb-server.
  4. refactor(app) lift the full CLI + dispatch into a new extenddb-app crate exposing run(BuildInfo); crates/bin becomes the Postgres thin bin and the reference template for backend authors.

First-party and third-party backends now follow the identical serve(config, registry) path, no privileged wiring for Postgres. Out of scope: the open-once/shared-handle lifecycle contract (only exclusive-lock embedded engines need it; no-op for Postgres/SQLite).

Why

The server orchestration (component assembly, cache wiring, AppState, worker spawning, TLS, PID lifecycle) lived in the bin crate, and backend registration relied on inventory link-time collection. A third party with only their backend crate + the published extenddb-* libraries could not run a server without forking bin. This makes the server a library call so a backend is a crate plus a ~10-line main.

Closes #168

Testing done

  • cargo build --workspace, cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings (the CI gate): all clean.
  • cargo test --workspace: 585 passed, 0 failed (unit + doc).
  • Integration on a fresh Postgres deployment (mirrors integration.yml): comprehensive 326 passed / 0 failed; rust-integration transaction/idempotency/GSI/data-plane/management paths pass. The only integration failures are pre-existing capacity_throttling cases that are environment-sensitive locally and green in CI.
  • End-to-end against live Postgres: version, serve + /health, a SigV4-signed CreateTable/PutItem/GetItem/DeleteTable round-trip, and every CLI subcommand — init, migrate, settings, manage, catalog-check, verify, status, stop, destroy.
  • No regressions: a differential run of pre-refactor main through the identical local harness fails on exactly the same environment-sensitive tests, confirming the remaining local failures are not introduced by this change.
  • Note: cargo clippy -- -W clippy::pedantic reports warnings, but they are pre-existing project-wide style (missing # Errors docs, backticks, function length) present on main; this PR's moves don't add net-new pedantic findings.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean — CI gate cargo clippy --all-targets -- -D warnings passes (-W clippy::pedantic emits only pre-existing project-wide warnings, unchanged by this PR)
  • I have added or updated tests for new functionality (registry-backed lookups covered by existing suites; added a pid_file_path test in extenddb-config)
  • I have updated documentation if behavior changed — not yet: the required-backend change (below) needs a note in the config reference / sample docs; flagging as a follow-up
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: RFC #168 (serve/library decoupling + registry). Public CLI surface is unchanged; the new public library surface (serve, BackendRegistry) is governed by RFC #168.

Breaking changes

StorageConfig deserialization no longer defaults the backend to "postgres". The [storage] backend key is now required, a backend-agnostic config crate cannot bake in a default. Configs that set backend explicitly (all shipped/sample configs do) are unaffected; a config omitting it now gets a clear error instead of a silent Postgres default.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

…BackendRegistry

Backends were wired in by link-time collection (the inventory crate): each
backend submitted six registration statics that the linker only preserved if
the binary happened to reference the backend crate. That is an invisible,
compiles-fine failure mode -- a backend added as an unreferenced dependency
silently isn't there at runtime.

Introduce an explicit BackendRegistry in extenddb-storage that holds the six
per-backend factories (bootstrapper, storage-config deserializer, operations
engine, settings store, diagnostics store, server components), installed once
into a process-global OnceLock via set_registry(). The six lookup free
functions now resolve against the installed registry; their signatures are
unchanged, so every call site is untouched. A missing registry degrades to the
existing unknown-backend error rather than a panic.

Each backend exposes a single register(&mut BackendRegistry) instead of six
inventory::submit! blocks; extenddb-storage-postgres::register() is the
reference. main() builds the registry, registers the compiled-in backend, and
installs it before dispatch. The inventory dependency is dropped from both
crates.

Behavior-preserving: workspace unit tests green, clippy -D warnings + fmt clean.
Signed-off-by: Lee Hannigan <lhnng@amazon.com>
…xtenddb-config crate

serve() cannot be a library entrypoint while AppConfig lives in the bin crate.
Move the configuration surface (AppConfig and subsections, load(), redaction
helpers, expand_tilde, build_config_entries, PID-file path helpers) out of
crates/bin into a new extenddb-config crate that depends only on the
extenddb-storage trait surface -- never on a concrete backend. This gives both
extenddb-server (for the upcoming serve()) and the CLI a shared lower crate to
depend on without a server<->app cycle.

The crate is backend-agnostic: the two dead postgres-gated items
(StorageConfig Default and default_backend, which had no callers) are dropped,
and StorageConfig deserialization no longer defaults to "postgres" -- the
[storage] backend key is now required, matching the decision that core carries
no built-in default backend. pid_file_path{,_default} move here from
serve_helpers so the server crate can write the PID file without depending on
the bin.

bin now depends on extenddb-config; all config call sites are unchanged via a
"use extenddb_config as config" alias. The now-unused external config crate
dependency is dropped from bin.

Behavior-preserving: workspace unit tests green (incl. new config tests),
clippy -D warnings + fmt clean.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
…o extenddb-server

The server orchestration (component assembly, cache wiring, AppState
construction, worker spawning, TLS assembly, PID-file cleanup) lived in the bin
crate, so a third party holding only their backend crate plus the published
extenddb-* library crates could not run a server without forking bin.

Move that orchestration into extenddb-server as a public `serve(config,
listener, port, run_dir, foreground, git_hash)` entrypoint, and move the
generic background workers (log-level poll, throttling poll, metrics
prune/flush, login-attempt cleanup, capacity warning) into the server crate
alongside it. AppState/start_server/Router/caches already lived here, so this
completes the assembly into one library call.

Build provenance (git hash) is passed in by the caller rather than read via
env!(): the deployed binary knows its own provenance, and the library must not
depend on the bin's build.rs environment variables. log_to_syslog_raw moves
into the server crate with serve; the bin retains only the CLI concerns
(config permission check, arg parsing, banner, bind, daemonize, PID dir).

bin's cmd_serve::run now loads config, binds, daemonizes, then calls
extenddb_server::serve. The unused syslog-tracing dependency is dropped from
bin.

Behavior-preserving: workspace unit tests green, clippy -D warnings + fmt clean.
Signed-off-by: Lee Hannigan <lhnng@amazon.com>
…es thin bin

Completes the library decoupling. The full CLI (serve, init, destroy, verify,
migrate, status, stop, settings, manage, catalog-check) plus its dispatch,
subcommand modules, and helpers move out of crates/bin into a new,
backend-agnostic extenddb-app crate exposing `run(BuildInfo)`.

crates/bin is now the reference thin bin: it registers exactly one backend
(postgres), installs the registry, and calls extenddb_app::run. This is the
copy-paste template for a third-party backend author — swap the register call,
supply your own build provenance, ship an extenddb-<backend> image, and touch
no ExtendDB core crate. First-party and third-party backends now follow the
identical path; there is no privileged wiring for postgres.

Build provenance (git hash, build time) is passed in via BuildInfo rather than
read through env!(), since the app library cannot see the bin's build.rs
environment. cmd_serve::run takes git_hash and forwards it to
extenddb_server::serve. The bin's dependency set collapses to extenddb-app,
extenddb-storage, the backend crate, and anyhow.

Behavior-preserving: workspace unit tests green (incl. the serve arg-parsing
tests now in extenddb-app), clippy -D warnings + fmt clean.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
Keep crates/config/src/lib.rs under the 500-line file limit (552 -> 440) by
moving REDACTED_CONFIG_KEYS, redact_if_sensitive, and build_config_entries into
a new display module; build_config_entries is re-exported so the public path is
unchanged.
inventory was replaced by the explicit BackendRegistry and is no longer used by
crates/storage or crates/storage-postgres.
@LeeroyHannigan
LeeroyHannigan force-pushed the feat/serve-lib-decoupling branch from a6cacab to a738a7a Compare July 28, 2026 20:27

@pdf-amzn pdf-amzn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-motivated refactoring. The new crate topology makes sense and the inventory → explicit BackendRegistry migration eliminates a class of silent linker failures. Commit structure is excellent — each commit is independently reviewable and behavior-preserving.

One discussion point: serve() signature — git_hash: &str

The current signature is:

pub async fn serve(
    app_config: config::AppConfig,
    std_listener: TcpListener,
    port: u16,
    run_dir: String,
    foreground: bool,
    git_hash: &str,
) -> anyhow::Result<()>

Since this is a public library API, the git_hash lifetime is worth a quick discussion. All realistic callers pass env!("EXTENDDB_GIT_HASH") which is 'static, so &'static str would be the tighter contract. On the other hand, &str is strictly more permissive today and doesn't break anyone — it only becomes a problem if you later want to narrow to &'static str (breaking change) or if the function needs to store it beyond the call (would need String/'static).

Not blocking — happy to discuss offline to sort out what makes sense here

Minor non-blocking observations (no action needed now)

  • port is derivable from std_listener.local_addr() — passing both creates a consistency coupling
  • 6 positional params approaching ergonomic threshold; a ServeConfig struct would be more future-proof
  • BackendRegistry::register_* silently overwrites on duplicate key — a tracing::warn would help catch wiring bugs
  • REDACTED_CONFIG_KEYS comment says "keep in sync" — consider exporting from one place

Everything else looks solid. Nice work.

@pdf-amzn

Copy link
Copy Markdown
Collaborator

Non-blocking observations (9 items)

All of these are suggestions for future improvement — none should block merge.


1. port is redundant with std_listener

serve() takes both std_listener: TcpListener and port: u16, but the port is derivable from std_listener.local_addr().unwrap().port(). Passing both creates a logical coupling where the caller must ensure consistency. Consider extracting port from the listener internally.

2. 6 positional params → consider a config struct

Six positional parameters (one of which is a bool flag) is at the ergonomic threshold for a public library API. Before v1.0, consider:

pub struct ServeParams {
    pub app_config: AppConfig,
    pub listener: TcpListener,
    pub run_dir: String,
    pub foreground: bool,
    pub git_hash: &'static str,
}

pub async fn serve(params: ServeParams) -> anyhow::Result<()>

More future-proof (adding a field is non-breaking with #[non_exhaustive]) and self-documenting at call sites.

3. Workers lack a CancellationToken

Worker loops (loop { sleep(...).await; ... }) in workers.rs rely on tokio runtime drop for shutdown. This works today but makes graceful drain impossible (e.g., flushing final metrics before exit). A CancellationToken passed to each worker would allow ordered shutdown. Follow-up item.

4. env!("CARGO_PKG_VERSION") in app crate

The app crate uses env!("CARGO_PKG_VERSION") directly in print_version and the serve banner. This gives the app crate's version, which is fine while all workspace crates stay version-synchronized. But for full decoupling, consider threading the version through BuildInfo alongside git_hash and build_time.

5. postgres feature flag is vestigial

[features]
default = ["postgres"]
postgres = ["extenddb-storage-postgres"]

Since main.rs unconditionally calls extenddb_storage_postgres::register(), disabling the feature causes a compile error. Either remove the feature flag or gate the register call behind #[cfg(feature = "postgres")].

6. Silent overwrite on duplicate backend name

BackendRegistry::register_* methods use HashMap::insert which silently overwrites on duplicate key. If two backends accidentally register with the same name, the second wins silently. Consider:

if self.bootstrappers.contains_key(name) {
    tracing::warn!("Backend '{name}' already registered — overwriting");
}

Or even debug_assert!(!self.bootstrappers.contains_key(name)) for catching wiring bugs in dev.

7. Stale comment: # Database plugin registry

In Cargo.toml, the section comment # Database plugin registry now has no content below it (the inventory dep was the only thing there). Remove or repurpose.

8. Duplicate log_to_syslog_raw code

The syslog FFI helper appears in two forms: as a function in crates/server/src/serve.rs and as inline unsafe in the panic hook in crates/app/src/cmd_serve.rs. Since extenddb-server is a dep of extenddb-app, the app crate could import and reuse the server's helper, eliminating the duplication.

9. REDACTED_CONFIG_KEYS — export from one place

crates/config/src/display.rs defines REDACTED_CONFIG_KEYS with a comment: "keep in sync with REDACTED_KEYS in crates/server/src/console/pages/settings_pages.rs". Since extenddb-server depends on extenddb-config, the server could import the canonical list from the config crate, turning a manual-sync hazard into a compile-time guarantee.

register_* used HashMap::insert, so a second backend claiming an existing
name silently won and the effective backend depended on the order of
register calls in main. A tracing::warn would be invisible here because
registration runs before the subscriber is installed, so record the
collisions and fail set_registry instead — the error surfaces through
main's ? before any request is served.

RegistryAlreadySet is replaced by RegistryError, which carries either the
already-installed case or the list of colliding (slot, backend) pairs.

Adds unit tests for the duplicate and distinct-name paths.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
The console settings page carried its own copy of the redaction patterns
and display.rs carried a "keep in sync" comment, which is a manual-sync
hazard: a pattern added in one place silently leaks values in the other.

Export should_redact from extenddb-config and have the console import it.
The server crate already depends on config, so the two lists cannot drift
apart any more.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
main.rs calls extenddb_storage_postgres::register unconditionally, so
--no-default-features did not build, and a bin with the feature disabled
would register no backend at all — every command would then fail at
runtime with an unknown-backend error.

The thin bin exists to wire exactly one backend, so the dependency is not
optional. Making it non-optional removes configuration that cannot work
rather than adding a cfg gate around the only call that makes the binary
useful.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
serve() took six positional parameters, two of which encoded caller
concerns the library should not know:

- port duplicated std_listener, forcing the caller to keep them
  consistent. It is now read back from the bound listener, which also
  resolves an ephemeral (port 0) bind correctly.
- foreground described the deployment model. Replaced by LogTarget
  {Syslog, Stderr}, so the library only decides where logs go. The PID
  file is now written unconditionally: the value daemonize writes for the
  grandchild is the same as std::process::id() post-fork, so this is a
  consistent rewrite and no longer needs a foreground branch.
- git_hash: &str is now BuildInfo.git_hash: &'static str. Every caller
  already passes a compile-time env!, and declaring the real lifetime now
  means the value can later be stored past the call without a breaking
  signature change.

The remaining arguments move into a #[non_exhaustive] ServeParams built
via new() + with_log_target(), so later fields are non-breaking.

BuildInfo moves to the server crate (app re-exports it) and gains version,
read from the bin crate. The banner and console version string previously
used the library crate's CARGO_PKG_VERSION, which reports the wrong number
as soon as crate versions stop moving together.

Background workers now take a CancellationToken and stop at their next
tick, and serve awaits them (bounded at 5s) after the HTTP server stops.
Previously they were abandoned to runtime drop, so the metrics flush
worker could lose its final bucket; it now performs a full drain on the
way out. ServerRuntimeHooks::spawn_workers returns its JoinHandles so
backend workers join the same drain, and extenddb-storage exports
CancellationToken plus a sleep_or_shutdown helper so a backend crate needs
no tokio-util dependency of its own.

Also removes the duplicate raw-syslog writer: the app's panic hook now
calls the server crate's log_to_syslog_raw instead of its own inline
unsafe block, and drops the now-empty registry comment section left by the
inventory removal.

Verified: 674 unit tests and 408 Rust integration tests pass; live SIGTERM
drains all 13 workers (6 generic + 7 postgres) in 38ms.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
The cancellation behaviour was only verified by hand, so a regression to
loop { sleep } would not have failed anything.

Three tests against fake stores:
- worker_keeps_running_until_cancelled — guards the two below from passing
  vacuously for a worker that returns immediately and never does work.
- cancellation_stops_a_sleeping_worker — a worker mid-sleep on an hour-long
  interval returns within 2s of cancellation, which is only possible if it
  also selects on the token.
- cancellation_flushes_the_final_partial_bucket — the flush interval is 60s,
  so a persisted row proves the write came from the cancellation drain and
  not a periodic tick.

Both drain tests were mutation checked: restoring drain_age to
FLUSH_INTERVAL fails the final-flush test, and reverting the cleanup worker
to loop { sleep } fails the cancellation test.

Corrects a count in 2b9ab1b's message: the pre-existing unit-test total was
617, not 674; it is 620 with these three.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks Paul, this was a useful list. All nine are addressed. Pushed as fd43714, 3a78255, 0e2ddfb, 2b9ab1b, 0b388dd.

1, 2 (port redundancy, param count). serve() now takes a single #[non_exhaustive] ServeParams, built via ServeParams::new(..).with_log_target(..). #[non_exhaustive] blocks struct literals from other crates, so the constructor is required rather than optional. port is gone from the signature and read back from the bound listener, which also resolves an ephemeral (port 0) bind correctly.

git_hash lifetime. Tightened to &'static str as part of BuildInfo. Agreed on the reasoning: every caller already passes a compile-time env!, and narrowing later would be breaking for anyone who wrote a backend against the library.

3 (no CancellationToken). Every worker now takes a token and returns at its next tick. serve() cancels once the HTTP server stops accepting, then awaits the handles with a 5s bound. ServerRuntimeHooks::spawn_workers returns its JoinHandles so backend workers join the same drain instead of each backend inventing its own shutdown path. extenddb-storage re-exports CancellationToken and a sleep_or_shutdown helper so a third-party backend needs no tokio-util dependency and there is one implementation of the select! rather than one per worker.

Your point about flushing final metrics was correct: metrics_flush_worker was discarding its partial bucket on every shutdown. It now drains everything on the cancellation pass. insert_metrics upserts additively, so a partial bucket flushed at shutdown accumulates rather than overwriting after a restart.

Covered by three tests in 0b388dd, each mutation checked so they are not vacuous: reverting drain_age to FLUSH_INTERVAL fails the final-flush test, and reverting a worker to loop { sleep } fails the cancellation test. Live check: SIGTERM drained 13 workers (6 generic, 7 Postgres) in 38ms.

4 (CARGO_PKG_VERSION in the app crate). BuildInfo gains version, read from the bin crate, and the banner plus console version string use it. Worth noting serve() had the same bug: it reported the server crate's version. One caveat, BuildInfo lives in the server crate so extenddb-app can re-export it without a new crate. Build provenance is arguably not a server concept, so if you would rather it sat somewhere neutral, say so and I will move it.

5 (vestigial postgres feature). Removed rather than cfg-gated. With the feature off the bin would register no backend, so every command would fail at runtime with an unknown-backend error. The thin bin exists to wire exactly one backend, so the dependency is not optional.

6 (silent overwrite on duplicate name). Registration runs before the tracing subscriber exists, so a tracing::warn would be invisible. The registry records collisions and set_registry fails with RegistryError::DuplicateRegistrations, surfacing through main's ? before any request is served. RegistryAlreadySet folds into that enum, which is a public rename, safe only because the registry is new in this PR. Unit tested both ways.

7 (stale Cargo.toml comment). Removed. That was left behind by my inventory removal.

8 (duplicate log_to_syslog_raw). The app's panic hook now calls the server crate's copy. log_to_syslog_raw is pub and documented for that use, since tracing is unusable in the hook.

9 (REDACTED_CONFIG_KEYS single source). should_redact is exported from extenddb-config and imported by the console; the console's copy is deleted along with the "keep in sync" comment. This one fails in the unsafe direction if it drifts, so making it a compile-time guarantee was the right call.

Two things I have not resolved. The 5s drain bound stacks on the existing graceful window, so a worker wedged in a slow backend call could extend shutdown; the happy path is 38ms but I have not measured the pathological case against a 10s container grace period. Happy to lower the bound if you think that matters.

Verification: 620 unit tests and 408 Rust integration tests pass, clippy clean with no suppressions, and each of the five commits compiles standalone.

@pdf-amzn

Copy link
Copy Markdown
Collaborator

Design question: registry vs. single-backend setter

The BackendRegistry + HashMap approach supports multiple backends registered in one binary, with config-driven dispatch via [storage] backend = "postgres". But looking at the architecture — one [storage] section, one backend per deployment, no multi-tenant routing — this capability isn't used today, and the SQLite dev mode (#182) is a separate-binary use case.

Would a simpler single-backend design work equally well?

// In extenddb-storage:
pub struct Backend {
    pub bootstrapper: BootstrapperFactory,
    pub storage_config: StorageConfigDeserializer,
    pub operations: &'static dyn OperationsEngine,
    pub settings_store: SettingsStoreFactory,
    pub diagnostics_store: DiagnosticsStoreFactory,
    pub server_components: ServerComponentsFactory,
}

pub fn set_backend(backend: Backend) -> Result<(), BackendAlreadySet> { ... }

Thin bin becomes:

fn main() {
    extenddb_storage::set_backend(extenddb_storage_postgres::backend());
    extenddb_app::run(...)
}

What this would give you:

  • Compile-time guarantee: no "unknown backend" errors possible
  • No string-keyed dispatch — the backend = "postgres" config key becomes redundant (there's only one compiled in)
  • Trivial public API: one struct, one function
  • Same library decoupling goal achieved (backend plugged in by thin bin, not hard-wired)

What you'd lose:

  • Multi-backend-in-one-binary (e.g. a hypothetical extenddb-multi registering both postgres + sqlite, config selects at runtime)
  • The config file driving backend selection — each binary is its backend

Not blocking — the registry as implemented is clean and the extra complexity is bounded (~100 lines). This is more of a "is the HashMap dispatch earning its keep?" question. If there's a concrete plan for multi-backend binaries or config-time selection, the registry makes sense. If every deployment is always one-backend-per-binary anyway, set_backend is simpler and eliminates a class of runtime errors entirely.

Curious whether RFC #168 considered this alternative or specifically chose multi-backend support for a reason I'm not seeing.

…name-keyed registry

A binary is built for exactly one backend, so the registry's string-keyed
dispatch was capability the project does not use. It also allowed a class of
runtime error that cannot exist without it: a mistyped or absent backend name
produced an "unknown backend" failure after startup rather than a compile-time
guarantee.

extenddb_storage::Backend collects the six factories a backend provides, and
set_backend installs it once from the thin bin. The six lookup functions drop
their backend-name parameter and read the installed backend directly, so the
name no longer flows through 13 call sites. BackendRegistry, RegistryError and
the duplicate-registration detection are gone: with one backend there is no
collision to detect.

Backend carries its own name, which keeps the config file format unchanged.
Configuration asks the installed backend for its [storage.<name>] section
rather than taking that name from the file, so:

  * the [storage] backend key is now optional again, restoring the original
    "omit it and get the compiled-in backend" behaviour that this branch had
    regressed into a hard error;
  * when the key is present it is validated against the compiled-in backend and
    a mismatch fails at startup naming the correct binary, instead of being
    silently accepted and failing later.

The BackendNotFound/UnknownBackend error variants are renamed
BackendNotInstalled, which is the only remaining failure mode.

Verified: 620 unit tests, 408 Rust integration tests, fmt and clippy clean.
Live checks: a config with no backend key starts, a config naming a different
backend is rejected with an actionable message, and extenddb version reports
the single compiled-in backend.

Signed-off-by: Lee Hannigan <lhnng@amazon.com>
@pdf-amzn
pdf-amzn added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 27a5bea Jul 29, 2026
12 checks passed
diegotoledano95 added a commit to diegotoledano95/extenddb that referenced this pull request Jul 30, 2026
…ndDB#218 main

 Rebase onto upstream main after PR ExtendDB#218 (serve lib decoupling), which
 replaced inventory backend registration with an explicit set_backend/Backend
 model and split the CLI into extenddb-app. Also adapts to backup-trait and
 worker changes and to new backup_arn_scoping conformance tests pulled in by
 the rebase.

 - Replace the six inventory::submit! blocks with a single
  extenddb_storage_mongodb::backend() constructor plus a
  server_components_factory fn, mirroring the postgres backend.
 - Drop the now-removed inventory dependency.
 - Feature-gate the thin bin: install the mongodb backend under
  --features mongodb, else postgres.
 - Scope describe_backup and delete_backup to account_id (added to the
  BackupEngine trait upstream); exclude DELETED backups from describe_backup
  so a deleted backup reads as BackupNotFoundException.
 - Give backup ARNs a timestamp-plus-8-hex-char random id so they are not
  guessable from creation time alone.
 - Return the spawned worker JoinHandles from spawn_workers, whose trait
  signature now requires Vec<JoinHandle<()>>.
robinnsc added a commit that referenced this pull request Jul 30, 2026
…cert SANs

Make the binary supervisable and probeable from a container runtime. First of
two changes for container readiness; the migration concurrency guard follows
separately.

- healthcheck: new subcommand that probes /health over HTTPS and exits 0 or 1,
  so a Docker HEALTHCHECK needs no shell or curl and works on distroless. It
  reports liveness, which is what a HEALTHCHECK and a Kubernetes livenessProbe
  want: /health is a static handler that does not query the backend, and a
  liveness probe that failed on a database outage would restart every replica
  at once. There is no readiness endpoint yet; adding one backed by a cached
  storage-layer round-trip is the follow-up. Connect, read, and write are
  bounded at 3s (TcpStream::connect has no timeout of its own), every resolved
  address is tried so a name resolving to both ::1 and 127.0.0.1 works, and
  --endpoint accepts an optional scheme, port, path, and IPv6 literal. The
  flagless probe derives its host from the configured bind_addr rather than
  assuming 127.0.0.1, so an IPv6-bound server is not reported unhealthy, and
  --port mirrors serve's own override. Read and write timeout failures are
  propagated, since a bounded probe is the whole point of the command.
- serve --foreground: write no PID file and skip the run directory by default,
  so the container can use a read-only root filesystem. Daemon mode is
  unchanged. With no PID file to read, `stop` now probes the port and reports
  that a server is listening under foreign supervision rather than claiming
  nothing is running; `status` already degrades to an unknown PID.
- serve --write-pid-file: opt back into the PID file in foreground mode, for
  shell use and tooling that wants `stop` and `status` to work. It goes to the
  same run_dir path daemon mode uses, so neither command needs extra arguments,
  and run_dir then has to be writable. Ignored in daemon mode, which always
  writes one. devtools/run-tests restarts the server with `stop` to apply a
  config change, so the integration workflow passes this flag; without it that
  restart silently did nothing: `stop` failed, `serve` could not bind, and the
  health check passed against the process that was never replaced. That path
  no longer suppresses errors either, so a failed restart fails the run instead
  of reporting success.
- init --tls-san <name> (repeatable): append Subject Alternative Names to the
  generated self-signed certificate so it is valid for the name clients use,
  such as an in-cluster service DNS name, not just
  localhost/127.0.0.1/bind-addr. Values are trimmed and de-duplicated
  case-insensitively. init never regenerates an existing certificate, so a
  later --tls-san cannot take effect; rather than exit 0 having dropped the
  name and leave clients to hit a TLS hostname verification failure, it
  verifies the existing certificate covers every requested SAN and fails with
  an actionable error otherwise. Certificate generation moved ahead of all
  database work so a bad SAN fails before any state is created. The coverage
  check uses rustls's own `verify_server_name`, so it adds no new dependency. A
  wildcard such as *.svc.cluster.local is a valid certificate entry but not a
  valid server name, so coverage is tested by substituting a single label;
  without that, a wildcard accepted on the first run failed on every later one,
  which is a crash loop for the idempotent entrypoint. Every requested name is
  validated before generation, so a malformed wildcard fails on the first run
  rather than the next.
- docs: correct the architecture and deployment guides, which claimed the
  server always daemonizes and that foreground mode still writes a PID file,
  and replace the container recipe that waited on that PID file with a
  foreground entrypoint plus a HEALTHCHECK.
- tests: add tests/test_cli_container_readiness.py covering SAN generation,
  dedup/blank handling, the not-covered failure and the already-covered
  idempotent case, healthcheck up/down/--endpoint plus prompt failure against
  an unreachable host, and foreground leaving no PID file or run directory
  while still exiting on SIGTERM. Runs under an isolated $HOME so the suite no
  longer overwrites the developer's real ~/.extenddb certificate.
- devtools/run-tests: exclude the new file from the main pytest suite and run
  it in the CLI section instead, alongside test_cli_lifecycle.py. Like those
  tests it starts and stops its own servers and creates its own databases, so
  it cannot run in parallel against the shared instance the main suite uses.

Rebased onto the post-#218 layout: the CLI now lives in crates/app, so
cmd_healthcheck joins it there. ServeParams gains pid_file: Option<PathBuf> in
place of run_dir, which it only ever used to derive that path, so serve() no
longer writes a PID file unconditionally and needs no notion of a run
directory.
diegotoledano95 added a commit to mongodb-forks/extenddb that referenced this pull request Jul 30, 2026
…ndDB#218 main

 Rebase onto upstream main after PR ExtendDB#218 (serve lib decoupling), which
 replaced inventory backend registration with an explicit set_backend/Backend
 model and split the CLI into extenddb-app. Also adapts to backup-trait and
 worker changes and to new backup_arn_scoping conformance tests pulled in by
 the rebase.

 - Replace the six inventory::submit! blocks with a single
  extenddb_storage_mongodb::backend() constructor plus a
  server_components_factory fn, mirroring the postgres backend.
 - Drop the now-removed inventory dependency.
 - Feature-gate the thin bin: install the mongodb backend under
  --features mongodb, else postgres.
 - Scope describe_backup and delete_backup to account_id (added to the
  BackupEngine trait upstream); exclude DELETED backups from describe_backup
  so a deleted backup reads as BackupNotFoundException.
 - Give backup ARNs a timestamp-plus-8-hex-char random id so they are not
  guessable from creation time alone.
 - Return the spawned worker JoinHandles from spawn_workers, whose trait
  signature now requires Vec<JoinHandle<()>>.
robinnsc added a commit that referenced this pull request Aug 4, 2026
…isory lock

Two replicas running `extenddb migrate` at once race each other: both evaluate
which migrations are pending before either records anything, both apply, and
one fails with a duplicate pg_type_typname_nsp_index key when the concurrent
CREATE TABLE IF NOT EXISTS statements collide in PostgreSQL's system catalog.
That makes an idempotent container entrypoint, which runs migrate on every
start of every replica, unsafe.

Take a namespaced session-level advisory lock around the migration step so
migrators serialize. The second blocks, then finds the schema already applied
and no-ops. The lock is held on a dedicated connection to the catalog database
for the duration and released on every path, including the "nothing to do"
early return and error returns; if the process dies, PostgreSQL releases it
when the connection closes.

- A migrator that must wait tries the lock first and prints why it is waiting,
  instead of sitting silent for as long as the other migration takes.
- Acquiring is not re-entrant: a second acquire would open a second connection
  and block on the lock the first holds, deadlocking against itself, so it is
  rejected. A failed unlock is reported rather than swallowed, though closing
  the connection releases the lock anyway so it is never fatal.
- Advisory locks are scoped to a database, so migrators serialize only if they
  share a catalog database — they do, since it comes from the same connection
  string.
- After acquiring, verify against pg_locks that this session holds the lock,
  and fail hard if it does not. A transaction-pooling proxy (pgbouncer in
  transaction mode, RDS Proxy) puts the lock on an arbitrary pooled backend,
  which otherwise leaves migrators unserialized while looking safe. One
  statement maps to one backend even through such a proxy, so the check detects
  it. Direct RDS and Aurora connections pass.
- init takes the same lock around its own schema work, so it cannot race a
  migrate running on another replica. Two concurrent inits cannot reach the
  migrations at all: the second aborts earlier at create_catalog_db because the
  database already exists, so this guards the narrower init-versus-migrate
  overlap.
- The Bootstrapper trait gains acquire_migration_lock and
  release_migration_lock with default no-ops, so out-of-tree backends compile
  unchanged. A new MinimalBootstrapper test implements only the required
  methods, so it stops compiling if a defaulted method loses its default, and
  pins object safety.
- tests: add tests/test_cli_migrate_concurrency.py asserting that two
  concurrent `migrate --yes` runs both succeed with exactly one applying the
  migration and the other observing it as done, and that a migrate blocked on a
  lock held from an external session waits rather than proceeding, then
  completes and reports that it waited. Both tests fail when the lock is
  disabled.
- devtools/run-tests: exclude the new file from the main pytest suite and run
  it in the CLI section instead, alongside test_cli_lifecycle.py. Like those
  tests it starts and stops its own servers and creates its own databases, so
  it cannot run in parallel against the shared instance the main suite uses.

Rebased onto the post-#218 layout: cmd_migrate and cmd_init now live in
crates/app. The guard is unaffected by that move and by the registry removal;
sqlx::migrate (#221) is not yet adopted, so the custom migration runner this
serializes is still in place.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants