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
13 changes: 2 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

204 changes: 94 additions & 110 deletions crates/hfs/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,32 @@ use helios_audit::{
};
use helios_auth::{AuthConfig, InMemoryJtiCache, JtiCache, JwksBearerAuthProvider, JwksCache};
use helios_persistence::{BackendKind, ResourceStorage, TenantContext};
use helios_rest::{AuthMiddlewareState, ServerConfig, StorageBackendMode, create_app_with_auth};
use helios_rest::{AuthMiddlewareState, ServerConfig, StorageBackendMode};
use tracing::info;

use helios_persistence::backends::local_fs::LocalFsOutputStore;
#[cfg(any(feature = "sqlite", feature = "postgres"))]
use helios_persistence::core::SettingsStore;
use helios_persistence::core::{
BulkExportJobStore, BulkSubmitJobStore, DefaultExportWorker, DefaultSubmitWorker,
ExportOutputStore, SubmitInputFetcher, WorkerId,
};
#[cfg(any(feature = "sqlite", feature = "postgres"))]
use helios_rest::bulk_export_auth::BearerScopeAuth;
#[cfg(any(feature = "sqlite", feature = "postgres"))]
// The S3+Elasticsearch composite is the only remaining caller of the plain
// bulk builder: S3 has no settings store, and its bulk-export job state rides
// on an embedded SQLite sidecar.
#[cfg(all(feature = "s3", feature = "elasticsearch", feature = "sqlite"))]
use helios_rest::create_app_with_auth_and_bulk;
// Settings-capable standalone/composite backends (SQLite, PostgreSQL, MongoDB)
// host the per-user settings store, wired alongside bulk export/submit.
#[cfg(any(feature = "sqlite", feature = "postgres"))]
use helios_rest::create_app_with_auth_bulk_and_settings;
// S3 does not host a settings store (tracked follow-up #199), so its startup
// paths use the plain app builder. Every other standalone/composite backend now
// wires the per-user settings store via `create_app_with_auth_bulk_and_settings`.
#[cfg(feature = "s3")]
use helios_rest::create_app_with_auth;

#[cfg(feature = "sqlite")]
use helios_persistence::backends::sqlite::{SqliteBackend, SqliteBackendConfig};
Expand Down Expand Up @@ -502,32 +516,33 @@ async fn start_mongodb(
let backend = Arc::new(backend);
let serve_audit_state = audit_state.clone();

// MongoDB primary; embedded SQLite sidecar for job state.
#[cfg(feature = "sqlite")]
{
let jobs = build_embedded_job_store(&config)?;
Comment thread
smunini marked this conversation as resolved.
if let Some(bundle) = build_bulk_export(&config, backend.clone(), jobs).await? {
let app = create_app_with_auth_and_bulk(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
Some(bundle),
None,
);
return serve(app, &config, serve_audit_state).await;
// MongoDB is a full standalone primary, so it also hosts the per-user
// settings store: it keeps ownership of the backend Arc and wires the
// settings-capable builder (like the SQLite/Postgres backends).
let settings_store: Option<Arc<dyn SettingsStore>> = Some(backend.clone());

// MongoDB primary; embedded SQLite sidecar for bulk-export job state.
let export_bundle = {
#[cfg(feature = "sqlite")]
{
let jobs = build_embedded_job_store(&config)?;
build_bulk_export(&config, backend.clone(), jobs).await?
}
}
#[cfg(not(feature = "sqlite"))]
{
None
}
};

let app = create_app_with_auth(
Arc::try_unwrap(backend).unwrap_or_else(|_| {
unreachable!("backend Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
None,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down Expand Up @@ -863,29 +878,20 @@ async fn start_sqlite(
let serve_audit_state = audit_state.clone();
let backend = Arc::new(create_sqlite_backend(&config)?);

// The SQLite backend also hosts the per-user settings store, so it always
// keeps ownership of the backend Arc and uses the settings-capable builder.
let settings_store: Option<Arc<dyn SettingsStore>> = Some(backend.clone());
let export_bundle = build_bulk_export(&config, backend.clone(), backend.clone()).await?;
let submit_bundle = build_bulk_submit(&config, backend.clone()).await?;
if export_bundle.is_some() || submit_bundle.is_some() {
let app = create_app_with_auth_and_bulk(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
);
return serve(app, &config, serve_audit_state).await;
}

let app = create_app_with_auth(
Arc::try_unwrap(backend).unwrap_or_else(|_| {
unreachable!("backend Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down Expand Up @@ -1416,29 +1422,22 @@ async fn start_sqlite_elasticsearch(
let serve_audit_state = audit_state.clone();
let composite = Arc::new(composite);

// The per-user settings store lives on the SQLite primary (Elasticsearch is
// search-only), so it is wired from the underlying `sqlite` backend even
// though the app is served over the composite storage.
let settings_store: Option<Arc<dyn SettingsStore>> = Some(sqlite.clone());

let export_bundle = build_bulk_export(&config, sqlite.clone(), sqlite.clone()).await?;
let submit_bundle = build_bulk_submit(&config, sqlite.clone()).await?;
if export_bundle.is_some() || submit_bundle.is_some() {
let app = create_app_with_auth_and_bulk(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
);
return serve(app, &config, serve_audit_state).await;
}

let app = create_app_with_auth(
Arc::try_unwrap(composite).unwrap_or_else(|_| {
unreachable!("composite Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down Expand Up @@ -1484,29 +1483,20 @@ async fn start_postgres(
let backend = Arc::new(backend);

let serve_audit_state = audit_state.clone();
// The PostgreSQL backend also hosts the per-user settings store, so it always
Comment thread
smunini marked this conversation as resolved.
// keeps ownership of the backend Arc and uses the settings-capable builder.
let settings_store: Option<Arc<dyn SettingsStore>> = Some(backend.clone());
let export_bundle = build_bulk_export(&config, backend.clone(), backend.clone()).await?;
let submit_bundle = build_bulk_submit(&config, backend.clone()).await?;
if export_bundle.is_some() || submit_bundle.is_some() {
let app = create_app_with_auth_and_bulk(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
);
return serve(app, &config, serve_audit_state).await;
}

let app = create_app_with_auth(
Arc::try_unwrap(backend).unwrap_or_else(|_| {
unreachable!("backend Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
backend,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down Expand Up @@ -1650,29 +1640,22 @@ async fn start_postgres_elasticsearch(
let serve_audit_state = audit_state.clone();
let composite = Arc::new(composite);

// The per-user settings store lives on the PostgreSQL primary (Elasticsearch
// is search-only), so it is wired from the underlying `pg` backend even
// though the app is served over the composite storage.
let settings_store: Option<Arc<dyn SettingsStore>> = Some(pg.clone());

let export_bundle = build_bulk_export(&config, pg.clone(), pg.clone()).await?;
let submit_bundle = build_bulk_submit(&config, pg.clone()).await?;
if export_bundle.is_some() || submit_bundle.is_some() {
let app = create_app_with_auth_and_bulk(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
);
return serve(app, &config, serve_audit_state).await;
}

let app = create_app_with_auth(
Arc::try_unwrap(composite).unwrap_or_else(|_| {
unreachable!("composite Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
submit_bundle,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down Expand Up @@ -1808,32 +1791,33 @@ async fn start_mongodb_elasticsearch(
let serve_audit_state = audit_state.clone();
let composite = Arc::new(composite);

// MongoDB primary; embedded SQLite sidecar for job state.
#[cfg(feature = "sqlite")]
{
let jobs = build_embedded_job_store(&config)?;
if let Some(bundle) = build_bulk_export(&config, mongo.clone(), jobs).await? {
let app = create_app_with_auth_and_bulk(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
Some(bundle),
None,
);
return serve(app, &config, serve_audit_state).await;
// The per-user settings store lives on the MongoDB primary (Elasticsearch is
// search-only), so it is wired from the underlying `mongo` backend even
// though the app is served over the composite storage.
let settings_store: Option<Arc<dyn SettingsStore>> = Some(mongo.clone());

// MongoDB primary; embedded SQLite sidecar for bulk-export job state.
let export_bundle = {
#[cfg(feature = "sqlite")]
{
let jobs = build_embedded_job_store(&config)?;
build_bulk_export(&config, mongo.clone(), jobs).await?
}
}
#[cfg(not(feature = "sqlite"))]
{
None
}
};

let app = create_app_with_auth(
Arc::try_unwrap(composite).unwrap_or_else(|_| {
unreachable!("composite Arc is uniquely owned when bulk export is disabled")
}),
let app = create_app_with_auth_bulk_and_settings(
composite,
config.clone(),
auth_config,
auth_state,
audit_state,
export_bundle,
None,
settings_store,
);
serve(app, &config, serve_audit_state).await
}
Expand Down
13 changes: 10 additions & 3 deletions crates/hts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ It can also be used standalone as a general-purpose FHIR terminology service, in

An open test server will soon be available at https://hts.heliossoftware.com/ for experimentation and evaluation.

HTS currently uses SQLite as its database backend. PostgreSQL support is planned for a future release - see [Storage Backends](#storage-backends) for details.
HTS supports both SQLite (the zero-config default) and PostgreSQL database backends, with full feature parity between them - see [Storage Backends](#storage-backends) for details.

### Terminology Data

Expand Down Expand Up @@ -422,10 +422,17 @@ On top of the SQL layer, the SQLite backend keeps small bounded in-memory caches

### PostgreSQL

PostgreSQL backend support is planned for a future release. The schema, query patterns, and persistence trait surface have been designed with multi-backend portability in mind, and the integration is being staged behind feature work tracked separately. Until it lands, all production deployments should use the SQLite backend documented above.
HTS fully supports PostgreSQL as an alternative backend, with feature parity with SQLite across all terminology operations, CRUD, search, and import formats. The schema mirrors the SQLite layout and is applied automatically on first connection, so no manual migration step is required.

Build with the `postgres` feature, then select the backend with `--storage-backend postgres` (or `HTS_STORAGE_BACKEND=postgres`) and a `postgresql://` connection string:

```bash
# Coming soon
# Build with PostgreSQL support
cargo build --release -p helios-hts --features postgres

# Run against PostgreSQL
hts run --storage-backend postgres \
--database-url "postgresql://user:pass@localhost/hts"
```

## API Endpoints
Expand Down
2 changes: 1 addition & 1 deletion crates/persistence/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ tokio = { version = "1", features = ["full", "test-util"] }
tokio-test = "0.4"
tempfile = "3"
criterion = { version = "0.5", features = ["async_tokio"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "elastic_search", "minio"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "elastic_search", "minio", "mongo"] }
paste = "1.0"

# `watchdog` pulls in `signal_hook::iterator`/`SIGQUIT`, which only compile on Unix.
Expand Down
1 change: 1 addition & 0 deletions crates/persistence/src/backends/mongodb/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ mod bulk_export;
pub(crate) mod schema;
mod search_impl;
mod storage;
mod user_settings;

pub use backend::{MongoBackend, MongoBackendConfig};
Loading