From e6e2bb65dc6e3eec69d3f1e18dd1eb9ba4731b2e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mauricio=20Pe=C3=B1aranda?=
Date: Mon, 14 Sep 2026 09:41:11 -0400
Subject: [PATCH 1/5] fix(elasticsearch): raise the nested-object limit so
large resources stay searchable (#1050)
Elasticsearch rejects a whole document once it holds more nested objects
than `index.mapping.nested_objects.limit`, summed across every nested
search-parameter field. Its default of 10000 is below real data: 458 of
the 11,704 Synthea Provenance resources carry more than 10000 `target`
references (the largest, 28,192), so they were stored in the primary but
never searchable, and `$reindex` failed on them the same way.
Add `ElasticsearchConfig::nested_objects_limit` (default 50000) and
`HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT`. The limit is written into the
index template for new indices, and `Backend::initialize` raises it on
existing `{prefix}_*` indices that are below it. The setting is dynamic,
so resources that already indexed need no reindex. The startup pass only
ever raises, sends index names in chunks, and is non-fatal, so a missing
`manage` privilege cannot stop the server.
Tests: the mapping carries the setting; the startup parser picks exactly
the indices below the target; config and serde defaults; and, against a
real Elasticsearch, a 12,000-target Provenance indexes through the
`$reindex` page writer, while an index created at 10000 rejects it until
a backend started with the raised limit raises that existing index.
---
.claude/skills/run-hfs-server/SKILL.md | 1 +
README.md | 1 +
.../configuration/environment-variables.md | 1 +
crates/hfs/README.md | 1 +
crates/hfs/src/main.rs | 4 +
crates/persistence/README.md | 17 ++
.../src/backends/elasticsearch/backend.rs | 41 ++++-
.../src/backends/elasticsearch/schema.rs | 162 +++++++++++++++++-
.../persistence/tests/elasticsearch_tests.rs | 149 ++++++++++++++++
crates/rest/src/config.rs | 19 ++
10 files changed, 394 insertions(+), 2 deletions(-)
diff --git a/.claude/skills/run-hfs-server/SKILL.md b/.claude/skills/run-hfs-server/SKILL.md
index ce97cca3c0..f8078c1c14 100644
--- a/.claude/skills/run-hfs-server/SKILL.md
+++ b/.claude/skills/run-hfs-server/SKILL.md
@@ -75,6 +75,7 @@ HFS_SERVER_PORT=3000 HFS_LOG_LEVEL=debug cargo run --bin hfs
| `HFS_ELASTICSEARCH_PASSWORD` | none | Elasticsearch basic auth password |
| `HFS_ELASTICSEARCH_REFRESH_INTERVAL` | `1s` | Index `refresh_interval` applied when an index is created (`-1` disables periodic refresh) |
| `HFS_ELASTICSEARCH_WRITE_REFRESH` | `false` | `refresh` parameter on index/delete writes: `false`, `wait_for`, or `true` |
+| `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` | `50000` | Index `mapping.nested_objects.limit`: max nested objects per document across all nested search-parameter fields. Set on new indices; raised at startup on existing indices below it |
| `HFS_COMPOSITE_SYNC_MODE` | `asynchronous` | ES-backed composite write sync mode: asynchronous, synchronous, or hybrid |
Use `HFS_COMPOSITE_SYNC_MODE=synchronous` **and** `HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for` when callers need read-your-write search semantics, such as integration tests or bulk loads that immediately search. Either alone still leaves a window: synchronous mode only guarantees the document reached Elasticsearch, and it is not searchable until the next index refresh. See `crates/persistence/README.md` (Search visibility on Elasticsearch-backed composites).
diff --git a/README.md b/README.md
index 497ff812b6..0d3d0e9d52 100644
--- a/README.md
+++ b/README.md
@@ -301,6 +301,7 @@ compressed when the client sends `Accept-Encoding`.
| `HFS_ELASTICSEARCH_PASSWORD` | *(none)* | ES basic auth password |
| `HFS_ELASTICSEARCH_REFRESH_INTERVAL` | `1s` | Index `refresh_interval` applied when an index is created (`-1` disables periodic refresh) |
| `HFS_ELASTICSEARCH_WRITE_REFRESH` | `false` | `refresh` parameter on index/delete writes: `false`, `wait_for`, or `true` |
+| `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` | `50000` | Index `mapping.nested_objects.limit`: max nested objects per document across all nested search-parameter fields. Set on new indices; raised at startup on existing indices below it |
**PostgreSQL** (used to assemble a connection when `HFS_DATABASE_URL` is not set)
diff --git a/book/src/configuration/environment-variables.md b/book/src/configuration/environment-variables.md
index 3c11f5d3b7..6c2c70bfeb 100644
--- a/book/src/configuration/environment-variables.md
+++ b/book/src/configuration/environment-variables.md
@@ -57,6 +57,7 @@ are compressed when the client sends `Accept-Encoding`.
| `HFS_ELASTICSEARCH_PASSWORD` | *(none)* | Elasticsearch basic auth password |
| `HFS_ELASTICSEARCH_REFRESH_INTERVAL` | `1s` | Index `refresh_interval` applied when an index is created (`-1` disables periodic refresh) |
| `HFS_ELASTICSEARCH_WRITE_REFRESH` | `false` | `refresh` parameter on index/delete writes: `false`, `wait_for`, or `true` |
+| `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` | `50000` | Index `mapping.nested_objects.limit`: max nested objects per document across all nested search-parameter fields. Set on new indices; raised at startup on existing indices below it |
| `HFS_S3_BUCKET` | `hfs` | S3 bucket name |
| `HFS_S3_REGION` | *(AWS chain)* | AWS region override |
| `HFS_S3_PREFIX` | *(none)* | Optional key prefix for all S3 object keys |
diff --git a/crates/hfs/README.md b/crates/hfs/README.md
index fc468bc0a3..5a30a99b74 100644
--- a/crates/hfs/README.md
+++ b/crates/hfs/README.md
@@ -99,6 +99,7 @@ Options:
| `HFS_COMPOSITE_SYNC_MODE` | `asynchronous` | Composite-store write sync mode for ES-backed backends (`sqlite-elasticsearch`, `postgres-elasticsearch`, `mongodb-elasticsearch`, `s3-elasticsearch`). One of `asynchronous`, `synchronous`, `hybrid`. With `asynchronous` (default) the write returns as soon as the primary commits and the search backend is updated on a background worker — lowest latency, but a follow-up search can race the indexing. Use `synchronous` when callers need read-your-write semantics (e.g. integration tests, bulk-load flows that immediately search). Ignored when the storage backend has no search secondary. |
| `HFS_ELASTICSEARCH_REFRESH_INTERVAL` | `1s` | Elasticsearch index `refresh_interval` for ES-backed backends. Controls how quickly indexed documents become searchable when no per-write refresh is requested. `-1` disables periodic refresh entirely. Applied when an index is created; indices that already exist keep their current setting. |
| `HFS_ELASTICSEARCH_WRITE_REFRESH` | `false` | The `refresh` parameter applied to Elasticsearch index/delete operations. One of `false` (no per-write refresh), `wait_for` (block each write until the affected shards refresh), or `true` (force a refresh per write; expensive, low-volume deployments only). |
+| `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` | `50000` | Elasticsearch `index.mapping.nested_objects.limit`: the maximum nested objects a single document may contain, summed across every nested search-parameter field. Elasticsearch's own default of 10000 rejects larger documents, so a resource with very many indexed values (for example a Synthea `Provenance` with 13,554 `target` references) is stored but never searchable. Applied to new indices through the index template, and raised at startup on existing indices that are below it; the setting is dynamic, so no reindex is needed for resources that already indexed. |
Read-after-write search on an ES-backed composite needs **both**
`HFS_COMPOSITE_SYNC_MODE=synchronous` and
diff --git a/crates/hfs/src/main.rs b/crates/hfs/src/main.rs
index a58dc2f996..d11f689527 100644
--- a/crates/hfs/src/main.rs
+++ b/crates/hfs/src/main.rs
@@ -2191,6 +2191,7 @@ async fn start_sqlite_elasticsearch(
fhir_version: config.default_fhir_version,
refresh_interval: config.elasticsearch_refresh_interval.clone(),
write_refresh: es_write_refresh_from_config(&config)?,
+ nested_objects_limit: config.elasticsearch_nested_objects_limit,
..Default::default()
};
@@ -2485,6 +2486,7 @@ async fn start_postgres_elasticsearch(
fhir_version: config.default_fhir_version,
refresh_interval: config.elasticsearch_refresh_interval.clone(),
write_refresh: es_write_refresh_from_config(&config)?,
+ nested_objects_limit: config.elasticsearch_nested_objects_limit,
..Default::default()
};
@@ -2696,6 +2698,7 @@ async fn start_mongodb_elasticsearch(
fhir_version: config.default_fhir_version,
refresh_interval: config.elasticsearch_refresh_interval.clone(),
write_refresh: es_write_refresh_from_config(&config)?,
+ nested_objects_limit: config.elasticsearch_nested_objects_limit,
..Default::default()
};
@@ -3108,6 +3111,7 @@ async fn start_s3_elasticsearch(
fhir_version: config.default_fhir_version,
refresh_interval: config.elasticsearch_refresh_interval.clone(),
write_refresh: es_write_refresh_from_config(&config)?,
+ nested_objects_limit: config.elasticsearch_nested_objects_limit,
..Default::default()
};
diff --git a/crates/persistence/README.md b/crates/persistence/README.md
index 7f85fda2a5..f977d45d6c 100644
--- a/crates/persistence/README.md
+++ b/crates/persistence/README.md
@@ -1407,6 +1407,23 @@ immediate follow-up search misses the write. The `hfs` binary exposes these as
`HFS_ELASTICSEARCH_WRITE_REFRESH` (see the
[hfs README](../hfs/README.md#environment-variables)).
+#### Very large resources on Elasticsearch-backed composites
+
+Every indexed search-parameter value is a nested object in the resource's
+Elasticsearch document, and Elasticsearch rejects the **whole document** once it
+holds more than `index.mapping.nested_objects.limit` of them. The resource stays
+stored in the primary and readable by id, but is absent from every search.
+Elasticsearch's own default of 10000 is exceeded by real data: 458 of the 11,704
+Synthea `Provenance` resources carry more than 10000 `target` references (the
+largest, 28,192).
+
+`ElasticsearchConfig::nested_objects_limit` (default 50000) is written into the
+index template, so new indices get it, and is raised during backend
+initialization on existing indices that are below it. The setting is dynamic,
+so resources that already indexed need no reindex; resources rejected before the
+raise are repaired with `POST /{type}/$reindex`. The `hfs` binary exposes it as
+`HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT`.
+
### Cost-Based Optimization
The cost estimator uses benchmark-derived costs to make routing decisions:
diff --git a/crates/persistence/src/backends/elasticsearch/backend.rs b/crates/persistence/src/backends/elasticsearch/backend.rs
index 8b71dd2088..6430a1dad2 100644
--- a/crates/persistence/src/backends/elasticsearch/backend.rs
+++ b/crates/persistence/src/backends/elasticsearch/backend.rs
@@ -120,6 +120,18 @@ pub struct ElasticsearchConfig {
#[serde(default = "default_max_result_window")]
pub max_result_window: u32,
+ /// Maximum nested objects one document may contain, summed across every
+ /// `nested` search-parameter field (default: 50000).
+ ///
+ /// Elasticsearch's own default of 10000 rejects the whole document, so a
+ /// resource with more indexed values than that — a Synthea `Provenance`
+ /// whose `target` array alone holds 13,554 references — is stored but
+ /// never searchable (#1050). The setting is dynamic: new indices take it
+ /// from the index template, existing ones are raised in
+ /// [`Backend::initialize`].
+ #[serde(default = "default_nested_objects_limit")]
+ pub nested_objects_limit: u32,
+
/// Request timeout in milliseconds (default: 30000).
#[serde(default = "default_request_timeout_ms")]
pub request_timeout_ms: u64,
@@ -158,6 +170,10 @@ fn default_max_result_window() -> u32 {
10000
}
+fn default_nested_objects_limit() -> u32 {
+ 50_000
+}
+
fn default_request_timeout_ms() -> u64 {
30000
}
@@ -172,6 +188,7 @@ impl Default for ElasticsearchConfig {
refresh_interval: default_refresh_interval(),
write_refresh: WriteRefreshPolicy::default(),
max_result_window: default_max_result_window(),
+ nested_objects_limit: default_nested_objects_limit(),
request_timeout_ms: default_request_timeout_ms(),
auth: None,
disable_certificate_validation: false,
@@ -540,7 +557,29 @@ impl Backend for ElasticsearchBackend {
backend_name: "elasticsearch".to_string(),
message: format!("Failed to create index template: {}", e),
source: None,
- })
+ })?;
+
+ // The template only reaches indices created from now on. Indices that
+ // already exist keep Elasticsearch's 10000 nested-object limit until
+ // raised here (#1050). Not fatal: a missing `manage` privilege must not
+ // stop the server, and every resource under the old limit still
+ // indexes.
+ let limit = self.config().nested_objects_limit;
+ match super::schema::raise_nested_objects_limit(self).await {
+ Ok(0) => {}
+ Ok(raised) => tracing::info!(
+ indices = raised,
+ limit,
+ "raised the Elasticsearch nested-object limit on existing indices"
+ ),
+ Err(e) => tracing::warn!(
+ error = %e,
+ limit,
+ "could not raise the Elasticsearch nested-object limit on existing indices; \
+ resources with more nested values than an index's current limit stay unsearchable"
+ ),
+ }
+ Ok(())
}
async fn migrate(&self) -> Result<(), BackendError> {
diff --git a/crates/persistence/src/backends/elasticsearch/schema.rs b/crates/persistence/src/backends/elasticsearch/schema.rs
index 4eb4078179..8f88514b5c 100644
--- a/crates/persistence/src/backends/elasticsearch/schema.rs
+++ b/crates/persistence/src/backends/elasticsearch/schema.rs
@@ -3,7 +3,10 @@
//! Defines the index structure for FHIR resources in Elasticsearch.
//! Uses nested objects for search parameters to ensure correct multi-value matching.
-use elasticsearch::indices::{IndicesCreateParts, IndicesExistsParts, IndicesPutTemplateParts};
+use elasticsearch::indices::{
+ IndicesCreateParts, IndicesExistsParts, IndicesGetSettingsParts, IndicesPutSettingsParts,
+ IndicesPutTemplateParts,
+};
use serde_json::json;
use crate::error::{BackendError, StorageResult};
@@ -24,6 +27,7 @@ pub fn create_index_mapping(config: &super::backend::ElasticsearchConfig) -> ser
"number_of_shards": config.number_of_shards,
"number_of_replicas": config.number_of_replicas,
"index.max_result_window": config.max_result_window,
+ "index.mapping.nested_objects.limit": config.nested_objects_limit,
"refresh_interval": config.refresh_interval,
"analysis": {
"normalizer": {
@@ -259,6 +263,121 @@ pub async fn create_index_template(backend: &ElasticsearchBackend) -> StorageRes
}
/// Ensures an index exists for the given tenant and resource type, creating it if necessary.
+/// The index setting capping how many nested objects one document may hold.
+const NESTED_OBJECTS_LIMIT_SETTING: &str = "index.mapping.nested_objects.limit";
+
+/// Raises the nested-object limit on every existing index under the configured
+/// prefix whose current limit is below the configured one, and returns how many
+/// indices were raised.
+///
+/// New indices take the limit from the index template, but a template only
+/// applies when an index is created. A deployment that indexed data before
+/// #1050 keeps Elasticsearch's default of 10000 on those indices, and keeps
+/// silently dropping large resources from search on the next write or
+/// `$reindex`. The setting is dynamic, so it changes on a live index without
+/// closing or reindexing it.
+///
+/// The limit is only ever raised: an index an operator already set higher by
+/// hand is left alone.
+pub async fn raise_nested_objects_limit(backend: &ElasticsearchBackend) -> StorageResult {
+ /// Index names per update request, keeping the request URL short.
+ const INDICES_PER_REQUEST: usize = 50;
+
+ let target = u64::from(backend.config().nested_objects_limit);
+ let pattern = format!("{}_*", backend.config().index_prefix);
+
+ let response = backend
+ .client()
+ .indices()
+ .get_settings(IndicesGetSettingsParts::IndexName(
+ &[&pattern],
+ &[NESTED_OBJECTS_LIMIT_SETTING],
+ ))
+ .include_defaults(true)
+ .flat_settings(true)
+ .allow_no_indices(true)
+ .ignore_unavailable(true)
+ .send()
+ .await
+ .map_err(|e| settings_error(format!("Failed to read index settings: {e}")))?;
+ let status = response.status_code();
+ let body: serde_json::Value = response
+ .json()
+ .await
+ .map_err(|e| settings_error(format!("Failed to parse index settings: {e}")))?;
+ if !status.is_success() {
+ return Err(settings_error(format!(
+ "Reading index settings failed (status {status}): {body}"
+ )));
+ }
+
+ let below = indices_below_nested_limit(&body, target);
+ for chunk in below.chunks(INDICES_PER_REQUEST) {
+ let names: Vec<&str> = chunk.iter().map(String::as_str).collect();
+ let mut settings = serde_json::Map::new();
+ settings.insert(NESTED_OBJECTS_LIMIT_SETTING.to_string(), json!(target));
+ let response = backend
+ .client()
+ .indices()
+ .put_settings(IndicesPutSettingsParts::Index(&names))
+ .body(serde_json::Value::Object(settings))
+ .send()
+ .await
+ .map_err(|e| {
+ settings_error(format!(
+ "Failed to raise {NESTED_OBJECTS_LIMIT_SETTING}: {e}"
+ ))
+ })?;
+ let status = response.status_code();
+ if !status.is_success() {
+ let text = response.text().await.unwrap_or_default();
+ return Err(settings_error(format!(
+ "Raising {NESTED_OBJECTS_LIMIT_SETTING} failed (status {status}): {text}"
+ )));
+ }
+ }
+ Ok(below.len())
+}
+
+/// The indices in a `GET _settings?include_defaults=true&flat_settings=true`
+/// response whose nested-object limit is below `target`, sorted by name.
+///
+/// An explicit per-index value wins over the cluster default. An index that
+/// reports the setting in neither place is left alone rather than guessed at.
+fn indices_below_nested_limit(body: &serde_json::Value, target: u64) -> Vec {
+ let Some(indices) = body.as_object() else {
+ return Vec::new();
+ };
+ let mut below: Vec = indices
+ .iter()
+ .filter_map(|(name, entry)| {
+ let value = entry
+ .get("settings")
+ .and_then(|s| s.get(NESTED_OBJECTS_LIMIT_SETTING))
+ .or_else(|| {
+ entry
+ .get("defaults")
+ .and_then(|d| d.get(NESTED_OBJECTS_LIMIT_SETTING))
+ })?;
+ let current = value
+ .as_str()
+ .and_then(|s| s.parse::().ok())
+ .or_else(|| value.as_u64())?;
+ (current < target).then(|| name.clone())
+ })
+ .collect();
+ below.sort();
+ below
+}
+
+fn settings_error(message: String) -> crate::error::StorageError {
+ crate::error::StorageError::Backend(BackendError::Internal {
+ backend_name: "elasticsearch".to_string(),
+ message,
+ source: None,
+ })
+}
+
pub async fn ensure_index(
backend: &ElasticsearchBackend,
tenant_id: &str,
@@ -468,6 +587,12 @@ mod tests {
// Verify settings
assert_eq!(mapping["settings"]["number_of_shards"], 1);
assert_eq!(mapping["settings"]["number_of_replicas"], 1);
+ // #1050: raised above Elasticsearch's 10000 default, which silently
+ // drops resources with many indexed values from search.
+ assert_eq!(
+ mapping["settings"]["index.mapping.nested_objects.limit"],
+ 50_000
+ );
// Verify mappings exist
let props = &mapping["mappings"]["properties"];
@@ -489,4 +614,39 @@ mod tests {
// Verify normalizer
assert!(mapping["settings"]["analysis"]["normalizer"]["lowercase_normalizer"].is_object());
}
+
+ /// #1050: which existing indices the startup pass raises. An explicit
+ /// value beats the cluster default, an index already above the target is
+ /// never lowered, and an index that reports no value is not guessed at.
+ #[test]
+ fn test_indices_below_nested_limit() {
+ let body = json!({
+ "hfs_t_provenance": {
+ "settings": {},
+ "defaults": { "index.mapping.nested_objects.limit": "10000" }
+ },
+ "hfs_t_observation": {
+ "settings": { "index.mapping.nested_objects.limit": "10000" },
+ "defaults": {}
+ },
+ "hfs_t_patient": {
+ "settings": { "index.mapping.nested_objects.limit": "80000" },
+ "defaults": { "index.mapping.nested_objects.limit": "10000" }
+ },
+ "hfs_t_encounter": {
+ "settings": { "index.mapping.nested_objects.limit": "50000" }
+ },
+ "hfs_t_unknown": { "settings": {}, "defaults": {} }
+ });
+
+ assert_eq!(
+ indices_below_nested_limit(&body, 50_000),
+ vec![
+ "hfs_t_observation".to_string(),
+ "hfs_t_provenance".to_string()
+ ]
+ );
+ assert!(indices_below_nested_limit(&json!({}), 50_000).is_empty());
+ assert!(indices_below_nested_limit(&json!("not an object"), 50_000).is_empty());
+ }
}
diff --git a/crates/persistence/tests/elasticsearch_tests.rs b/crates/persistence/tests/elasticsearch_tests.rs
index 1d348ac5a4..0d73ee5445 100644
--- a/crates/persistence/tests/elasticsearch_tests.rs
+++ b/crates/persistence/tests/elasticsearch_tests.rs
@@ -25,6 +25,7 @@ fn test_elasticsearch_config_defaults() {
assert_eq!(config.number_of_shards, 1);
assert_eq!(config.number_of_replicas, 1);
assert!(config.auth.is_none());
+ assert_eq!(config.nested_objects_limit, 50_000);
}
#[test]
@@ -53,6 +54,17 @@ fn test_write_refresh_policy_default_is_false() {
assert_eq!(config.write_refresh, WriteRefreshPolicy::False);
}
+/// #1050: a config that omits the field must not fall back to
+/// Elasticsearch's own limit of 10000, which drops large resources from search.
+#[test]
+fn test_nested_objects_limit_defaults_above_elasticsearch_default() {
+ assert_eq!(ElasticsearchConfig::default().nested_objects_limit, 50_000);
+
+ let json = r#"{"nodes": ["http://localhost:9200"]}"#;
+ let config: ElasticsearchConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.nested_objects_limit, 50_000);
+}
+
#[test]
fn test_write_refresh_policy_parsing() {
assert_eq!(
@@ -854,6 +866,60 @@ mod es_integration {
TenantContext::new(TenantId::new(id), TenantPermissions::full_access())
}
+ /// A backend on `index_prefix` with the given nested-object limit, so the
+ /// #1050 tests can put two backends on the same indices.
+ async fn create_backend_with_nested_limit(
+ index_prefix: &str,
+ nested_objects_limit: u32,
+ ) -> ElasticsearchBackend {
+ let es = shared_es().await;
+ let config = ElasticsearchConfig {
+ nodes: vec![format!("http://{}:{}", es.host, es.port)],
+ index_prefix: index_prefix.to_string(),
+ number_of_replicas: 0,
+ refresh_interval: "1ms".to_string(),
+ nested_objects_limit,
+ ..Default::default()
+ };
+ let backend = ElasticsearchBackend::with_shared_registry(config, build_search_registry())
+ .expect("Failed to create ElasticsearchBackend");
+ backend
+ .initialize()
+ .await
+ .expect("Failed to initialize ES backend");
+ backend
+ }
+
+ /// A Synthea-shaped `Provenance` whose `target` array alone holds more
+ /// nested reference values than Elasticsearch's default limit of 10000 —
+ /// the shape of the 458 resources #1050 found stored but unsearchable.
+ fn oversized_provenance(
+ tenant: &TenantContext,
+ id: &str,
+ targets: usize,
+ ) -> helios_persistence::types::StoredResource {
+ let target: Vec = (0..targets)
+ .map(|n| json!({ "reference": format!("Observation/obs-{n}") }))
+ .collect();
+ helios_persistence::types::StoredResource::from_storage(
+ "Provenance",
+ id.to_string(),
+ "1",
+ tenant.tenant_id().clone(),
+ json!({
+ "resourceType": "Provenance",
+ "id": id,
+ "target": target,
+ "recorded": "2009-02-28T07:56:45.469-05:00",
+ "agent": [{ "who": { "reference": "Practitioner/p1" } }]
+ }),
+ chrono::Utc::now(),
+ chrono::Utc::now(),
+ None,
+ FhirVersion::default(),
+ )
+ }
+
/// #519: the #456 boundary table over the real ES search path.
#[tokio::test]
async fn es_day_precision_date_boundaries() {
@@ -886,6 +952,89 @@ mod es_integration {
assert_eq!(created.version_id(), "1");
}
+ /// #1050: Elasticsearch's default nested-object limit of 10000 rejects the
+ /// whole document, so a resource with more indexed values than that was
+ /// stored but never searchable. With the raised default it indexes through
+ /// the `$reindex` page writer.
+ #[tokio::test]
+ async fn es_integration_resource_over_elasticsearch_default_nested_limit_indexes() {
+ use helios_persistence::search::ReindexTarget;
+
+ let backend = create_backend().await;
+ let tenant = create_tenant("nested-limit-default");
+ let provenance = oversized_provenance(&tenant, "oversized", 12_000);
+
+ let outcomes = backend
+ .write_search_entries_page(&tenant, std::slice::from_ref(&provenance))
+ .await;
+ assert!(
+ outcomes[0].is_ok(),
+ "a Provenance with 12000 targets must index: {:?}",
+ outcomes[0]
+ );
+ assert!(
+ backend
+ .read(&tenant, "Provenance", "oversized")
+ .await
+ .unwrap()
+ .is_some(),
+ "the document must be in the index"
+ );
+ }
+
+ /// #1050: the index template only reaches indices created after it, so an
+ /// index that already existed at Elasticsearch's limit of 10000 kept
+ /// rejecting large resources. Starting a backend with the raised limit must
+ /// raise it on that existing index. `ensure_index` never touches an index
+ /// that exists, so only the startup pass can explain the second write
+ /// succeeding.
+ #[tokio::test]
+ async fn es_integration_startup_raises_nested_limit_on_existing_index() {
+ use helios_persistence::search::ReindexTarget;
+
+ let prefix = format!("hfs_{}", uuid::Uuid::new_v4().simple());
+ let tenant = create_tenant("nested-limit-existing");
+ let provenance = oversized_provenance(&tenant, "oversized", 12_000);
+
+ // An index created under Elasticsearch's own limit rejects it.
+ let before = create_backend_with_nested_limit(&prefix, 10_000).await;
+ let rejected = before
+ .write_search_entries_page(&tenant, std::slice::from_ref(&provenance))
+ .await;
+ let error = rejected[0]
+ .as_ref()
+ .expect_err("a limit of 10000 must reject 12000 nested objects");
+ assert!(
+ error.to_string().contains("nested"),
+ "the rejection must be the nested-object limit, got: {error}"
+ );
+ assert!(
+ before
+ .read(&tenant, "Provenance", "oversized")
+ .await
+ .unwrap()
+ .is_none()
+ );
+
+ // A backend started with the raised limit fixes the existing index.
+ let after = create_backend_with_nested_limit(&prefix, 50_000).await;
+ let outcomes = after
+ .write_search_entries_page(&tenant, std::slice::from_ref(&provenance))
+ .await;
+ assert!(
+ outcomes[0].is_ok(),
+ "the raised index must accept it: {:?}",
+ outcomes[0]
+ );
+ assert!(
+ after
+ .read(&tenant, "Provenance", "oversized")
+ .await
+ .unwrap()
+ .is_some()
+ );
+ }
+
/// `$reindex` walks a page at a time and, before #1021, Elasticsearch used
/// the default trait implementation: one HTTP round trip per resource. This
/// pins the batched override — every resource of the page indexed, its own
diff --git a/crates/rest/src/config.rs b/crates/rest/src/config.rs
index e97a493153..504dd25ffd 100644
--- a/crates/rest/src/config.rs
+++ b/crates/rest/src/config.rs
@@ -1130,6 +1130,19 @@ pub struct ServerConfig {
#[arg(long, env = "HFS_ELASTICSEARCH_WRITE_REFRESH", default_value = "false")]
pub elasticsearch_write_refresh: String,
+ /// Maximum nested objects one Elasticsearch document may contain, summed
+ /// across every nested search-parameter field. Elasticsearch's default of
+ /// 10000 rejects larger documents outright, leaving those resources stored
+ /// but unsearchable (#1050). New indices take the value from the index
+ /// template; existing indices below it are raised at startup (the setting
+ /// is dynamic, so no reindex of already-indexed resources is needed).
+ #[arg(
+ long,
+ env = "HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT",
+ default_value = "50000"
+ )]
+ pub elasticsearch_nested_objects_limit: u32,
+
/// Enable SQL-on-FHIR operations ($sql-run, $sql-export).
/// When enabled, the configured storage backend MUST provide an in-DB
/// SOF runner (sqlite or postgres) — there is no in-process fallback.
@@ -1397,6 +1410,7 @@ impl Default for ServerConfig {
elasticsearch_password: None,
elasticsearch_refresh_interval: "1s".to_string(),
elasticsearch_write_refresh: "false".to_string(),
+ elasticsearch_nested_objects_limit: 50_000,
sof_enabled: true,
ui_enabled: true,
dashboard_reconcile_interval_secs: 30,
@@ -1535,6 +1549,10 @@ impl ServerConfig {
errors.push("Batch max concurrency cannot be 0".to_string());
}
+ if self.elasticsearch_nested_objects_limit == 0 {
+ errors.push("Elasticsearch nested objects limit cannot be 0".to_string());
+ }
+
if self.dashboard_reconcile_interval_secs == 0 {
errors.push("Dashboard reconcile interval cannot be 0".to_string());
}
@@ -1640,6 +1658,7 @@ impl ServerConfig {
elasticsearch_password: None,
elasticsearch_refresh_interval: "1s".to_string(),
elasticsearch_write_refresh: "false".to_string(),
+ elasticsearch_nested_objects_limit: 50_000,
sof_enabled: true,
ui_enabled: true,
dashboard_reconcile_interval_secs: 30,
From 6890dc000c1603eee29467a92cca59fc020414c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mauricio=20Pe=C3=B1aranda?=
Date: Mon, 14 Sep 2026 09:53:04 -0400
Subject: [PATCH 2/5] fix(reindex): report failing resources and stop retrying
permanent rejections (#1050)
A reindex that finished with per-resource errors exposed only
`errorCount` in `$reindex-status`, so the 458 Provenance records
Elasticsearch rejected in #1050 could only be found by reading server
logs. And the automatic deferred reindex (#1087) retried any such
completion once, which for a document the index rejects outright just
repeats the same failure over the same resources.
- Each recorded error now carries `retryable`: true for the conditions
REST answers with 503/504 (Unavailable, ConnectionFailed,
PoolExhausted, Timeout), false otherwise. Unclassified errors
deserialize as retryable, keeping the old behaviour.
- The Elasticsearch `_bulk` path classifies failures: a request that
never arrived, a 429 or a 5xx is transient and surfaces from
`write_search_entries_page` as Unavailable; a 4xx item rejection
(the nested-object limit) stays Internal. `create_many` keeps its
existing error mapping.
- `$reindex-status` lists the first 100 failing resources as `error`
parameters (resourceType, resourceId, message, retryable), plus
`errorsOmitted` and, for a failed job, `errorMessage`. `errorCount`
is unchanged.
- An automatic generation that completes with only permanent errors is
not retried; it logs the count, the first failing Type/ids and the
first error. Any transient error, a failed job or a panic still gets
the single retry.
---
.agents/skills/bulk-data-submit/SKILL.md | 4 +-
.claude/skills/bulk-data-submit/SKILL.md | 2 +-
.../src/backends/elasticsearch/storage.rs | 71 +++-
crates/persistence/src/search/reindex.rs | 384 ++++++++++++++++--
.../persistence/tests/elasticsearch_tests.rs | 10 +
crates/rest/README.md | 8 +
6 files changed, 428 insertions(+), 51 deletions(-)
diff --git a/.agents/skills/bulk-data-submit/SKILL.md b/.agents/skills/bulk-data-submit/SKILL.md
index 8a8729fe15..1ac5665aca 100644
--- a/.agents/skills/bulk-data-submit/SKILL.md
+++ b/.agents/skills/bulk-data-submit/SKILL.md
@@ -112,12 +112,12 @@ The backend capability splits into `BulkSubmitIngest` (the synchronous `BulkSubm
- With `HFS_BULK_SUBMIT_DEFER_INDEXING=true` (bulk fast-load, #903, the default since #946), ingestion skips search-index and FTS writes. The worker requests an automatic full-type reindex after the manifest becomes terminal, so `$bulk-submit-status` can answer `200` while search is still incomplete.
- Automatic deferred reindex requests share the coordinator owned by their `ReindexOperation` (#1087). One tenant has one active generation plus one pending, deduplicated type set. The process runs at most `W` automatic generations and retains at most `2W` tenant entries, where `W` is the existing `HFS_BULK_SUBMIT_WORKER_CONCURRENCY` value. Admission applies backpressure before it adds another tenant. HFS has no separate bulk-submit reindex-concurrency variable.
- The coordinator releases and reacquires its execution permit between generations so another admitted tenant can progress. Separate `ReindexOperation` instances remain independent because they can have different writer and registry sets. Explicit `$reindex` jobs bypass this coordinator and can overlap automatic work.
-- A clean automatic generation ends `Completed` with no resource errors. Failure, panic, or an errorful completion gets one retry with the active and pending types. A second consecutive failure abandons that generation and logs the manual `$reindex` repair. Independently queued work that arrived during the retry still runs as a new generation with its own retry budget. Cancellation does not retry the cancelled active types, but independently queued pending types still run after the cancelled task has stopped writing.
+- A clean automatic generation ends `Completed` with no resource errors. Failure, panic, or a completion with *transient* resource errors (a backend that was unavailable, timed out, or answered Elasticsearch `429`/`5xx`) gets one retry with the active and pending types. A completion whose resource errors are all *permanent* — a document the search backend rejects outright, such as one over Elasticsearch's nested-object limit (#1050) — is not retried, because a rerun fails identically; it logs the error count and the first failing `Type/id`s instead. A second consecutive failure abandons that generation and logs the manual `$reindex` repair. Independently queued work that arrived during the retry still runs as a new generation with its own retry budget. Cancellation does not retry the cancelled active types, but independently queued pending types still run after the cancelled task has stopped writing.
- Coordination and reindex job state are in memory and local to one HFS process. A restart loses pending work, and separate processes do not coordinate. Full-type scans remain in use, so a finite burst can still cause one active scan and one accumulated follow-up. Limiting work to successful manifest IDs was evaluated and deferred because the generic path lacks bounded receipt deduplication, current-resource handling for missing or deleted IDs, and consistent semantics for every composite target.
- The coordination logic is common to standalone SQLite, PostgreSQL, and MongoDB plus the Elasticsearch composites that wire reindex. Current performance evidence is PostgreSQL-only; do not claim equivalent latency or database-work improvements for the other backends without measuring them. See `docs/deferred-reindex-coordination-benchmark.md`.
- MongoDB ingests a batch, not an entry (#1000): one `find` resolves which ids already exist, then one `insert`/`update` command per collection. The per-entry path it replaced cost ~9 round trips per resource and ran at ~60–76 resources/s with the server two-thirds idle; batched it reaches ~720, and ~3 100 with indexing deferred. The flush is ordered commands, not one transaction — the per-entry path was not atomic across a batch either, and a batch-wide transaction would widen #1001 from one lost entry to a whole batch.
- Composite deployments (primary + Elasticsearch, including the `mongo-es`/`s3-es` modes) must wrap the primary's job store with `composite_submit_jobs(...)`: ingestion runs on the primary, whose own indexing is offloaded, so without the wrapper a completed import is readable by id and invisible to every search (#882, and #1021 for the modes that were missed). Guard: `crates/hfs/tests/bulk_submit/run_composite_es_index_check.sh`.
-- On those composite deployments (#1007), the worker copies every manifest's ingested resources into the secondary search index *before* writing the manifest's receipt (not at `finish_manifest`, which no longer syncs — a manifest already terminal is never re-synced by a restart). A resource the secondary still rejects after its retries gets an entry result of `processing-error` in the receipt, with an OperationOutcome (`incomplete`) naming the `Type/id`, the rejecting backend, and `POST /{type}/$reindex` as the repair; the resource itself stays stored and readable by id, and `failed_entries` on the status counts it.
+- On those composite deployments (#1007), the worker copies every manifest's ingested resources into the secondary search index *before* writing the manifest's receipt (not at `finish_manifest`, which no longer syncs — a manifest already terminal is never re-synced by a restart). A resource the secondary still rejects after its retries gets an entry result of `processing-error` in the receipt, with an OperationOutcome (`incomplete`) naming the `Type/id`, the rejecting backend, and `POST /{type}/$reindex` as the repair; the resource itself stays stored and readable by id, and `failed_entries` on the status counts it. If the rejection is Elasticsearch's nested-object limit (`The number of nested documents has exceeded the allowed limit`), `$reindex` fails the same way until `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` (default 50000, raised on existing indices at startup) is above that resource's nested value count (#1050). Each failed resource is listed, with its error and whether it is retryable, in `GET /$reindex-status/{job_id}`.
- After that copy, the worker compares the primary's tenant-wide count against each secondary's for every resource type the manifest ingested. A mismatch is recorded as a `warning` OperationOutcome (also `incomplete`, naming both counts) in the manifest's `error` artifact and logged. This only runs when `HFS_COMPOSITE_SYNC_MODE` is `synchronous` or `hybrid`; under the default `asynchronous` mode the secondary's count reflects whatever had already drained from its queue rather than this manifest's sync, so the check is skipped and the receipt then only guarantees the resources committed on the primary.
- Without `HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for`, a small count difference can be a write not yet visible rather than a real gap; reconfirm with `GET /{type}?_summary=count` before treating it as drift. See "Verifying and repairing search drift" below.
- `HFS_BULK_SUBMIT_DEFER_INDEXING` is read once at startup, not per submission. Repair a stale or missed index with `$reindex`.
diff --git a/.claude/skills/bulk-data-submit/SKILL.md b/.claude/skills/bulk-data-submit/SKILL.md
index 7cb102ccce..4c7b938c0b 100644
--- a/.claude/skills/bulk-data-submit/SKILL.md
+++ b/.claude/skills/bulk-data-submit/SKILL.md
@@ -119,7 +119,7 @@ The backend capability splits into `BulkSubmitIngest` (the synchronous `BulkSubm
- With `HFS_BULK_SUBMIT_DEFER_INDEXING=true` (bulk fast-load, #903 — **the default since #946**) ingestion skips the search-index and FTS writes and an automatic per-type reindex rebuilds them when each manifest finishes. Reads and history are complete throughout; search sees a manifest's resources once its reindex lands. That rebuild is started *after* the manifest is already terminal and is fire-and-forget (`bulk_submit_worker.rs` → `reindex.rs`, `tokio::spawn`), so `$bulk-submit-status` answers `200` while search is still incomplete, and the job lives only in an in-memory map — no column on `bulk_manifests` records that indexing is outstanding and nothing re-fires it at startup. A restart in that window is not recoverable on its own.
- MongoDB ingests a batch, not an entry: one `find` resolves which of the batch's ids already exist, then one `insert` or `update` command per collection writes the whole batch (`backends/mongodb/bulk_ingest.rs`). Before #1000 each entry cost ~9 round trips of its own — a `read`, `create`'s second existence probe, the resource and history inserts, a search-index delete and insert, a transaction commit, the rollback record and the receipt — which pinned ingest at ~60–76 resources/s with `mongod` two-thirds idle. The batch flush is a sequence of commands rather than one transaction. Every command is retried on a transient driver error (`RetryableError`/`RetryableWriteError` label, I/O error, cleared pool — not a server-selection timeout) with 100 ms doubling backoff capped at 1 s over six attempts, checking the submission's cancel token before each sleep; a retry never duplicates what an earlier attempt landed (resources are re-read and matched on version + the batch's own `last_updated` + content, history and rollback rows dedupe on their unique keys, the search index is cleared before re-insert). When a stage outlives its retries the batch's entries get `processing-error` receipts with issue code `transient` and the file continues with its next batch, so `max_errors`/`continue_on_error` govern backend failures too (#1001); re-submitting the file converges. Only a receipt write that itself fails after retries still aborts the file. The manifest counters are a `$inc` and may over-count one batch if a retried attempt had actually landed — the receipts are authoritative.
- **On a composite deployment (primary + Elasticsearch), the ingest engine does not reach the secondary by itself.** Ingestion runs on the *primary's* engine, and the primary deliberately skips its own indexing when search is offloaded — so `main.rs` wraps the primary's job store in `CompositeSubmitJobs`, which syncs each manifest's ingested resources into the secondary. Every composite mode must call `composite_submit_jobs(...)`; `mongo-es` and `s3-es` did not, and a completed import there was readable by id and invisible to every search — 15.27M of 15.28M resources on the reported deployment, with `GET` by id passing every smoke test (#1021). `crates/hfs/tests/bulk_submit/run_composite_es_index_check.sh` asserts the searchable count, not just readability, and is the guard against a fourth composite backend repeating it.
-- The sync itself runs *before* the manifest's receipt is written (#1007), as an explicit worker step — not at `finish_manifest`, which no longer syncs by itself, so a manifest that already reached a terminal state is never re-synced by a restart; repair it with `$reindex`. A resource the secondary still rejects after its retries gets an entry result of `processing-error` in the receipt, carrying an OperationOutcome (`incomplete`) that names the `Type/id`, the rejecting backend, and `POST /{type}/$reindex` as the repair; the resource itself stays stored and readable by id, and the status's `failed_entries` counts it.
+- The sync itself runs *before* the manifest's receipt is written (#1007), as an explicit worker step — not at `finish_manifest`, which no longer syncs by itself, so a manifest that already reached a terminal state is never re-synced by a restart; repair it with `$reindex`. A resource the secondary still rejects after its retries gets an entry result of `processing-error` in the receipt, carrying an OperationOutcome (`incomplete`) that names the `Type/id`, the rejecting backend, and `POST /{type}/$reindex` as the repair; the resource itself stays stored and readable by id, and the status's `failed_entries` counts it. If the rejection is Elasticsearch's nested-object limit (`The number of nested documents has exceeded the allowed limit`), `$reindex` fails the same way until `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT` (default 50000, raised on existing indices at startup) is above that resource's nested value count (#1050). Each failed resource is listed, with its error and whether it is retryable, in `GET /$reindex-status/{job_id}`.
- After that copy, for every resource type the manifest ingested, the worker compares the primary's tenant-wide resource count against each secondary's. A mismatch is recorded as a `warning` OperationOutcome (also `incomplete`, naming both counts) in the manifest's `error` artifact and logged on the server. This check only runs when `HFS_COMPOSITE_SYNC_MODE` is `synchronous` or `hybrid`; under the default `asynchronous` mode the secondary's count reflects whatever had already drained from its queue rather than this manifest's own sync, so the check is skipped and the receipt then guarantees only that the resources committed on the primary.
- Without `HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for`, a small count difference can be a write that has not become visible yet rather than a real gap; reconfirm with `GET /{type}?_summary=count` before treating it as drift. See "Verifying and repairing search drift" below.
- **Under deferred indexing the rebuild is the import.** On SQLite the fast-load
diff --git a/crates/persistence/src/backends/elasticsearch/storage.rs b/crates/persistence/src/backends/elasticsearch/storage.rs
index 50644f0dcf..d087fb5f42 100644
--- a/crates/persistence/src/backends/elasticsearch/storage.rs
+++ b/crates/persistence/src/backends/elasticsearch/storage.rs
@@ -29,6 +29,22 @@ use super::schema;
/// making a load pay one refresh wait per handful of documents.
const BULK_OPS_PER_REQUEST: usize = 500;
+/// Why a resource's documents did not index in a `_bulk` request.
+struct BulkFailure {
+ message: String,
+ /// The cluster never judged the document — the request did not arrive, or
+ /// the cluster pushed back (`429`) or failed (`5xx`) — so a rerun may
+ /// succeed. A `4xx` rejection of the document itself (for example the
+ /// nested-object limit, #1050) is permanent.
+ transient: bool,
+}
+
+/// Whether a `_bulk` request or item status asks for a retry rather than
+/// rejecting the document.
+fn is_transient_bulk_status(status: u64) -> bool {
+ status == 429 || (500..600).contains(&status)
+}
+
fn internal_error(message: String) -> StorageError {
StorageError::Backend(BackendError::Internal {
backend_name: "elasticsearch".to_string(),
@@ -732,7 +748,7 @@ impl ResourceStorage for ElasticsearchBackend {
.into_iter()
.zip(failures)
.map(|(p, failure)| match failure {
- Some(message) => Err(internal_error(message)),
+ Some(failure) => Err(internal_error(failure.message)),
None => Ok(StoredResource::from_storage(
resource_type,
&p.id,
@@ -1337,15 +1353,19 @@ impl ElasticsearchBackend {
&self,
ops: &[(usize, &str, &str, &Value)],
owners: usize,
- ) -> Vec
+ {#
+ #1065: a search-index rebuild is running. Stored resources stay readable
+ by id, but searches can miss them until it finishes. Inside the live
+ region, so the refresh a running rebuild keeps going updates it.
+ #}
+ {% if let Some(activity) = rebuild %}
+
{{ self.rebuild_text(activity) }}
+ {% endif %}
{% if chart_unsupported %}
{# #1078: this backend cannot count, so there is nothing to wait for and
diff --git a/crates/ui/templates/pages/resources.html b/crates/ui/templates/pages/resources.html
index da6a647e5b..a2cd7d9eb3 100644
--- a/crates/ui/templates/pages/resources.html
+++ b/crates/ui/templates/pages/resources.html
@@ -19,6 +19,10 @@
{{ i18n.t("resources-heading") }}
{{ i18n.t("resources-lede") }}
+ {# #1065: results may miss stored resources while the search index rebuilds. #}
+ {% if let Some(activity) = rebuild %}
+