From a65cb21c6c62426228bf70e003657b60fb025aa9 Mon Sep 17 00:00:00 2001 From: Hwuiwon Kim Date: Fri, 10 Jul 2026 12:41:44 -0400 Subject: [PATCH 1/4] audit 1 --- Cargo.lock | 3 + crates/moa-analytics/Cargo.toml | 1 + crates/moa-analytics/src/clickhouse_exec.rs | 45 +- crates/moa-analytics/src/compiler.rs | 13 +- crates/moa-analytics/src/error.rs | 14 + crates/moa-analytics/src/executor.rs | 33 +- crates/moa-analytics/src/query.rs | 216 +++++- .../tests/clickhouse_executor_offline.rs | 13 +- .../tests/executor_statement_timeout_db.rs | 102 +++ crates/moa-auth/auth0/src/auth0_provider.rs | 23 +- crates/moa-auth/auth0/src/jwks_cache.rs | 484 +++++++++++-- crates/moa-auth/auth0/src/oidc_provider.rs | 8 +- crates/moa-auth/authz-schema/src/tuple.rs | 44 -- crates/moa-auth/authz/Cargo.toml | 1 + crates/moa-auth/authz/src/outbox.rs | 65 +- crates/moa-auth/authz/src/poller.rs | 75 +- .../authz/tests/authz_db/authz_poller_db.rs | 7 +- .../authz/tests/authz_db/outbox_basic_db.rs | 328 +++++++-- crates/moa-auth/providers/src/api_keys.rs | 7 +- .../moa-auth/providers/src/user_sessions.rs | 7 +- crates/moa-brain/src/pipeline/memory.rs | 96 ++- .../src/pipeline/memory/rendering.rs | 33 +- crates/moa-brain/src/retrieval/enrichment.rs | 459 +++++++++++++ crates/moa-brain/src/retrieval/hybrid.rs | 522 +++++++++++--- crates/moa-brain/src/retrieval/mod.rs | 1 + crates/moa-brain/src/retrieval/types.rs | 3 - crates/moa-core/src/config/analytics.rs | 107 +++ crates/moa-core/src/config/mod.rs | 5 + crates/moa-core/src/events.rs | 194 ++++++ crates/moa-core/src/lib.rs | 2 +- crates/moa-core/src/session_engine.rs | 152 +++- crates/moa-core/src/wire/privacy.rs | 24 + crates/moa-edge/src/main.rs | 10 +- crates/moa-edge/src/routes/analytics.rs | 76 +- crates/moa-edge/src/routes/auth_accounts.rs | 32 +- crates/moa-edge/src/routes/tenant_accounts.rs | 19 +- .../moa-edge/tests/direct_read_routes_db.rs | 10 + .../core/src/evaluators/output_match.rs | 159 ++++- crates/moa-eval/core/src/types.rs | 17 +- .../moa-eval/src/long_conversation/budgets.rs | 24 +- .../src/long_conversation/score_card.rs | 47 +- .../long_conversation/transcript_runner.rs | 211 +++++- .../long_conversation_foundation_eval.rs | 10 +- crates/moa-experiments/src/app.rs | 30 +- crates/moa-experiments/src/store.rs | 83 +++ .../tests/experiment_store_db.rs | 221 ++++++ crates/moa-knowledge/src/ingestion.rs | 51 +- crates/moa-knowledge/src/repository.rs | 35 + .../ingestion_pipeline_db_memory.rs | 289 +++++++- crates/moa-lineage/sink/src/fjall_journal.rs | 19 + crates/moa-lineage/sink/src/writer.rs | 75 +- crates/moa-lineage/sink/tests/writer_db.rs | 114 ++- crates/moa-loadtest/src/merge.rs | 174 +++++ crates/moa-memory/graph/src/store.rs | 17 +- crates/moa-memory/graph/src/write.rs | 21 - .../write_protocol_db_memory.rs | 58 +- crates/moa-memory/ingest/src/fast_path.rs | 3 +- .../ingest/tests/fast_path_db_memory.rs | 3 +- .../moa-memory/lifecycle/src/consolidate.rs | 4 +- crates/moa-memory/lifecycle/src/digest.rs | 74 ++ .../digest_postgres_db_memory.rs | 72 ++ crates/moa-memory/pii/src/erasure.rs | 78 ++- .../moa-memory/pii/tests/erasure_db_memory.rs | 198 +++++- crates/moa-memory/vector/src/backend.rs | 76 +- crates/moa-memory/vector/src/lib.rs | 64 +- crates/moa-memory/vector/src/sync.rs | 84 ++- .../vector_sync_outbox_db_memory.rs | 248 +++++++ crates/moa-messaging/src/slack.rs | 649 ++++++++++++++---- .../postgres/V000101__auth_baseline.sql | 21 +- .../V000322__workspace_authz_backfill.sql | 28 +- .../V000328__privacy_erasure_jobs.sql | 51 ++ ...000329__vector_sync_outbox_dead_letter.sql | 26 + .../V000330__analytics_mv_refresh_state.sql | 28 + crates/moa-migrations/src/lib.rs | 12 + crates/moa-ocsf/Cargo.toml | 1 + crates/moa-ocsf/src/audit_sink.rs | 13 + crates/moa-orchestrator/src/runtime/jobs.rs | 325 ++++++++- .../src/services/authz_admin.rs | 87 +-- .../moa-orchestrator/src/services/contacts.rs | 159 +++-- .../src/services/experiments.rs | 20 +- .../src/services/graph_memory_maint.rs | 56 ++ .../src/services/knowledge/ingest.rs | 4 +- .../src/services/privacy/erase.rs | 266 +++++-- .../src/services/privacy/repository.rs | 291 +++++++- .../src/services/session_store/handlers.rs | 21 + .../src/services/session_store/inner.rs | 222 +++++- .../src/services/session_store/mod.rs | 5 + .../src/services/session_store/tests.rs | 294 +++++++- .../src/workflows/experiment_cancel.rs | 194 ++++++ .../src/workflows/experiment_run.rs | 85 +++ .../src/workflows/experiment_trial_run.rs | 18 + crates/moa-orchestrator/src/workflows/mod.rs | 1 + .../tests/knowledge_service.rs | 25 + .../tests/orchestrator_db/authz_admin_db.rs | 85 ++- .../orchestrator_db/workspace_authz_db.rs | 46 +- .../privacy_service_db_memory.rs | 174 ++++- crates/moa-session/src/lib.rs | 4 +- crates/moa-session/src/store/mod.rs | 115 +++- crates/moa-session/src/store/session_store.rs | 251 ++++++- crates/moa-session/tests/postgres_store_db.rs | 92 +++ crates/xtask/src/check_eval_budgets.rs | 327 +++++++-- crates/xtask/src/wixqa_rag_eval.rs | 3 +- 102 files changed, 8591 insertions(+), 1201 deletions(-) create mode 100644 crates/moa-analytics/tests/executor_statement_timeout_db.rs create mode 100644 crates/moa-brain/src/retrieval/enrichment.rs create mode 100644 crates/moa-core/src/config/analytics.rs create mode 100644 crates/moa-migrations/migrations/postgres/V000328__privacy_erasure_jobs.sql create mode 100644 crates/moa-migrations/migrations/postgres/V000329__vector_sync_outbox_dead_letter.sql create mode 100644 crates/moa-migrations/migrations/postgres/V000330__analytics_mv_refresh_state.sql create mode 100644 crates/moa-orchestrator/src/workflows/experiment_cancel.rs diff --git a/Cargo.lock b/Cargo.lock index e608ff76..35b5cb30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3320,6 +3320,7 @@ dependencies = [ "insta", "moa-core", "moa-db", + "moa-test-support", "serde_json", "sqlx", "thiserror 2.0.18", @@ -3414,6 +3415,7 @@ version = "0.1.0" dependencies = [ "async-trait", "httpmock", + "metrics", "moa-authz-schema", "moa-core", "moa-migrations", @@ -4117,6 +4119,7 @@ dependencies = [ "constant_time_eq 0.3.1", "hex", "hmac", + "metrics", "moa-core", "moa-migrations", "moka", diff --git a/crates/moa-analytics/Cargo.toml b/crates/moa-analytics/Cargo.toml index e68b7755..6f463076 100644 --- a/crates/moa-analytics/Cargo.toml +++ b/crates/moa-analytics/Cargo.toml @@ -19,4 +19,5 @@ workspace-hack.workspace = true [dev-dependencies] clickhouse = { workspace = true, features = ["test-util"] } insta.workspace = true +moa-test-support.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/moa-analytics/src/clickhouse_exec.rs b/crates/moa-analytics/src/clickhouse_exec.rs index 48acc4c9..0e1290a2 100644 --- a/crates/moa-analytics/src/clickhouse_exec.rs +++ b/crates/moa-analytics/src/clickhouse_exec.rs @@ -34,8 +34,17 @@ fn is_unknown_table(error: &clickhouse::error::Error) -> bool { #[derive(Clone)] pub struct AnalyticsClickHouseClient { client: Client, + max_execution_time_secs: u64, + max_rows_to_read: u64, + max_bytes_to_read: u64, } +/// Default ClickHouse per-query budgets applied when the caller does not +/// override them from config. Mirror [`moa_core::config::AnalyticsConfig`]. +const DEFAULT_MAX_EXECUTION_TIME_SECS: u64 = 10; +const DEFAULT_MAX_ROWS_TO_READ: u64 = 1_000_000_000; +const DEFAULT_MAX_BYTES_TO_READ: u64 = 10_000_000_000; + impl std::fmt::Debug for AnalyticsClickHouseClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AnalyticsClickHouseClient") @@ -56,13 +65,33 @@ impl AnalyticsClickHouseClient { if let Some(password) = config.password.as_deref() { client = client.with_password(password); } - Self { client } + Self::from_client(client) } /// Wraps an existing client, used by tests to point at a mock server. #[must_use] pub fn from_client(client: Client) -> Self { - Self { client } + Self { + client, + max_execution_time_secs: DEFAULT_MAX_EXECUTION_TIME_SECS, + max_rows_to_read: DEFAULT_MAX_ROWS_TO_READ, + max_bytes_to_read: DEFAULT_MAX_BYTES_TO_READ, + } + } + + /// Overrides the per-query ClickHouse budgets (`max_execution_time`, + /// `max_rows_to_read`, `max_bytes_to_read`) so a runaway read is bounded. + #[must_use] + pub fn with_query_budgets( + mut self, + max_execution_time_secs: u64, + max_rows_to_read: u64, + max_bytes_to_read: u64, + ) -> Self { + self.max_execution_time_secs = max_execution_time_secs; + self.max_rows_to_read = max_rows_to_read; + self.max_bytes_to_read = max_bytes_to_read; + self } /// Deletes a tenant's rows from every analytics table during offboarding. @@ -107,7 +136,17 @@ impl AnalyticsClickHouseClient { &self, compiled: &CompiledAnalyticsQuery, ) -> Result>> { - let mut query = self.client.query(&compiled.sql); + // Bound the read: a runaway scan is cancelled by ClickHouse instead of + // reading a tenant's full history to satisfy an exact ordered percentile. + let mut query = self + .client + .query(&compiled.sql) + .with_option( + "max_execution_time", + self.max_execution_time_secs.to_string(), + ) + .with_option("max_rows_to_read", self.max_rows_to_read.to_string()) + .with_option("max_bytes_to_read", self.max_bytes_to_read.to_string()); for bind in &compiled.bind_values { query = match bind { AnalyticsBindValue::String(value) => query.bind(value), diff --git a/crates/moa-analytics/src/compiler.rs b/crates/moa-analytics/src/compiler.rs index 59ad99fb..b38f70cf 100644 --- a/crates/moa-analytics/src/compiler.rs +++ b/crates/moa-analytics/src/compiler.rs @@ -729,7 +729,15 @@ mod tests { aggregation: AnalyticsAggregation::P95, alias: Some("p95_cost".to_string()), }], - filters: Vec::new(), + // Time-series datasets require a bounded window; a recent lower bound + // keeps the shared fixture valid against the wall clock. + filters: vec![AnalyticsFilter { + field: "finished_at".to_string(), + operator: AnalyticsFilterOperator::Gte, + value: Some(AnalyticsCell::String( + (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(), + )), + }], order_by: vec![AnalyticsOrderBy { field: "p95_cost".to_string(), direction: AnalyticsSortDirection::Desc, @@ -748,7 +756,8 @@ mod tests { assert!(compiled.sql.contains("d.tenant_id = $1::UUID")); assert!(compiled.sql.contains("PERCENTILE_CONT(0.95)")); assert!(compiled.sql.contains("ORDER BY c1 DESC")); - assert_eq!(compiled.bind_values.len(), 1); + // Tenant scope bind plus the required time-window lower bound. + assert_eq!(compiled.bind_values.len(), 2); assert_eq!(compiled.limit, 25); } diff --git a/crates/moa-analytics/src/error.rs b/crates/moa-analytics/src/error.rs index 96586621..3844ff66 100644 --- a/crates/moa-analytics/src/error.rs +++ b/crates/moa-analytics/src/error.rs @@ -120,6 +120,20 @@ pub enum AnalyticsError { /// Maximum allowed time window in whole days. max_days: i64, }, + /// A time-series dataset was queried without a bounded time window. + #[error( + "analytics dataset `{dataset}` requires a bounded time filter on `{time_field}` \ + (a two-sided `between` within {max_days} days, or a lower bound `gte`/`gt`/`eq` \ + no older than {max_days} days)" + )] + MissingTimeWindow { + /// Dataset that requires a bounded time window. + dataset: String, + /// Designated time field that must be bounded. + time_field: String, + /// Maximum allowed time window in whole days. + max_days: i64, + }, /// A query tried to order by a field or alias that is not selected. #[error("order field `{field}` is not selected by the query")] UnknownOrderField { diff --git a/crates/moa-analytics/src/executor.rs b/crates/moa-analytics/src/executor.rs index 20a5e955..38569b5d 100644 --- a/crates/moa-analytics/src/executor.rs +++ b/crates/moa-analytics/src/executor.rs @@ -21,9 +21,23 @@ use crate::error::{AnalyticsError, Result}; /// [`AnalyticsService::clickhouse`] compiles and executes against the ClickHouse /// read models. The edge selects the constructor by `[clickhouse]` presence and /// calls the matching `query` / `query_clickhouse` entrypoint. -#[derive(Debug, Clone, Default)] +/// Default Postgres `statement_timeout` applied to each analytics query when +/// the caller does not override it from config. +pub const DEFAULT_STATEMENT_TIMEOUT_MS: u64 = 10_000; + +#[derive(Debug, Clone)] pub struct AnalyticsService { compiler: AnalyticsCompiler, + statement_timeout_ms: u64, +} + +impl Default for AnalyticsService { + fn default() -> Self { + Self { + compiler: AnalyticsCompiler::default(), + statement_timeout_ms: DEFAULT_STATEMENT_TIMEOUT_MS, + } + } } impl AnalyticsService { @@ -39,9 +53,18 @@ impl AnalyticsService { analytics_catalog(), AnalyticsBackend::ClickHouse, ), + statement_timeout_ms: DEFAULT_STATEMENT_TIMEOUT_MS, } } + /// Overrides the Postgres `statement_timeout` applied to each query, in + /// milliseconds, so a runaway scan is cancelled server-side. + #[must_use] + pub fn with_statement_timeout_ms(mut self, statement_timeout_ms: u64) -> Self { + self.statement_timeout_ms = statement_timeout_ms; + self + } + /// Returns the backend this service compiles and executes against. pub fn backend(&self) -> AnalyticsBackend { self.compiler.backend() @@ -67,6 +90,14 @@ impl AnalyticsService { let mut conn = ScopedConn::begin_tenant(pool, compiled.effective_tenant_id) .await .map_err(|error| AnalyticsError::Execution(error.to_string()))?; + // Bound the database work: an unbounded ordered-percentile scan is + // cancelled server-side rather than holding a connection open. `SET LOCAL` + // scopes the timeout to this transaction only. + sqlx::query("SELECT set_config('statement_timeout', $1, true)") + .bind(self.statement_timeout_ms.to_string()) + .execute(conn.as_mut()) + .await + .map_err(|error| AnalyticsError::Execution(error.to_string()))?; let rows = execute_compiled(&compiled, conn.as_mut()).await?; conn.commit() .await diff --git a/crates/moa-analytics/src/query.rs b/crates/moa-analytics/src/query.rs index dafb9f23..ba1c0030 100644 --- a/crates/moa-analytics/src/query.rs +++ b/crates/moa-analytics/src/query.rs @@ -3,8 +3,9 @@ use chrono::{DateTime, Utc}; use moa_core::TenantId; use moa_core::wire::analytics::{ - AnalyticsAggregation, AnalyticsCatalogResponse, AnalyticsCell, AnalyticsField, - AnalyticsFieldKind, AnalyticsFieldRole, AnalyticsFilterOperator, AnalyticsQueryRequest, + AnalyticsAggregation, AnalyticsCatalogResponse, AnalyticsCell, AnalyticsDataset, + AnalyticsField, AnalyticsFieldKind, AnalyticsFieldRole, AnalyticsFilter, + AnalyticsFilterOperator, AnalyticsQueryRequest, }; use crate::catalog::find_dataset; @@ -38,6 +39,16 @@ pub struct ValidatedAnalyticsQuery { pub fn validate_query( catalog: &AnalyticsCatalogResponse, request: AnalyticsQueryRequest, +) -> Result { + validate_query_at(catalog, request, Utc::now()) +} + +/// Validates a query against a caller-supplied `now`, used by tests to pin the +/// time-window bound against a fixed clock. +pub fn validate_query_at( + catalog: &AnalyticsCatalogResponse, + request: AnalyticsQueryRequest, + now: DateTime, ) -> Result { let dataset = find_dataset(catalog, &request.dataset).ok_or_else(|| AnalyticsError::UnknownDataset { @@ -118,6 +129,8 @@ pub fn validate_query( validate_time_window(field, filter.operator, filter.value.as_ref())?; } + enforce_required_time_window(dataset, &request.filters, now)?; + for order in &request.order_by { if !selected_field_or_alias(&request, &order.field) { return Err(AnalyticsError::UnknownOrderField { @@ -264,6 +277,74 @@ fn validate_time_window( Ok(()) } +/// Requires a time-series dataset to carry a filter that bounds its scan to a +/// window no wider than [`MAX_TIME_WINDOW_DAYS`]. +/// +/// A dataset opts out of the requirement by declaring no `default_time_field`. +/// Otherwise at least one filter on that field must close the window: a +/// two-sided `between` (span already checked by [`validate_time_window`]), an +/// `eq` point, or a lower bound (`gte`/`gt`) whose start is no older than the +/// limit. An upper bound alone (`lt`/`lte`) leaves history unbounded and does +/// not satisfy the requirement. +fn enforce_required_time_window( + dataset: &AnalyticsDataset, + filters: &[AnalyticsFilter], + now: DateTime, +) -> Result<()> { + let Some(time_field) = dataset.default_time_field.as_deref() else { + return Ok(()); + }; + + let mut bounded = false; + for filter in filters { + if filter.field != time_field { + continue; + } + match filter.operator { + AnalyticsFilterOperator::Between | AnalyticsFilterOperator::Eq => { + bounded = true; + } + AnalyticsFilterOperator::Gte | AnalyticsFilterOperator::Gt => { + let start = lower_bound_timestamp(time_field, filter.value.as_ref())?; + let days = now.signed_duration_since(start).num_days(); + if days > MAX_TIME_WINDOW_DAYS { + return Err(AnalyticsError::TimeWindowTooLarge { + days, + max_days: MAX_TIME_WINDOW_DAYS, + }); + } + bounded = true; + } + _ => {} + } + } + + if bounded { + Ok(()) + } else { + Err(AnalyticsError::MissingTimeWindow { + dataset: dataset.id.clone(), + time_field: time_field.to_string(), + max_days: MAX_TIME_WINDOW_DAYS, + }) + } +} + +fn lower_bound_timestamp(field: &str, value: Option<&AnalyticsCell>) -> Result> { + let raw = value + .and_then(cell_as_string) + .ok_or_else(|| AnalyticsError::InvalidFilterValue { + field: field.to_string(), + reason: "timestamp lower bound must be an RFC3339 string".to_string(), + })?; + DateTime::parse_from_rfc3339(raw) + .map(|timestamp| timestamp.with_timezone(&Utc)) + .map_err(|error| AnalyticsError::InvalidFilterValue { + field: field.to_string(), + reason: error.to_string(), + }) +} + fn parse_timestamp_json(field: &str, value: &serde_json::Value) -> Result> { let Some(value) = value.as_str() else { return Err(AnalyticsError::InvalidFilterValue { @@ -335,3 +416,134 @@ fn role_name(role: AnalyticsFieldRole) -> &'static str { AnalyticsFieldRole::FilterOnly => "filter", } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::analytics_catalog; + use chrono::TimeZone; + use moa_core::wire::analytics::{AnalyticsDimension, AnalyticsMeasure}; + + fn fixed_now() -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 10, 12, 0, 0) + .single() + .expect("now") + } + + fn sessions_request(filters: Vec) -> AnalyticsQueryRequest { + AnalyticsQueryRequest { + dataset: "sessions".to_string(), + tenant_id: Some(TenantId::new()), + dimensions: vec![AnalyticsDimension { + field: "channel".to_string(), + alias: None, + }], + measures: vec![AnalyticsMeasure { + field: None, + aggregation: AnalyticsAggregation::Count, + alias: None, + }], + filters, + order_by: Vec::new(), + limit: Some(10), + } + } + + fn created_at(op: AnalyticsFilterOperator, value: &str) -> AnalyticsFilter { + AnalyticsFilter { + field: "created_at".to_string(), + operator: op, + value: Some(AnalyticsCell::String(value.to_string())), + } + } + + #[test] + fn rejects_query_without_time_window() { + // Pins: a time-series dataset queried with no time filter is rejected so it + // cannot scan a tenant's full event history. + let error = validate_query_at( + &analytics_catalog(), + sessions_request(Vec::new()), + fixed_now(), + ) + .expect_err("unbounded query must be rejected"); + assert!(matches!(error, AnalyticsError::MissingTimeWindow { .. })); + } + + #[test] + fn rejects_upper_bound_only_time_window() { + // Pins: an upper bound alone (`lt`/`lte`) leaves history unbounded. + let filters = vec![created_at( + AnalyticsFilterOperator::Lt, + "2026-07-01T00:00:00Z", + )]; + let error = validate_query_at(&analytics_catalog(), sessions_request(filters), fixed_now()) + .expect_err("upper-bound-only must be rejected"); + assert!(matches!(error, AnalyticsError::MissingTimeWindow { .. })); + } + + #[test] + fn rejects_lower_bound_older_than_max_window() { + // Pins: a lower bound older than the limit is a too-wide window, not a pass. + let filters = vec![created_at( + AnalyticsFilterOperator::Gte, + "2024-01-01T00:00:00Z", + )]; + let error = validate_query_at(&analytics_catalog(), sessions_request(filters), fixed_now()) + .expect_err("too-wide lower bound must be rejected"); + assert!(matches!(error, AnalyticsError::TimeWindowTooLarge { .. })); + } + + #[test] + fn accepts_recent_lower_bound() { + let filters = vec![created_at( + AnalyticsFilterOperator::Gte, + "2026-06-10T00:00:00Z", + )]; + validate_query_at(&analytics_catalog(), sessions_request(filters), fixed_now()) + .expect("a recent lower bound is accepted"); + } + + #[test] + fn accepts_between_within_window() { + let between = AnalyticsFilter { + field: "created_at".to_string(), + operator: AnalyticsFilterOperator::Between, + value: Some(AnalyticsCell::Json(serde_json::json!([ + "2026-06-01T00:00:00Z", + "2026-07-01T00:00:00Z" + ]))), + }; + validate_query_at( + &analytics_catalog(), + sessions_request(vec![between]), + fixed_now(), + ) + .expect("a bounded between is accepted"); + } + + #[test] + fn accepts_eq_point_bound() { + let filters = vec![created_at( + AnalyticsFilterOperator::Eq, + "2026-06-10T00:00:00Z", + )]; + validate_query_at(&analytics_catalog(), sessions_request(filters), fixed_now()) + .expect("an eq point bound is accepted"); + } + + #[test] + fn dataset_without_time_field_is_exempt() { + // Pins: a dataset that declares no default_time_field opts out of the window + // requirement instead of being unqueryable. + let dataset = AnalyticsDataset { + id: "catalog_meta".to_string(), + label: "Meta".to_string(), + description: String::new(), + default_time_field: None, + fields: Vec::new(), + }; + enforce_required_time_window(&dataset, &[], fixed_now()) + .expect("a dataset without a time field is exempt"); + } +} diff --git a/crates/moa-analytics/tests/clickhouse_executor_offline.rs b/crates/moa-analytics/tests/clickhouse_executor_offline.rs index d76078e1..cf916d66 100644 --- a/crates/moa-analytics/tests/clickhouse_executor_offline.rs +++ b/crates/moa-analytics/tests/clickhouse_executor_offline.rs @@ -12,7 +12,8 @@ use clickhouse::test::{Mock, handlers}; use moa_analytics::{AnalyticsClickHouseClient, AnalyticsService}; use moa_core::TenantId; use moa_core::wire::analytics::{ - AnalyticsAggregation, AnalyticsDimension, AnalyticsMeasure, AnalyticsQueryRequest, + AnalyticsAggregation, AnalyticsCell, AnalyticsDimension, AnalyticsFilter, + AnalyticsFilterOperator, AnalyticsMeasure, AnalyticsQueryRequest, }; #[tokio::test] @@ -38,7 +39,15 @@ async fn clickhouse_executor_returns_empty_result_and_metadata_offline() { aggregation: AnalyticsAggregation::P95, alias: None, }], - filters: Vec::new(), + // Time-series datasets require a bounded window; a recent lower bound + // satisfies the validator against the wall clock. + filters: vec![AnalyticsFilter { + field: "created_at".to_string(), + operator: AnalyticsFilterOperator::Gte, + value: Some(AnalyticsCell::String( + (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(), + )), + }], order_by: Vec::new(), limit: Some(10), }; diff --git a/crates/moa-analytics/tests/executor_statement_timeout_db.rs b/crates/moa-analytics/tests/executor_statement_timeout_db.rs new file mode 100644 index 00000000..ea193082 --- /dev/null +++ b/crates/moa-analytics/tests/executor_statement_timeout_db.rs @@ -0,0 +1,102 @@ +//! DB coverage for the analytics executor's per-query Postgres budget. + +use moa_analytics::AnalyticsService; +use moa_core::TenantId; +use moa_core::wire::analytics::{ + AnalyticsCell, AnalyticsDimension, AnalyticsFilter, AnalyticsFilterOperator, AnalyticsMeasure, + AnalyticsQueryRequest, +}; +use moa_test_support::postgres::{TestDb, bootstrap_test_db}; + +async fn configured_test_db() -> Option { + std::env::var_os("MOA_DATABASE_URL")?; + Some( + bootstrap_test_db() + .await + .expect("bootstrap Postgres test database"), + ) +} + +#[tokio::test] +#[ignore = "requires MOA_DATABASE_URL and a reachable Postgres instance"] +async fn analytics_query_applies_statement_timeout_and_runs_db() { + // Pins: the Postgres analytics executor runs each query inside a tenant-scoped + // transaction with a bounded statement_timeout set, and the query path + // succeeds against the empty read models. + let Some(test_db) = configured_test_db().await else { + return; + }; + let tenant = TenantId::new(); + let request = AnalyticsQueryRequest { + dataset: "sessions".to_string(), + tenant_id: Some(tenant), + dimensions: vec![AnalyticsDimension { + field: "channel".to_string(), + alias: None, + }], + measures: vec![AnalyticsMeasure { + field: None, + aggregation: moa_core::wire::analytics::AnalyticsAggregation::Count, + alias: None, + }], + filters: vec![AnalyticsFilter { + field: "created_at".to_string(), + operator: AnalyticsFilterOperator::Gte, + value: Some(AnalyticsCell::String( + (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(), + )), + }], + order_by: Vec::new(), + limit: Some(10), + }; + + let response = AnalyticsService::new() + .with_statement_timeout_ms(5_000) + .query(test_db.store().pool(), request) + .await + .expect("analytics query runs under the statement-timeout budget"); + assert_eq!(response.metadata.row_count, 0, "empty read model"); +} + +#[tokio::test] +#[ignore = "requires MOA_DATABASE_URL and a reachable Postgres instance"] +async fn statement_timeout_cancels_a_slow_query_db() { + // Pins: the per-transaction statement_timeout the executor sets actually + // cancels a runaway query server-side (SQLSTATE 57014), so an unbounded + // ordered-percentile scan cannot hold a connection indefinitely. + let Some(test_db) = configured_test_db().await else { + return; + }; + let mut tx = test_db + .store() + .pool() + .begin() + .await + .expect("begin transaction"); + // Same mechanism as executor.rs: SET LOCAL statement_timeout for this tx. + sqlx::query("SELECT set_config('statement_timeout', $1, true)") + .bind("100") + .execute(tx.as_mut()) + .await + .expect("set statement timeout"); + + let error = sqlx::query("SELECT pg_sleep(3)") + .execute(tx.as_mut()) + .await + .expect_err("a query longer than the timeout must be cancelled"); + let cancelled = error + .as_database_error() + .and_then(|db_error| db_error.code().map(|code| code.into_owned())) + .as_deref() + == Some("57014") + || error + .to_string() + .to_lowercase() + .contains("statement timeout"); + assert!( + cancelled, + "expected a statement-timeout cancellation, got: {error}" + ); + + let _ = tx.rollback().await; +} diff --git a/crates/moa-auth/auth0/src/auth0_provider.rs b/crates/moa-auth/auth0/src/auth0_provider.rs index 907e14a4..319b98aa 100644 --- a/crates/moa-auth/auth0/src/auth0_provider.rs +++ b/crates/moa-auth/auth0/src/auth0_provider.rs @@ -4,7 +4,7 @@ //! The provider maps the external `sub` claim to MOA's internal UUID through //! the `auth0_user_map` table, creating a local row on first login. -use crate::jwks_cache::JwksCache; +use crate::jwks_cache::{JwksCache, JwksError}; use async_trait::async_trait; use jsonwebtoken::{Algorithm, Validation, decode, decode_header}; use moa_core::TenantId; @@ -95,11 +95,7 @@ impl AuthProvider for Auth0AuthProvider { return Err(AuthError::Rejected); } let kid = header.kid.ok_or(AuthError::InvalidFormat)?; - let key = self - .jwks - .key_for(&kid) - .await - .map_err(|error| AuthError::Unavailable(format!("jwks: {error}")))?; + let key = self.jwks.key_for(&kid).await.map_err(map_jwks_error)?; let mut validation = Validation::new(Algorithm::RS256); validation.set_issuer(&[&self.issuer]); @@ -229,6 +225,21 @@ async fn provision_static( Ok(resolved_id) } +/// Maps a JWKS key-resolution failure to an authentication outcome. +/// +/// A `kid` a successful refresh proved the provider does not publish is an +/// invalid credential ([`AuthError::Rejected`]); an unreachable or unparseable +/// JWKS endpoint is a provider-availability failure ([`AuthError::Unavailable`]) +/// so callers do not treat a transient outage as a bad token. +pub(crate) fn map_jwks_error(error: JwksError) -> AuthError { + match error { + JwksError::UnknownKid(_) => AuthError::Rejected, + JwksError::Http(_) | JwksError::Parse(_) => { + AuthError::Unavailable(format!("jwks: {error}")) + } + } +} + pub(crate) fn parse_identity_type(value: Option<&str>) -> Result { match value.unwrap_or("operator") { "operator" => Ok(IdentityType::Operator), diff --git a/crates/moa-auth/auth0/src/jwks_cache.rs b/crates/moa-auth/auth0/src/jwks_cache.rs index 932b41eb..33f6845c 100644 --- a/crates/moa-auth/auth0/src/jwks_cache.rs +++ b/crates/moa-auth/auth0/src/jwks_cache.rs @@ -1,74 +1,267 @@ //! In-memory JWKS cache for JWT signature validation. //! -//! The cache refreshes on `kid` miss and after its configured TTL. It stores -//! only public decoding keys and is safe to clone across request handlers. +//! The cache serves known `kid`s from a last-known-good key set and refreshes +//! that set — never discarding it — when a `kid` is unknown or its keys age past +//! the configured TTL. It stores only public decoding keys and is safe to clone +//! across request handlers. +//! +//! Because the JWT header (and therefore its `kid`) is attacker-controllable +//! before signature validation, refreshes are hardened against amplification: +//! +//! * A miss never evicts the last-known-good set; the set is replaced only when +//! a refresh succeeds, so known keys keep validating during a provider outage. +//! * A single-flight refresh gate collapses concurrent misses into one fetch and +//! enforces a cooldown, so a flood of distinct random `kid`s cannot drive one +//! outbound JWKS request each. +//! * Unknown `kid`s confirmed absent by a successful refresh are negative-cached +//! for a short, bounded window, so repeated probes of the same `kid` do not +//! refetch. +//! * The JWKS HTTP client carries tight connect/request/body-size budgets. use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use jsonwebtoken::{DecodingKey, jwk::JwkSet}; use moka::future::Cache; use thiserror::Error; +use tokio::sync::{Mutex, RwLock}; /// Decoding keys from one JWKS fetch, cached as a single unit so that a `kid` /// rotation refreshes every key together rather than per entry. type KeySet = Arc>; +/// Connect-phase budget for one JWKS fetch. +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +/// Total wall-clock budget for one JWKS fetch (connect, send, and read). +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +/// Maximum accepted JWKS response body. Real documents are a few kilobytes; this +/// bounds memory against a misbehaving or compromised provider endpoint. +const MAX_JWKS_BODY_BYTES: u64 = 1024 * 1024; +/// Minimum interval between JWKS refresh attempts. Rate-limits refreshes so that +/// a burst of distinct unknown `kid`s cannot amplify into one fetch each. +const DEFAULT_REFRESH_COOLDOWN: Duration = Duration::from_secs(10); +/// How long a `kid` confirmed absent by a successful refresh is remembered as +/// unknown so repeated probes are rejected without refetching. +const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(60); +/// Bound on the negative cache; the keyspace is attacker-controlled. +const DEFAULT_NEGATIVE_CAPACITY: u64 = 1024; + +/// Mutable last-known-good state guarded by [`JwksCache::state`]. +#[derive(Default)] +struct KeyState { + /// Most recent successfully fetched key set (empty before the first fetch). + keys: KeySet, + /// When [`Self::keys`] was last successfully refreshed; drives TTL freshness. + refreshed_at: Option, + /// When a refresh was last attempted (success or failure); drives the + /// cooldown so a failing provider is not hammered. + last_attempt: Option, +} + +impl KeyState { + /// Whether the current key set is within its freshness TTL. + fn is_fresh(&self, ttl: Duration) -> bool { + self.refreshed_at + .is_some_and(|refreshed| refreshed.elapsed() < ttl) + } + + /// Whether a refresh was attempted within the cooldown window. + fn within_cooldown(&self, cooldown: Duration) -> bool { + self.last_attempt + .is_some_and(|attempt| attempt.elapsed() < cooldown) + } +} + /// Thread-safe cache of public JWT decoding keys fetched from one JWKS URL. #[derive(Clone)] pub struct JwksCache { - keys: Cache<(), KeySet>, + inner: Arc, +} + +struct Inner { jwks_url: String, http: reqwest::Client, + /// Last-known-good key set and refresh bookkeeping. + state: RwLock, + /// Single-flight refresh gate: held across the miss re-check, cooldown + /// decision, fetch, and replace so concurrent misses collapse into one + /// fetch and observe each other's cooldown. + refresh: Mutex<()>, + /// Bounded, short-TTL memory of `kid`s a successful refresh proved absent. + negative: Cache, + ttl: Duration, + cooldown: Duration, } impl JwksCache { - /// Create a JWKS cache for `jwks_url` with entries valid for `ttl`. + /// Create a JWKS cache for `jwks_url` whose keys are considered fresh for + /// `ttl`, with default refresh cooldown and negative-cache policy. #[must_use] pub fn new(jwks_url: impl Into, ttl: Duration) -> Self { + Self::with_policy( + jwks_url, + ttl, + DEFAULT_REFRESH_COOLDOWN, + DEFAULT_NEGATIVE_TTL, + DEFAULT_NEGATIVE_CAPACITY, + ) + } + + /// Create a JWKS cache with explicit refresh cooldown and negative-cache + /// policy. Exposed for tests that must exercise cooldown and negative-TTL + /// windows without real-time waits. + #[must_use] + fn with_policy( + jwks_url: impl Into, + ttl: Duration, + cooldown: Duration, + negative_ttl: Duration, + negative_capacity: u64, + ) -> Self { + // Tight budgets: an unverified attacker-supplied `kid` can drive a fetch, + // so a slow or hostile endpoint must not tie up an auth request. + let http = reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|error| { + tracing::warn!(?error, "falling back to default JWKS HTTP client"); + reqwest::Client::new() + }); Self { - keys: Cache::builder().time_to_live(ttl).build(), - jwks_url: jwks_url.into(), - http: reqwest::Client::new(), + inner: Arc::new(Inner { + jwks_url: jwks_url.into(), + http, + state: RwLock::new(KeyState::default()), + refresh: Mutex::new(()), + negative: Cache::builder() + .max_capacity(negative_capacity) + .time_to_live(negative_ttl) + .build(), + ttl, + cooldown, + }), } } - /// Return the decoding key for `kid`, refreshing the cache when needed. + /// Return the decoding key for `kid`. + /// + /// Serves a fresh known `kid` from cache without any network call. Otherwise + /// takes the single-flight refresh path, which — subject to the cooldown — + /// refreshes the key set and either resolves `kid`, serves a still-valid + /// last-known-good key during a provider outage, negative-caches a confirmed + /// unknown `kid` ([`JwksError::UnknownKid`]), or surfaces the provider + /// failure ([`JwksError::Http`]/[`JwksError::Parse`]) without discarding + /// valid keys. pub async fn key_for(&self, kid: &str) -> Result { - // Fast path: an unexpired key set that already contains `kid`. - if let Some(keys) = self.keys.get(&()).await - && let Some(key) = keys.get(kid) - { - return Ok(key.clone()); + let inner = self.inner.as_ref(); + + // Fast path: a known `kid` whose key set is still within its TTL. + if let Some(key) = inner.fresh_known_key(kid).await { + return Ok(key); + } + // A `kid` a recent successful refresh proved absent: reject without a + // fetch. Checked before taking the refresh gate so a repeated probe is + // cheap. + if inner.negative.get(kid).await.is_some() { + return Err(JwksError::UnknownKid(kid.to_string())); } - // Slow path: cold cache, expired TTL, or a `kid` that may have rotated - // in. Drop any stale set and refetch the whole JWKS (coalescing - // concurrent refreshes) before looking `kid` up again. - self.keys.invalidate(&()).await; - self.keys - .try_get_with((), self.fetch_keys()) - .await - .map_err(|error: Arc| (*error).clone())? - .get(kid) - .cloned() - .ok_or_else(|| JwksError::UnknownKid(kid.to_string())) + // Slow path: single-flight. Concurrent misses queue here so at most one + // fetch runs; each re-checks the state the winner just published. + let _guard = inner.refresh.lock().await; + + if let Some(key) = inner.fresh_known_key(kid).await { + return Ok(key); + } + if inner.negative.get(kid).await.is_some() { + return Err(JwksError::UnknownKid(kid.to_string())); + } + + if inner.state.read().await.within_cooldown(inner.cooldown) { + // Rate-limited: no fetch. Serve a stale-but-known key (availability) + // or reject an unknown `kid` as an invalid credential. A `kid` not + // fetched here is not negative-cached — only a successful refresh may + // confirm absence. + return inner + .known_key(kid) + .await + .ok_or_else(|| JwksError::UnknownKid(kid.to_string())); + } + + inner.state.write().await.last_attempt = Some(Instant::now()); + match inner.fetch_keys().await { + Ok(new_keys) => { + { + let mut state = inner.state.write().await; + state.keys = new_keys.clone(); + state.refreshed_at = Some(Instant::now()); + } + match new_keys.get(kid) { + Some(key) => Ok(key.clone()), + None => { + inner.negative.insert(kid.to_string(), ()).await; + Err(JwksError::UnknownKid(kid.to_string())) + } + } + } + // Provider failure must not discard still-valid keys: serve the + // last-known-good key for `kid` if we have one, else surface the + // provider error. + Err(error) => inner.known_key(kid).await.ok_or(error), + } + } +} + +impl Inner { + /// Returns the decoding key for `kid` only if the key set is fresh. + async fn fresh_known_key(&self, kid: &str) -> Option { + let state = self.state.read().await; + state + .is_fresh(self.ttl) + .then(|| state.keys.get(kid).cloned()) + .flatten() + } + + /// Returns the decoding key for `kid` from the last-known-good set, + /// regardless of freshness. + async fn known_key(&self, kid: &str) -> Option { + self.state.read().await.keys.get(kid).cloned() } async fn fetch_keys(&self) -> Result { tracing::info!(jwks_url = %self.jwks_url, "refreshing JWKS"); - let jwks: JwkSet = self + let response = self .http .get(&self.jwks_url) .send() .await .map_err(|error| JwksError::Http(error.to_string()))? .error_for_status() - .map_err(|error| JwksError::Http(error.to_string()))? - .json() + .map_err(|error| JwksError::Http(error.to_string()))?; + + // Body-size budget: reject an over-large declared length before reading, + // and cap the actually-read body as a backstop. + if response + .content_length() + .is_some_and(|len| len > MAX_JWKS_BODY_BYTES) + { + return Err(JwksError::Http(format!( + "JWKS body exceeds {MAX_JWKS_BODY_BYTES} byte budget" + ))); + } + let body = response + .bytes() .await - .map_err(|error| JwksError::Parse(error.to_string()))?; + .map_err(|error| JwksError::Http(error.to_string()))?; + if body.len() as u64 > MAX_JWKS_BODY_BYTES { + return Err(JwksError::Http(format!( + "JWKS body exceeds {MAX_JWKS_BODY_BYTES} byte budget" + ))); + } + let jwks: JwkSet = + serde_json::from_slice(&body).map_err(|error| JwksError::Parse(error.to_string()))?; let mut keys = HashMap::new(); for jwk in jwks.keys { @@ -113,39 +306,52 @@ mod tests { use rsa::traits::PublicKeyParts; use rsa::{RsaPrivateKey, rand_core::OsRng}; - /// Builds a one-key JWKS document for `kid` backed by a fresh RSA key. - fn jwks_json(kid: &str) -> serde_json::Value { + /// Builds one JWK entry for `kid` backed by a fresh RSA key. + fn jwk_entry(kid: &str) -> serde_json::Value { let key = RsaPrivateKey::new(&mut OsRng, 2048).expect("generate RSA test key"); let public = key.to_public_key(); serde_json::json!({ - "keys": [{ - "kty": "RSA", - "kid": kid, - "use": "sig", - "alg": "RS256", - "n": URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()), - "e": URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()), - }] + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()), + "e": URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()), + }) + } + + /// Builds a JWKS document covering every `kid` in `kids`. + fn jwks_body(kids: &[&str]) -> serde_json::Value { + serde_json::json!({ + "keys": kids.iter().map(|kid| jwk_entry(kid)).collect::>(), }) } + /// JWKS cache with explicit cooldown/negative windows for deterministic + /// timing without real-time waits. + fn cache_with( + url: String, + ttl: Duration, + cooldown: Duration, + negative_ttl: Duration, + ) -> JwksCache { + JwksCache::with_policy(url, ttl, cooldown, negative_ttl, 1024) + } + #[tokio::test] - async fn caches_within_ttl_and_refetches_whole_jwks_on_unknown_kid() { - // Pins: a `kid` hit inside the TTL is served from cache without a refetch, - // while an unknown `kid` forces a single whole-JWKS refresh (rotation). + async fn known_kid_served_from_cache_within_ttl_without_refetch() { + // Pins: a `kid` hit inside the TTL is served from cache with no refetch. let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(GET).path("/jwks.json"); - then.status(200).json_body(jwks_json("kid-1")); + then.status(200).json_body(jwks_body(&["kid-1"])); }) .await; - let cache = JwksCache::new(server.url("/jwks.json"), Duration::from_secs(3600)); cache.key_for("kid-1").await.expect("known kid resolves"); assert_eq!(mock.hits_async().await, 1, "first lookup fetches the JWKS"); - cache .key_for("kid-1") .await @@ -155,15 +361,197 @@ mod tests { 1, "second lookup is served from cache" ); + } + + #[tokio::test] + async fn unknown_kid_probe_during_outage_does_not_evict_known_keys() { + // Pins: F12 — a miss that triggers a failing refresh must not discard the + // last-known-good set; known keys keep validating during a provider outage. + let server = MockServer::start_async().await; + let ok = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(200).json_body(jwks_body(&["kid-1"])); + }) + .await; + let cache = cache_with( + server.url("/jwks.json"), + Duration::from_secs(3600), + Duration::from_millis(1), + Duration::from_secs(60), + ); + cache.key_for("kid-1").await.expect("known kid resolves"); + assert_eq!(ok.hits_async().await, 1); + + // The endpoint now errors for every fetch. + ok.delete_async().await; + let outage = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(500); + }) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; // exit the refresh cooldown + + // An unknown kid drives a refresh that fails; it surfaces as a provider + // error (not eviction), because the fetched set never arrived. + match cache.key_for("kid-2").await { + Err(JwksError::Http(_)) => {} + Err(other) => panic!("expected a provider error, got {other:?}"), + Ok(_) => panic!("unknown kid must not resolve against the retained set"), + } assert!( - matches!(cache.key_for("kid-2").await, Err(JwksError::UnknownKid(_))), - "unknown kid is rejected" + outage.hits_async().await >= 1, + "unknown kid attempted a refresh" ); + + // The known kid still validates from the retained last-known-good set. + cache + .key_for("kid-1") + .await + .expect("known kid still served despite the outage"); + } + + #[tokio::test] + async fn repeated_unknown_kid_probes_within_negative_ttl_fetch_at_most_once() { + // Pins: F12 — an unknown kid confirmed absent by a successful refresh is + // negative-cached, so repeated probes do not refetch even once the + // cooldown has elapsed between them. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(200).json_body(jwks_body(&["kid-1"])); + }) + .await; + let cache = cache_with( + server.url("/jwks.json"), + Duration::from_secs(3600), + Duration::from_millis(1), + Duration::from_secs(60), + ); + + cache.key_for("kid-1").await.expect("prime the cache"); + assert_eq!(mock.hits_async().await, 1); + tokio::time::sleep(Duration::from_millis(5)).await; // exit cooldown + + // First probe of the unknown kid refreshes once and confirms it absent. + assert!(matches!( + cache.key_for("kid-x").await, + Err(JwksError::UnknownKid(_)) + )); assert_eq!( mock.hits_async().await, 2, - "unknown kid forces a whole-JWKS refresh" + "first unknown probe refreshes once" + ); + + // Repeated probes, each after the cooldown has elapsed, are served from + // the negative cache without any further fetch. + for _ in 0..3 { + tokio::time::sleep(Duration::from_millis(3)).await; + assert!(matches!( + cache.key_for("kid-x").await, + Err(JwksError::UnknownKid(_)) + )); + } + assert_eq!( + mock.hits_async().await, + 2, + "repeated unknown probes are served from the negative cache" + ); + } + + #[tokio::test] + async fn stale_key_set_serves_last_known_good_when_refresh_fails() { + // Pins: F12 — once the set is past its TTL and the provider is down, a + // known kid is still served from the retained last-known-good set rather + // than failing authentication. + let server = MockServer::start_async().await; + let ok = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(200).json_body(jwks_body(&["kid-1"])); + }) + .await; + let cache = cache_with( + server.url("/jwks.json"), + Duration::from_millis(20), + Duration::from_millis(1), + Duration::from_secs(60), + ); + + cache.key_for("kid-1").await.expect("known kid resolves"); + + ok.delete_async().await; + let _outage = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(500); + }) + .await; + // Exceed both the freshness TTL and the cooldown so a refresh is attempted. + tokio::time::sleep(Duration::from_millis(30)).await; + + cache + .key_for("kid-1") + .await + .expect("stale known kid served from last-known-good during the outage"); + } + + #[tokio::test] + async fn new_key_is_rate_limited_within_cooldown_then_picked_up_after() { + // Pins: F12 — a newly published kid is rejected without a fetch while the + // cooldown is active (rate limit), then picked up by the first refresh + // after the cooldown elapses. + let server = MockServer::start_async().await; + // Precompute both bodies so no slow RSA work runs inside the cooldown window. + let body_one = jwks_body(&["kid-1"]); + let body_both = jwks_body(&["kid-1", "kid-2"]); + let first = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(200).json_body(body_one); + }) + .await; + let cache = cache_with( + server.url("/jwks.json"), + Duration::from_secs(3600), + Duration::from_millis(300), + Duration::from_secs(60), + ); + + cache.key_for("kid-1").await.expect("known kid resolves"); + assert_eq!(first.hits_async().await, 1); + + // Publish the new key. + first.delete_async().await; + let second = server + .mock_async(|when, then| { + when.method(GET).path("/jwks.json"); + then.status(200).json_body(body_both); + }) + .await; + + // Within the cooldown, the new kid is rejected as an invalid credential + // with no refetch. + assert!(matches!( + cache.key_for("kid-2").await, + Err(JwksError::UnknownKid(_)) + )); + assert_eq!( + second.hits_async().await, + 0, + "no refetch within the cooldown window" ); + + // After the cooldown elapses, the next probe refreshes and picks it up. + tokio::time::sleep(Duration::from_millis(350)).await; + cache + .key_for("kid-2") + .await + .expect("new key picked up after the cooldown"); + assert_eq!(second.hits_async().await, 1); } } diff --git a/crates/moa-auth/auth0/src/oidc_provider.rs b/crates/moa-auth/auth0/src/oidc_provider.rs index 046e4d5f..9f1bcd64 100644 --- a/crates/moa-auth/auth0/src/oidc_provider.rs +++ b/crates/moa-auth/auth0/src/oidc_provider.rs @@ -4,7 +4,7 @@ //! audience, and JWKS URL. Claim names for tenant and identity type are //! intentionally simple for P1.7 and can be extended by config later. -use crate::auth0_provider::{parse_identity_type, resolve_or_provision_static}; +use crate::auth0_provider::{map_jwks_error, parse_identity_type, resolve_or_provision_static}; use crate::jwks_cache::JwksCache; use async_trait::async_trait; use jsonwebtoken::{Algorithm, Validation, decode, decode_header}; @@ -70,11 +70,7 @@ impl AuthProvider for OidcAuthProvider { return Err(AuthError::Rejected); } let kid = header.kid.ok_or(AuthError::InvalidFormat)?; - let key = self - .jwks - .key_for(&kid) - .await - .map_err(|error| AuthError::Unavailable(format!("jwks: {error}")))?; + let key = self.jwks.key_for(&kid).await.map_err(map_jwks_error)?; let mut validation = Validation::new(Algorithm::RS256); validation.set_issuer(&[&self.issuer]); diff --git a/crates/moa-auth/authz-schema/src/tuple.rs b/crates/moa-auth/authz-schema/src/tuple.rs index 176ec081..0da6efd8 100644 --- a/crates/moa-auth/authz-schema/src/tuple.rs +++ b/crates/moa-auth/authz-schema/src/tuple.rs @@ -126,23 +126,6 @@ impl TupleKey { object: self.object_wire(), } } - - /// Build the deterministic outbox idempotency key for this tuple. - /// - /// Format: - /// `{op}-{object_type}-{object_id}-{relation}-{user_type}-{user_id}-v{model_version}`. - pub fn idempotency_key(&self, op: TupleOp, model_version: u32) -> String { - format!( - "{}-{}-{}-{}-{}-{}-v{}", - op, - self.object_type, - self.object_id, - self.relation, - self.user_type, - self.user_id, - model_version, - ) - } } /// Serializable OpenFGA tuple key shape. @@ -271,33 +254,6 @@ mod tests { assert_eq!(tuple.to_wire().relation, "workspace"); } - #[test] - fn idempotency_key_is_deterministic_and_includes_model_version() { - // Pins: outbox idempotency keys include operation, tuple identity, and model version. - let user_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111") - .expect("fixture operator UUID should parse"); - let tenant_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222") - .expect("fixture tenant UUID should parse"); - let tuple = TupleKey::new( - UserType::Operator, - user_id, - Relation::Operator, - ObjectType::Tenant, - tenant_id, - ); - - let first = tuple.idempotency_key(TupleOp::Write, 1); - let second = tuple.idempotency_key(TupleOp::Write, 1); - assert_eq!(first, second); - assert_eq!( - first, - "write-tenant-22222222-2222-2222-2222-222222222222-operator-operator-11111111-1111-1111-1111-111111111111-v1" - ); - - let v2 = tuple.idempotency_key(TupleOp::Write, 2); - assert_ne!(first, v2); - } - #[test] fn schema_v1_json_contains_security_contract_types_and_delegation() { // Pins: the deployed OpenFGA JSON model includes the auth object set and agent delegation. diff --git a/crates/moa-auth/authz/Cargo.toml b/crates/moa-auth/authz/Cargo.toml index d8f080bc..0b909eb3 100644 --- a/crates/moa-auth/authz/Cargo.toml +++ b/crates/moa-auth/authz/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] async-trait.workspace = true +metrics.workspace = true moa-authz-schema.workspace = true moa-core.workspace = true moa-ocsf.workspace = true diff --git a/crates/moa-auth/authz/src/outbox.rs b/crates/moa-auth/authz/src/outbox.rs index 7586af7b..9a4f22cb 100644 --- a/crates/moa-auth/authz/src/outbox.rs +++ b/crates/moa-auth/authz/src/outbox.rs @@ -5,11 +5,16 @@ use moa_authz_schema::{MODEL_VERSION, TupleKey, TupleOp}; use sqlx::PgExecutor; use uuid::Uuid; -/// Enqueue a tuple operation into `authz_outbox`. +/// Enqueue the desired state of a tuple into `authz_outbox`. /// /// Callers should execute this inside the same Postgres transaction as the -/// state mutation that requires the tuple. Enqueue is idempotent: if the -/// deterministic key already exists, the existing row is left unchanged. +/// state mutation that requires the tuple. Enqueue models the *latest desired +/// state* of the tuple identity `(user, relation, object, model_version)`: the +/// single row for that identity is upserted to `op`, its generation is bumped, +/// and it is reset to `pending`. Re-enqueuing the desired op that a row already +/// carries in a non-terminal state is a no-op, but any change of desired op — +/// or re-enqueuing a dead-lettered tuple — reactivates the row so the newest +/// intent is applied. This lets `write -> delete -> write` converge to `write`. pub async fn enqueue<'executor, Executor>( exec: Executor, op: TupleOp, @@ -19,11 +24,9 @@ pub async fn enqueue<'executor, Executor>( where Executor: PgExecutor<'executor>, { - let idempotency_key = tuple.idempotency_key(op, MODEL_VERSION); - insert_outbox( + upsert_desired_state( exec, op, - &idempotency_key, &tuple.user_wire(), &tuple.relation.to_string(), &tuple.object_wire(), @@ -32,11 +35,12 @@ where .await } -/// Enqueue a tuple operation using OpenFGA wire strings directly. +/// Enqueue the desired state of a tuple using OpenFGA wire strings directly. /// /// This is used for parent-edge tuples whose subject is another object, such /// as `tenant: tenant session:`, which cannot be represented by -/// the typed subject enum in [`TupleKey`]. +/// the typed subject enum in [`TupleKey`]. It shares the desired-state upsert +/// semantics of [`enqueue`]. pub async fn enqueue_raw<'executor, Executor>( exec: Executor, op: TupleOp, @@ -48,27 +52,20 @@ pub async fn enqueue_raw<'executor, Executor>( where Executor: PgExecutor<'executor>, { - let idempotency_key = format!("{op}-{object_wire}-{relation}-{user_wire}-v{MODEL_VERSION}"); - insert_outbox( - exec, - op, - &idempotency_key, - user_wire, - relation, - object_wire, - tenant_id, - ) - .await + upsert_desired_state(exec, op, user_wire, relation, object_wire, tenant_id).await } -/// Insert one `authz_outbox` row, leaving an existing row untouched on conflict. +/// Upsert one tuple identity to its latest desired operation. /// /// Shared by [`enqueue`] and [`enqueue_raw`], which differ only in how they -/// derive the idempotency key and wire strings. -async fn insert_outbox<'executor, Executor>( +/// derive the wire strings. On conflict the row is reactivated (op set, +/// generation incremented, status reset to `pending`, retry state cleared) only +/// when the desired op differs from the stored op or the stored row is +/// dead-lettered. A same-op enqueue against a non-terminal row leaves the row — +/// including any in-flight lease — untouched. +async fn upsert_desired_state<'executor, Executor>( exec: Executor, op: TupleOp, - idempotency_key: &str, user_wire: &str, relation: &str, object_wire: &str, @@ -80,12 +77,23 @@ where sqlx::query( r#" INSERT INTO authz_outbox - (idempotency_key, op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (idempotency_key) DO NOTHING + (op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id, + generation, status, attempts, next_attempt_at) + VALUES ($1, $2, $3, $4, $5, $6, 1, 'pending', 0, NOW()) + ON CONFLICT (tuple_user, tuple_relation, tuple_object, model_version) DO UPDATE + SET op = EXCLUDED.op, + generation = authz_outbox.generation + 1, + status = 'pending', + attempts = 0, + last_error = NULL, + lease_token = NULL, + lease_expires_at = NULL, + next_attempt_at = NOW(), + updated_at = NOW() + WHERE authz_outbox.op IS DISTINCT FROM EXCLUDED.op + OR authz_outbox.status = 'dead_letter' "#, ) - .bind(idempotency_key) .bind(op.to_string()) .bind(user_wire) .bind(relation) @@ -97,13 +105,14 @@ where Ok(()) } +/// One claimed `authz_outbox` row projected for OpenFGA application. #[derive(Debug, Clone, sqlx::FromRow)] pub(crate) struct OutboxRow { pub id: Uuid, - pub idempotency_key: String, pub op: String, pub tuple_user: String, pub tuple_relation: String, pub tuple_object: String, pub attempts: i32, + pub generation: i64, } diff --git a/crates/moa-auth/authz/src/poller.rs b/crates/moa-auth/authz/src/poller.rs index 6eace4ca..d89f0927 100644 --- a/crates/moa-auth/authz/src/poller.rs +++ b/crates/moa-auth/authz/src/poller.rs @@ -91,6 +91,9 @@ impl OutboxPoller { /// Run one drain pass and return the number of rows applied successfully. pub async fn tick(&self) -> Result { + // Sample the backlog age every tick (including idle ticks) so a stalled + // drain surfaces as a rising gauge rather than only in logs. + self.record_backlog_gauge().await; let claimed = self.claim_batch().await?; if claimed.is_empty() { return Ok(0); @@ -143,9 +146,9 @@ impl OutboxPoller { updated_at = NOW() FROM candidate WHERE outbox.id = candidate.id - RETURNING outbox.id, outbox.idempotency_key, outbox.op, + RETURNING outbox.id, outbox.op, outbox.tuple_user, outbox.tuple_relation, outbox.tuple_object, - outbox.attempts, outbox.lease_token + outbox.attempts, outbox.generation, outbox.lease_token "#, ) .bind(limit_i64(self.cfg.batch_size)) @@ -178,6 +181,10 @@ impl OutboxPoller { } async fn record_success(&self, claim: &ClaimedOutboxRow) -> Result { + // Compare-and-set on generation: only mark succeeded if the desired state + // this poller applied is still current. A concurrent enqueue that changed + // the desired op reset the row to pending and bumped its generation, so + // this update matches zero rows and the newer state is applied next tick. let result = sqlx::query( r#" UPDATE authz_outbox @@ -188,18 +195,21 @@ impl OutboxPoller { WHERE id=$1 AND status = 'in_flight' AND lease_token = $2 + AND generation = $3 "#, ) .bind(claim.row.id) .bind(claim.lease_token) + .bind(claim.row.generation) .execute(&self.pool) .await?; let completed = result.rows_affected() == 1; if !completed { tracing::debug!( id = %claim.row.id, - key = %claim.row.idempotency_key, - "outbox row lease was lost before success could be recorded" + tuple = %claim.tuple_display(), + generation = claim.row.generation, + "outbox row desired state changed before success could be recorded" ); } Ok(completed) @@ -212,10 +222,14 @@ impl OutboxPoller { ) -> Result<(), AuthzError> { let row = &claim.row; let next_attempts = row.attempts + 1; + // Every failure update is fenced on generation as well as the lease, so a + // concurrent desired-state change (which reactivated the row at a higher + // generation) is never overwritten with stale retry/dead-letter state. if next_attempts >= self.cfg.max_attempts { tracing::error!( id = %row.id, - key = %row.idempotency_key, + tuple = %claim.tuple_display(), + generation = row.generation, attempts = next_attempts, error = %error, "outbox row exhausted retries; moving to dead_letter" @@ -232,21 +246,25 @@ impl OutboxPoller { WHERE id=$1 AND status = 'in_flight' AND lease_token = $4 + AND generation = $5 "#, ) .bind(row.id) .bind(next_attempts) .bind(error.to_string()) .bind(claim.lease_token) + .bind(row.generation) .execute(&self.pool) .await?; + metrics::counter!("moa_authz_outbox_dead_letters_total").increment(1); return Ok(()); } let backoff = self.backoff_for(next_attempts); tracing::warn!( id = %row.id, - key = %row.idempotency_key, + tuple = %claim.tuple_display(), + generation = row.generation, attempts = next_attempts, backoff_ms = backoff.as_millis() as u64, error = %error, @@ -265,6 +283,7 @@ impl OutboxPoller { WHERE id=$1 AND status = 'in_flight' AND lease_token = $5 + AND generation = $6 "#, ) .bind(row.id) @@ -272,11 +291,41 @@ impl OutboxPoller { .bind(error.to_string()) .bind(duration_millis_string(backoff)) .bind(claim.lease_token) + .bind(row.generation) .execute(&self.pool) .await?; + metrics::counter!("moa_authz_outbox_retries_total").increment(1); Ok(()) } + /// Samples the drain-backlog age gauge: how long the oldest ready-to-apply + /// pending row has waited. Best-effort; a sampling error never fails the tick. + async fn record_backlog_gauge(&self) { + match self.oldest_ready_pending_age_seconds().await { + Ok(age) => { + metrics::gauge!("moa_authz_outbox_oldest_pending_age_seconds").set(age); + } + Err(error) => { + tracing::debug!(error = %error, "failed to sample authz outbox backlog age"); + } + } + } + + /// Returns the age in seconds of the oldest pending row whose next attempt is + /// due, or `0.0` when nothing is waiting to be applied. + async fn oldest_ready_pending_age_seconds(&self) -> Result { + let age: Option = sqlx::query_scalar( + r#" + SELECT EXTRACT(EPOCH FROM (NOW() - MIN(next_attempt_at))) + FROM authz_outbox + WHERE status = 'pending' AND next_attempt_at <= NOW() + "#, + ) + .fetch_one(&self.pool) + .await?; + Ok(age.unwrap_or(0.0).max(0.0)) + } + fn backoff_for(&self, attempt: i32) -> Duration { let pow = (attempt as u32).saturating_sub(1).min(20); let multiplier = 1u64 << pow; @@ -288,12 +337,12 @@ impl OutboxPoller { #[derive(Debug, Clone, sqlx::FromRow)] struct ClaimedOutboxRecord { id: Uuid, - idempotency_key: String, op: String, tuple_user: String, tuple_relation: String, tuple_object: String, attempts: i32, + generation: i64, lease_token: Uuid, } @@ -301,12 +350,12 @@ impl ClaimedOutboxRecord { fn row(&self) -> OutboxRow { OutboxRow { id: self.id, - idempotency_key: self.idempotency_key.clone(), op: self.op.clone(), tuple_user: self.tuple_user.clone(), tuple_relation: self.tuple_relation.clone(), tuple_object: self.tuple_object.clone(), attempts: self.attempts, + generation: self.generation, } } } @@ -326,6 +375,16 @@ struct ClaimedOutboxRow { lease_token: Uuid, } +impl ClaimedOutboxRow { + /// Render the tuple identity for structured logs, e.g. `write user rel object`. + fn tuple_display(&self) -> String { + format!( + "{} {} {} {}", + self.row.op, self.row.tuple_user, self.row.tuple_relation, self.row.tuple_object + ) + } +} + /// Handle for cleanly shutting down a spawned outbox poller. pub struct PollerHandle { shutdown: Option>, diff --git a/crates/moa-auth/authz/tests/authz_db/authz_poller_db.rs b/crates/moa-auth/authz/tests/authz_db/authz_poller_db.rs index 671b2551..ba127f43 100644 --- a/crates/moa-auth/authz/tests/authz_db/authz_poller_db.rs +++ b/crates/moa-auth/authz/tests/authz_db/authz_poller_db.rs @@ -174,16 +174,15 @@ async fn insert_outbox_row( let sql = format!( r#" INSERT INTO authz_outbox - (id, idempotency_key, op, tuple_user, tuple_relation, tuple_object, + (id, op, tuple_user, tuple_relation, tuple_object, model_version, status, next_attempt_at, lease_token, lease_expires_at) VALUES - ($1, $2, 'write', $3, 'operator', $4, 1, $5, - NOW() - INTERVAL '1 minute', $6, {lease_expires_at_sql}) + ($1, 'write', $2, 'operator', $3, 1, $4, + NOW() - INTERVAL '1 minute', $5, {lease_expires_at_sql}) "# ); sqlx::query(&sql) .bind(row_id) - .bind(format!("test-key-{row_id}")) .bind(format!("operator:{}", Uuid::new_v4())) .bind(format!("tenant:{}", Uuid::new_v4())) .bind(status) diff --git a/crates/moa-auth/authz/tests/authz_db/outbox_basic_db.rs b/crates/moa-auth/authz/tests/authz_db/outbox_basic_db.rs index 583a888a..3899282c 100644 --- a/crates/moa-auth/authz/tests/authz_db/outbox_basic_db.rs +++ b/crates/moa-auth/authz/tests/authz_db/outbox_basic_db.rs @@ -1,7 +1,8 @@ -//! Outbox enqueue and retry behavior against Postgres. +//! Outbox desired-state, convergence, and retry behavior against Postgres. +use httpmock::{Method::POST, MockServer}; use moa_authz::{FgaClient, FgaConfig, OutboxPoller, PollerConfig, enqueue}; -use moa_authz_schema::{ObjectType, Relation, TupleKey, TupleOp, UserType}; +use moa_authz_schema::{MODEL_VERSION, ObjectType, Relation, TupleKey, TupleOp, UserType}; use sqlx::{PgPool, postgres::PgPoolOptions}; use std::time::Duration; use uuid::Uuid; @@ -37,58 +38,100 @@ fn quote_identifier(identifier: &str) -> String { format!("\"{}\"", identifier.replace('"', "\"\"")) } -#[tokio::test] -async fn outbox_basic_enqueue_is_idempotent_on_same_key() { - // Pins: repeated enqueue of the same tuple operation creates exactly one outbox row. - let pool = test_pool().await; - let user_id = Uuid::new_v4(); +fn test_tuple() -> (TupleKey, Uuid) { let tenant_id = Uuid::new_v4(); let tuple = TupleKey::new( UserType::Operator, - user_id, + Uuid::new_v4(), Relation::Operator, ObjectType::Tenant, tenant_id, ); + (tuple, tenant_id) +} - enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) - .await - .expect("first enqueue should succeed"); - enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) - .await - .expect("second enqueue should be idempotent"); - enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) - .await - .expect("third enqueue should be idempotent"); +/// The single desired-state row for a tuple identity: `(op, generation, status)`. +async fn desired_state(pool: &PgPool, tuple: &TupleKey) -> Option<(String, i64, String)> { + sqlx::query_as( + "SELECT op, generation, status FROM authz_outbox + WHERE tuple_user=$1 AND tuple_relation=$2 AND tuple_object=$3", + ) + .bind(tuple.user_wire()) + .bind(tuple.relation.to_string()) + .bind(tuple.object_wire()) + .fetch_optional(pool) + .await + .expect("desired-state query should succeed") +} - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM authz_outbox WHERE tuple_user=$1 AND tuple_relation=$2 AND tuple_object=$3", +async fn row_count(pool: &PgPool, tuple: &TupleKey) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM authz_outbox + WHERE tuple_user=$1 AND tuple_relation=$2 AND tuple_object=$3", ) .bind(tuple.user_wire()) .bind(tuple.relation.to_string()) .bind(tuple.object_wire()) - .fetch_one(&pool) + .fetch_one(pool) .await - .expect("count query should succeed"); + .expect("count query should succeed") +} + +fn failing_poller(pool: PgPool, max_attempts: i32) -> OutboxPoller { + // 127.0.0.1:9 (discard) makes every FGA apply fail, driving the retry path. + poller_with_url(pool, "http://127.0.0.1:9".to_string(), max_attempts) +} + +fn poller_with_url(pool: PgPool, url: String, max_attempts: i32) -> OutboxPoller { + let client = FgaClient::new(FgaConfig { + url, + preshared_key: "test".to_string(), + store_id: "store".to_string(), + model_id: "model".to_string(), + timeout_ms: 5_000, + }) + .expect("client config should be valid"); + OutboxPoller::new( + pool, + client, + PollerConfig { + batch_size: 8, + poll_interval: Duration::from_millis(10), + max_attempts, + backoff_base: Duration::from_millis(1), + backoff_cap: Duration::from_millis(1), + lease_duration: PollerConfig::default().lease_duration, + }, + ) +} + +#[tokio::test] +async fn outbox_same_op_enqueue_is_idempotent_and_holds_generation_db() { + // Pins: repeated enqueue of the same desired op keeps exactly one row at its + // initial generation, so unchanged intent never busts the row or resets retry. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + + for _ in 0..3 { + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("same-op enqueue should be idempotent"); + } + + assert_eq!(row_count(&pool, &tuple).await, 1); assert_eq!( - count, 1, - "three enqueues of the same key must produce exactly one row" + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 1, "pending".to_string())) ); } #[tokio::test] -async fn outbox_basic_enqueue_separates_write_and_delete() { - // Pins: write and delete use distinct idempotency keys for the same tuple. +async fn outbox_alternating_ops_collapse_to_latest_desired_state_db() { + // Pins: write then delete for one tuple identity yields a single row whose + // desired op is the latest (delete) with a bumped generation — not two rows, + // and not a write that permanently owns the identity. let pool = test_pool().await; - let user_id = Uuid::new_v4(); - let tenant_id = Uuid::new_v4(); - let tuple = TupleKey::new( - UserType::Operator, - user_id, - Relation::Operator, - ObjectType::Tenant, - tenant_id, - ); + let (tuple, tenant_id) = test_tuple(); enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) .await @@ -97,69 +140,204 @@ async fn outbox_basic_enqueue_separates_write_and_delete() { .await .expect("delete enqueue should succeed"); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM authz_outbox WHERE tuple_user=$1 AND tuple_relation=$2 AND tuple_object=$3", + assert_eq!(row_count(&pool, &tuple).await, 1); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("delete".to_string(), 2, "pending".to_string())) + ); +} + +#[tokio::test] +async fn outbox_grant_revoke_grant_converges_to_write_db() { + // Pins: the F03 regression — grant, revoke, then re-grant must leave the tuple + // in the granted (write) desired state, not suppressed by the first write. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("grant should succeed"); + enqueue(&pool, TupleOp::Delete, &tuple, Some(tenant_id)) + .await + .expect("revoke should succeed"); + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("re-grant should succeed"); + + assert_eq!(row_count(&pool, &tuple).await, 1); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 3, "pending".to_string())) + ); +} + +#[tokio::test] +async fn outbox_revoke_grant_revoke_converges_to_delete_db() { + // Pins: the inverse sequence — revoke, grant, then re-revoke must converge to + // the revoked (delete) desired state. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + + enqueue(&pool, TupleOp::Delete, &tuple, Some(tenant_id)) + .await + .expect("revoke should succeed"); + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("grant should succeed"); + enqueue(&pool, TupleOp::Delete, &tuple, Some(tenant_id)) + .await + .expect("re-revoke should succeed"); + + assert_eq!(row_count(&pool, &tuple).await, 1); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("delete".to_string(), 3, "pending".to_string())) + ); +} + +#[tokio::test] +async fn outbox_poller_applies_latest_desired_state_after_change_db() { + // Pins: when the desired op changes after the row was picked up (simulated by + // an in_flight row plus an enqueue of the opposite op), the poller applies the + // NEW op and converges the row to succeeded at the bumped generation. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + + // Simulate a poller that claimed a write (in_flight, generation 1). The + // model_version must match what `enqueue` uses, since it is part of the + // tuple identity the reactivating upsert conflicts on. + sqlx::query( + "INSERT INTO authz_outbox + (op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id, + generation, status, lease_token, lease_expires_at, next_attempt_at) + VALUES ('write', $1, $2, $3, $4, $5, 1, 'in_flight', $6, + NOW() + INTERVAL '5 minutes', NOW())", ) .bind(tuple.user_wire()) .bind(tuple.relation.to_string()) .bind(tuple.object_wire()) - .fetch_one(&pool) + .bind(MODEL_VERSION as i32) + .bind(tenant_id) + .bind(Uuid::new_v4()) + .execute(&pool) .await - .expect("count query should succeed"); + .expect("seed in_flight row should succeed"); + + // A concurrent revoke changes the desired op: the row reactivates as a + // pending delete at generation 2, releasing the stale lease. + enqueue(&pool, TupleOp::Delete, &tuple, Some(tenant_id)) + .await + .expect("revoke should reactivate the row"); assert_eq!( - count, 2, - "write and delete must produce separate outbox rows" + desired_state(&pool, &tuple).await, + Some(("delete".to_string(), 2, "pending".to_string())) + ); + + let server = MockServer::start(); + let delete = server.mock(|when, then| { + when.method(POST) + .path("/stores/store/write") + .body_contains("deletes"); + then.status(200).json_body(serde_json::json!({})); + }); + let poller = poller_with_url(pool.clone(), server.base_url(), 4); + + let drained = poller.tick().await.expect("poller tick should complete"); + + assert_eq!(drained, 1, "the latest desired op should be applied once"); + delete.assert_hits(1); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("delete".to_string(), 2, "succeeded".to_string())), + "poller must converge the row to the latest desired op" ); } #[tokio::test] -async fn outbox_basic_failed_row_moves_to_dead_letter_at_max_attempts() { - // Pins: a non-retryable poller batch stops at dead_letter after max_attempts. +async fn outbox_dead_letter_reactivates_on_new_desired_state_db() { + // Pins: a dead-lettered tuple still accepts a new desired op and returns to + // pending, so a redrive after terminal failure is not permanently suppressed. let pool = test_pool().await; - let tenant_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let tuple = TupleKey::new( - UserType::Operator, - user_id, - Relation::Operator, - ObjectType::Tenant, - tenant_id, + let (tuple, tenant_id) = test_tuple(); + + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("initial grant should succeed"); + + // Exhaust retries against an unreachable FGA to reach dead_letter. + let poller = failing_poller(pool.clone(), 1); + poller.tick().await.expect("poller tick should complete"); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 1, "dead_letter".to_string())) ); + + // A new desired op (revoke) must reactivate the dead-lettered identity. + enqueue(&pool, TupleOp::Delete, &tuple, Some(tenant_id)) + .await + .expect("revoke should reactivate a dead-lettered tuple"); + + assert_eq!(row_count(&pool, &tuple).await, 1); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("delete".to_string(), 2, "pending".to_string())) + ); +} + +#[tokio::test] +async fn outbox_dead_letter_reactivates_on_same_op_redrive_db() { + // Pins: re-enqueuing the SAME op that dead-lettered still reactivates the row, + // so a transient outage that exhausted retries can be redriven. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) .await - .expect("enqueue should succeed"); + .expect("initial grant should succeed"); + failing_poller(pool.clone(), 1) + .tick() + .await + .expect("poller tick should complete"); + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 1, "dead_letter".to_string())) + ); - let client = FgaClient::new(FgaConfig { - url: "http://127.0.0.1:9".to_string(), - preshared_key: "test".to_string(), - store_id: "store".to_string(), - model_id: "model".to_string(), - timeout_ms: 200, - }) - .expect("client config should be valid"); - let poller = OutboxPoller::new( - pool.clone(), - client, - PollerConfig { - batch_size: 8, - poll_interval: Duration::from_millis(10), - max_attempts: 1, - backoff_base: Duration::from_millis(1), - backoff_cap: Duration::from_millis(1), - lease_duration: PollerConfig::default().lease_duration, - }, + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("same-op redrive should reactivate the row"); + + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 2, "pending".to_string())) ); +} - let drained = poller.tick().await.expect("poller tick should complete"); +#[tokio::test] +async fn outbox_failed_row_moves_to_dead_letter_at_max_attempts_db() { + // Pins: a non-retryable poller batch stops at dead_letter after max_attempts. + let pool = test_pool().await; + let (tuple, tenant_id) = test_tuple(); + enqueue(&pool, TupleOp::Write, &tuple, Some(tenant_id)) + .await + .expect("enqueue should succeed"); + + let drained = failing_poller(pool.clone(), 1) + .tick() + .await + .expect("poller tick should complete"); assert_eq!(drained, 0, "failed rows are not counted as drained"); - let (status, attempts): (String, i32) = sqlx::query_as( - "SELECT status, attempts FROM authz_outbox WHERE tuple_object=$1 ORDER BY created_at DESC LIMIT 1", + assert_eq!( + desired_state(&pool, &tuple).await, + Some(("write".to_string(), 1, "dead_letter".to_string())) + ); + let attempts: i32 = sqlx::query_scalar( + "SELECT attempts FROM authz_outbox WHERE tuple_object=$1 ORDER BY created_at DESC LIMIT 1", ) .bind(tuple.object_wire()) .fetch_one(&pool) .await - .expect("status query should succeed"); - assert_eq!(status, "dead_letter"); + .expect("attempts query should succeed"); assert_eq!(attempts, 1); } diff --git a/crates/moa-auth/providers/src/api_keys.rs b/crates/moa-auth/providers/src/api_keys.rs index 29ddb9a9..867faada 100644 --- a/crates/moa-auth/providers/src/api_keys.rs +++ b/crates/moa-auth/providers/src/api_keys.rs @@ -292,7 +292,12 @@ where { let key = generate(new.env); let prefix = prefix_of(key.expose_secret())?; - let hash = hash_key(key.expose_secret())?; + // Argon2 hashing is CPU-heavy; run it off the async runtime so key creation + // does not stall other tasks on the worker thread (mirrors the verify path). + let key_secret = key.expose_secret().to_string(); + let hash = tokio::task::spawn_blocking(move || hash_key(&key_secret)) + .await + .map_err(|error| ApiKeyError::Hash(format!("hash task failed: {error}")))??; let id = Uuid::new_v4(); let (owner_user_id, owner_agent_id) = match new.owner { KeyOwner::User(user_id) => (Some(user_id), None), diff --git a/crates/moa-auth/providers/src/user_sessions.rs b/crates/moa-auth/providers/src/user_sessions.rs index 9df3cc7f..67dc9114 100644 --- a/crates/moa-auth/providers/src/user_sessions.rs +++ b/crates/moa-auth/providers/src/user_sessions.rs @@ -83,7 +83,12 @@ where { let token = generate(); let prefix = prefix_of(&token)?; - let hash = hash_token(&token)?; + // Argon2 hashing is CPU-heavy; run it off the async runtime so session-token + // creation does not stall other tasks on the worker thread (mirrors verify). + let token_for_hash = token.clone(); + let hash = tokio::task::spawn_blocking(move || hash_token(&token_for_hash)) + .await + .map_err(|error| UserSessionTokenError::Hash(format!("hash task failed: {error}")))??; let id = Uuid::new_v4(); sqlx::query( r#" diff --git a/crates/moa-brain/src/pipeline/memory.rs b/crates/moa-brain/src/pipeline/memory.rs index 7e936c92..0bab6ec0 100644 --- a/crates/moa-brain/src/pipeline/memory.rs +++ b/crates/moa-brain/src/pipeline/memory.rs @@ -39,7 +39,7 @@ mod lineage; mod rendering; use lineage::lineage_context_from_context; -use rendering::{render_memory_context, render_memory_context_with_budget}; +use rendering::render_memory_context_with_budget; const MEMORY_BUDGET_DIVISOR: usize = 5; const GRAPH_MEMORY_RESULTS: usize = 4; @@ -232,8 +232,11 @@ pub trait ScopedRetrievalRuntimeFactory: Send + Sync { ) -> Result; } -#[derive(Debug, Default)] -struct PostgresScopedRetrievalRuntimeFactory; +struct PostgresScopedRetrievalRuntimeFactory { + /// Shared bounded enrichment worker handle, cloned into each scoped + /// [`HybridRetriever`]. `None` when constructed outside a Tokio runtime. + enrichment: Option, +} #[async_trait] impl ScopedRetrievalRuntimeFactory for PostgresScopedRetrievalRuntimeFactory { @@ -264,7 +267,8 @@ impl ScopedRetrievalRuntimeFactory for PostgresScopedRetrievalRuntimeFactory { graph.clone(), pgvector_source, ) - .with_assume_app_role(assume_app_role), + .with_assume_app_role(assume_app_role) + .with_enrichment(self.enrichment.clone()), ); let cached: Arc = if assume_app_role { Arc::new(crate::retrieval::CachedHybridRetriever::new_for_app_role( @@ -295,6 +299,12 @@ impl GraphMemoryRetriever { pool: PgPool, embedder: Option>, ) -> Self { + // F20: spawn the single shared bounded enrichment worker when a Tokio + // runtime is available (production and async tests). Sync callers + // (eval/unit setup) get no worker and skip best-effort enrichment. + let enrichment = tokio::runtime::Handle::try_current() + .ok() + .map(|_| crate::retrieval::enrichment::spawn_enrichment_worker(pool.clone())); Self { pool, embedder, @@ -304,7 +314,7 @@ impl GraphMemoryRetriever { result_limit: GRAPH_MEMORY_RESULTS, planner: crate::planning::QueryPlanner::new(), scoped_runtimes: build_scoped_runtime_cache(), - runtime_factory: Arc::new(PostgresScopedRetrievalRuntimeFactory), + runtime_factory: Arc::new(PostgresScopedRetrievalRuntimeFactory { enrichment }), } } @@ -464,7 +474,25 @@ impl GraphMemoryRetriever { let needs_backend = probes.iter().any(|probe| probe.cached_hits.is_none()); let query_embedding = if needs_backend { match self.embedder.as_deref() { - Some(embedder) => embed_query(embedder, query_str).await?, + // A query-embedding failure must degrade to lexical-only + // retrieval rather than abort the turn: an empty embedding makes + // the vector leg return nothing while the lexical leg still runs. + Some(embedder) => match embed_query(embedder, query_str).await { + Ok(embedding) => embedding, + Err(error) => { + tracing::warn!( + error = %error, + "query embedding failed; degrading to lexical-only retrieval" + ); + metrics::counter!( + "moa_retrieval_leg_degraded_total", + "leg" => "embedding", + "reason" => "error", + ) + .increment(1); + Vec::new() + } + }, None => Vec::new(), } } else { @@ -526,12 +554,17 @@ impl GraphMemoryRetriever { let result_limit = requested_result_limit; - let mut fused = Vec::new(); - for sub_query in sub_queries { - fused.extend( + // F23: run the (≤2) decomposed sub-queries concurrently instead of + // serially. `try_join_all` preserves the decomposition input order in its + // output, so fusion stays deterministic before `dedupe_and_rank_hits`. + let sub_results = + try_join_all(sub_queries.into_iter().map(|sub_query| { self.retrieve_hits(ctx, sub_query, policy, requested_result_limit) - .await?, - ); + })) + .await?; + let mut fused = Vec::new(); + for hits in sub_results { + fused.extend(hits); } Ok(dedupe_and_rank_hits(fused, result_limit)) } @@ -801,8 +834,38 @@ impl ContextProcessor for GraphMemoryRetriever { let tokens_before = ctx.token_count; let memory_budget = (ctx.token_budget / MEMORY_BUDGET_DIVISOR).max(MIN_PAGE_EXCERPT_TOKENS); - let per_hit_budget = (memory_budget / hits.len().max(1)).max(MIN_PAGE_EXCERPT_TOKENS); - let rendered = render_memory_context(&hits, per_hit_budget); + // F23: cap the whole injected block by the memory budget rather than a + // per-hit floor (which let N hits reach 96·N > memory_budget). Reserve + // the outer memory-reminder wrapper so the injected message — not just + // the inner rendered section — stays within budget, then render the + // largest ranked-hit prefix that fits. + let evidence_budget = memory_budget.saturating_sub(memory_reminder_wrapper_tokens()); + let budgeted = render_memory_context_with_budget(&hits, evidence_budget); + let omitted = hits.len().saturating_sub(budgeted.hit_count); + if omitted > 0 { + tracing::debug!( + omitted_hits = omitted, + rendered_hits = budgeted.hit_count, + evidence_budget, + "graph-memory injection omitted ranked hits to fit the memory budget" + ); + metrics::counter!("moa_memory_injection_omitted_hits_total") + .increment(omitted as u64); + } + if budgeted.hit_count == 0 { + // The budget could not fit even one hit; inject nothing rather than + // an empty reminder wrapper. + return Ok(ProcessorOutput { + items_excluded: vec!["graph_memory".to_string()], + excluded_items: vec![ExcludedItem { + item: "graph_memory".to_string(), + reason: "memory budget too small to render any ranked hit".to_string(), + }], + metadata: router_strategy_metadata(strategy_label), + ..ProcessorOutput::default() + }); + } + let rendered = budgeted.rendered; let reminder = format!( "{MEMORY_REMINDER_PREFIX}\n{}\n", @@ -838,6 +901,13 @@ async fn embed_query(embedder: &dyn EmbeddingProvider, query: &str) -> Result` wrapper added around the rendered +/// memory section, reserved from the memory budget so the whole injected message +/// stays within budget. +fn memory_reminder_wrapper_tokens() -> usize { + moa_core::estimate_text_tokens(&format!("{MEMORY_REMINDER_PREFIX}\n\n")) +} + /// Builds the processor-output metadata that records the router's chosen /// strategy, so evals and tests can observe the routing decision for a turn. fn router_strategy_metadata( diff --git a/crates/moa-brain/src/pipeline/memory/rendering.rs b/crates/moa-brain/src/pipeline/memory/rendering.rs index 1bd03006..d5f121c7 100644 --- a/crates/moa-brain/src/pipeline/memory/rendering.rs +++ b/crates/moa-brain/src/pipeline/memory/rendering.rs @@ -347,7 +347,38 @@ mod tests { KnowledgeChunkHydration, KnowledgeChunkWindowPart, LegSources, RetrievalHit, SourceTier, }; - use super::{MATCHED_CLOSE, MATCHED_OPEN, render_memory_context}; + use super::{ + MATCHED_CLOSE, MATCHED_OPEN, render_memory_context, render_memory_context_with_budget, + }; + + #[test] + fn budgeted_render_caps_aggregate_tokens_and_omits_excess_hits() { + // Pins: F23 — the aggregate renderer caps the whole rendered section at + // the token budget and drops ranked hits that do not fit, instead of a + // per-hit floor that let N hits reach 96·N tokens well past the budget. + let long_excerpt = "escalation ".repeat(60); + let hits = (0..8_u128) + .map(|index| fact_hit(Uuid::from_u128(index + 1), &long_excerpt)) + .collect::>(); + let token_budget = 200; + + let budgeted = render_memory_context_with_budget(&hits, token_budget); + + assert!(budgeted.hit_count >= 1, "at least one hit fits the budget"); + assert!( + budgeted.hit_count < hits.len(), + "excess ranked hits are omitted under the aggregate budget" + ); + assert!( + moa_core::estimate_text_tokens(&budgeted.rendered.section) <= token_budget, + "the rendered section stays within the aggregate token budget" + ); + assert_eq!( + budgeted.consumed_tokens, + moa_core::estimate_text_tokens(&budgeted.rendered.section), + "reported consumed tokens match the rendered section" + ); + } fn fact_hit(uid: Uuid, summary: &str) -> RetrievalHit { RetrievalHit { diff --git a/crates/moa-brain/src/retrieval/enrichment.rs b/crates/moa-brain/src/retrieval/enrichment.rs new file mode 100644 index 00000000..b86fc198 --- /dev/null +++ b/crates/moa-brain/src/retrieval/enrichment.rs @@ -0,0 +1,459 @@ +//! Bounded, batching background worker for best-effort post-retrieval enrichment. +//! +//! After a successful retrieval the hybrid retriever needs two best-effort side +//! writes — a `last_accessed_at` bump (memory decay input) and sampled retrieval +//! lineage (quality-score input). Previously each retrieval spawned detached +//! `tokio` tasks for these against the same Postgres pool that backs sessions, +//! authz, and graph work, with no queue bound, backpressure, or shutdown drain, +//! so under load they competed with durable writes and on shutdown were silently +//! dropped. +//! +//! This module funnels both through one bounded, instrumented worker: +//! +//! * Enqueue is a non-blocking `try_send`; when the fixed-capacity queue is full +//! the job is dropped with a metric (drop-on-overflow is correct for +//! best-effort enrichment — the retrieval path must never backpressure on it). +//! * Access bumps are coalesced within a batch window: one `UPDATE ... WHERE uid +//! = ANY($1)` per `(scope, role)` instead of one statement per retrieval. +//! * The worker drains its channel when all senders drop (shutdown), so queued +//! work is flushed rather than lost. +//! * Queue depth, drops, batch sizes, and flush latency are exported as metrics. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use moa_memory_types::MemoryScope; +use sqlx::PgPool; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use uuid::Uuid; + +use crate::retrieval::legs::{bump_last_accessed, write_retrieval_lineage}; +use crate::retrieval::types::{LineageContext, RetrievalLineageHit}; + +/// Fixed bound on queued enrichment jobs. Drop-on-overflow keeps the retrieval +/// path from ever backpressuring on best-effort enrichment. +const DEFAULT_QUEUE_CAPACITY: usize = 1024; +/// Maximum time a partially filled batch waits before flushing. +const DEFAULT_BATCH_WINDOW: Duration = Duration::from_millis(50); +/// Maximum number of jobs coalesced into one flush. +const DEFAULT_MAX_BATCH: usize = 256; + +/// A best-effort enrichment job produced after a successful retrieval. +enum EnrichmentJob { + /// Bump `last_accessed_at` for retrieved nodes in one scope. + AccessBump { + scope: MemoryScope, + uids: Vec, + assume_app_role: bool, + }, + /// Write sampled retrieval-lineage rows for one turn. + Lineage { + scope: MemoryScope, + lineage: LineageContext, + hits: Vec, + retrieved_at: DateTime, + assume_app_role: bool, + }, +} + +/// Applies enrichment writes. Abstracted behind a trait so the worker's +/// batching, coalescing, and drain logic is unit-testable without a database. +#[async_trait] +pub(crate) trait EnrichmentSink: Send + Sync { + /// Bumps `last_accessed_at` for `uids` within one scope. + async fn bump_access(&self, scope: MemoryScope, uids: Vec, assume_app_role: bool); + /// Writes sampled retrieval-lineage rows for one turn. + async fn write_lineage( + &self, + scope: MemoryScope, + lineage: LineageContext, + hits: Vec, + retrieved_at: DateTime, + assume_app_role: bool, + ); +} + +/// Production sink backed by the shared runtime Postgres pool. +struct PgEnrichmentSink { + pool: PgPool, +} + +#[async_trait] +impl EnrichmentSink for PgEnrichmentSink { + async fn bump_access(&self, scope: MemoryScope, uids: Vec, assume_app_role: bool) { + if let Err(error) = + bump_last_accessed(self.pool.clone(), scope, uids, assume_app_role).await + { + tracing::debug!( + error = %error, + "enrichment worker failed to bump graph-memory access timestamps" + ); + } + } + + async fn write_lineage( + &self, + scope: MemoryScope, + lineage: LineageContext, + hits: Vec, + retrieved_at: DateTime, + assume_app_role: bool, + ) { + if let Err(error) = write_retrieval_lineage( + self.pool.clone(), + scope, + lineage, + hits, + retrieved_at, + assume_app_role, + ) + .await + { + tracing::debug!( + error = %error, + "enrichment worker failed to write graph-memory retrieval lineage" + ); + } + } +} + +/// Non-blocking handle used by the retrieval path to enqueue enrichment. +#[derive(Clone)] +pub(crate) struct EnrichmentHandle { + tx: mpsc::Sender, + dropped: Arc, +} + +impl EnrichmentHandle { + /// Enqueues an access-timestamp bump; a no-op when `uids` is empty. + pub(crate) fn enqueue_access_bump( + &self, + scope: MemoryScope, + uids: Vec, + assume_app_role: bool, + ) { + if uids.is_empty() { + return; + } + self.try_send(EnrichmentJob::AccessBump { + scope, + uids, + assume_app_role, + }); + } + + /// Enqueues a retrieval-lineage write; a no-op when `hits` is empty. + pub(crate) fn enqueue_lineage( + &self, + scope: MemoryScope, + lineage: LineageContext, + hits: Vec, + retrieved_at: DateTime, + assume_app_role: bool, + ) { + if hits.is_empty() { + return; + } + self.try_send(EnrichmentJob::Lineage { + scope, + lineage, + hits, + retrieved_at, + assume_app_role, + }); + } + + fn try_send(&self, job: EnrichmentJob) { + let depth = self.tx.max_capacity().saturating_sub(self.tx.capacity()); + metrics::gauge!("moa_retrieval_enrichment_queue_depth").set(depth as f64); + match self.tx.try_send(job) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + metrics::counter!("moa_retrieval_enrichment_dropped_total").increment(1); + tracing::warn!( + dropped, + "retrieval enrichment queue full; dropping best-effort job" + ); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + // The worker has shut down; there is nothing left to enrich. + } + } + } + + #[cfg(test)] + fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +/// Spawns the single production enrichment worker over the shared pool. +/// +/// Must be called from within a Tokio runtime; callers without one skip +/// enrichment entirely (the handle is optional on the retriever). +pub(crate) fn spawn_enrichment_worker(pool: PgPool) -> EnrichmentHandle { + let (handle, _join) = spawn_with_sink( + Arc::new(PgEnrichmentSink { pool }), + DEFAULT_QUEUE_CAPACITY, + DEFAULT_BATCH_WINDOW, + DEFAULT_MAX_BATCH, + ); + handle +} + +/// Spawns a worker over an explicit sink and policy. The returned [`JoinHandle`] +/// completes once every sender has dropped and the channel has been drained. +fn spawn_with_sink( + sink: Arc, + capacity: usize, + batch_window: Duration, + max_batch: usize, +) -> (EnrichmentHandle, JoinHandle<()>) { + let (tx, rx) = mpsc::channel(capacity); + let worker = EnrichmentWorker { + sink, + rx, + batch_window, + max_batch, + }; + let join = tokio::spawn(worker.run()); + ( + EnrichmentHandle { + tx, + dropped: Arc::new(AtomicU64::new(0)), + }, + join, + ) +} + +struct EnrichmentWorker { + sink: Arc, + rx: mpsc::Receiver, + batch_window: Duration, + max_batch: usize, +} + +impl EnrichmentWorker { + async fn run(mut self) { + // `recv` yields `None` only once every sender has dropped and the buffer + // is empty, so this loop drains queued work on shutdown before exiting. + while let Some(first) = self.rx.recv().await { + let mut batch = vec![first]; + let deadline = tokio::time::Instant::now() + self.batch_window; + let mut senders_closed = false; + while batch.len() < self.max_batch { + match tokio::time::timeout_at(deadline, self.rx.recv()).await { + Ok(Some(job)) => batch.push(job), + Ok(None) => { + senders_closed = true; + break; + } + Err(_elapsed) => break, + } + } + metrics::histogram!("moa_retrieval_enrichment_batch_size").record(batch.len() as f64); + self.flush(batch).await; + if senders_closed { + break; + } + } + } + + async fn flush(&self, batch: Vec) { + let started = std::time::Instant::now(); + // Coalesce access bumps per (scope, role): the deduped union of uids is + // written as a single `UPDATE ... WHERE uid = ANY($1)` for each scope. + let mut bumps: HashMap<(MemoryScope, bool), HashSet> = HashMap::new(); + let mut lineage_jobs = Vec::new(); + for job in batch { + match job { + EnrichmentJob::AccessBump { + scope, + uids, + assume_app_role, + } => { + bumps + .entry((scope, assume_app_role)) + .or_default() + .extend(uids); + } + lineage @ EnrichmentJob::Lineage { .. } => lineage_jobs.push(lineage), + } + } + for ((scope, assume_app_role), uids) in bumps { + self.sink + .bump_access(scope, uids.into_iter().collect(), assume_app_role) + .await; + } + for job in lineage_jobs { + if let EnrichmentJob::Lineage { + scope, + lineage, + hits, + retrieved_at, + assume_app_role, + } = job + { + self.sink + .write_lineage(scope, lineage, hits, retrieved_at, assume_app_role) + .await; + } + } + metrics::histogram!("moa_retrieval_enrichment_flush_seconds") + .record(started.elapsed().as_secs_f64()); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use moa_core::SessionId; + + use super::*; + + #[derive(Default)] + struct CountingSink { + bumps: Mutex, bool)>>, + lineage_writes: Mutex, + } + + #[async_trait] + impl EnrichmentSink for CountingSink { + async fn bump_access( + &self, + scope: MemoryScope, + mut uids: Vec, + assume_app_role: bool, + ) { + uids.sort(); + self.bumps.lock().expect("counting sink bumps lock").push(( + scope, + uids, + assume_app_role, + )); + } + + async fn write_lineage( + &self, + _scope: MemoryScope, + _lineage: LineageContext, + _hits: Vec, + _retrieved_at: DateTime, + _assume_app_role: bool, + ) { + *self + .lineage_writes + .lock() + .expect("counting sink lineage lock") += 1; + } + } + + fn scope() -> MemoryScope { + MemoryScope::Tenant { + tenant_id: moa_core::TenantId::new(), + } + } + + fn lineage_context() -> LineageContext { + LineageContext { + session_id: SessionId::new(), + turn_id: None, + turn_seq: 1, + } + } + + #[tokio::test] + async fn enqueue_never_blocks_and_drops_with_metric_when_queue_is_full() { + // Pins: F20 — enqueue is non-blocking; once the fixed-capacity queue is + // full, further jobs are dropped (counted) rather than backpressuring the + // retrieval path. Holds the receiver unconsumed so the queue stays full. + let (tx, _rx) = mpsc::channel::(2); + let handle = EnrichmentHandle { + tx, + dropped: Arc::new(AtomicU64::new(0)), + }; + let uid = Uuid::now_v7(); + + // Fill the two-slot queue. + handle.enqueue_access_bump(scope(), vec![uid], false); + handle.enqueue_access_bump(scope(), vec![uid], false); + assert_eq!(handle.dropped(), 0, "enqueues within capacity are accepted"); + + // The next two enqueues overflow and are dropped, not blocked. + handle.enqueue_access_bump(scope(), vec![uid], false); + handle.enqueue_access_bump(scope(), vec![uid], false); + assert_eq!( + handle.dropped(), + 2, + "overflow jobs are dropped with a counter" + ); + } + + #[tokio::test] + async fn access_bumps_coalesce_into_one_deduped_batched_update() { + // Pins: F20 — access bumps within one batch coalesce to a single sink call + // per scope carrying the deduped union of uids, instead of one write each. + let sink = Arc::new(CountingSink::default()); + let (handle, join) = spawn_with_sink(sink.clone(), 1024, Duration::from_secs(10), 256); + let scope = scope(); + let u1 = Uuid::from_u128(1); + let u2 = Uuid::from_u128(2); + let u3 = Uuid::from_u128(3); + + handle.enqueue_access_bump(scope.clone(), vec![u1, u2], false); + handle.enqueue_access_bump(scope.clone(), vec![u2, u3], false); + // Dropping the handle closes the channel, so the worker drains both queued + // bumps into one final batch and flushes deterministically. + drop(handle); + join.await.expect("worker joins after draining"); + + let bumps = sink.bumps.lock().expect("bumps"); + assert_eq!(bumps.len(), 1, "the two bumps coalesce into one sink call"); + let (bumped_scope, mut uids, role) = bumps[0].clone(); + uids.sort(); + assert_eq!(bumped_scope, scope); + assert!(!role); + assert_eq!( + uids, + vec![u1, u2, u3], + "coalesced uids are the deduped union" + ); + } + + #[tokio::test] + async fn shutdown_drains_queued_work() { + // Pins: F20 — queued enrichment is flushed on shutdown (all senders drop), + // not silently discarded. + let sink = Arc::new(CountingSink::default()); + let (handle, join) = spawn_with_sink(sink.clone(), 1024, Duration::from_secs(10), 256); + + for _ in 0..5 { + handle.enqueue_lineage( + scope(), + lineage_context(), + vec![RetrievalLineageHit { + uid: Uuid::now_v7(), + chunk_uid: None, + document_version_uid: None, + }], + Utc::now(), + false, + ); + } + drop(handle); + join.await.expect("worker drains and joins on shutdown"); + + assert_eq!( + *sink.lineage_writes.lock().expect("lineage writes"), + 5, + "all queued lineage writes are flushed on shutdown" + ); + } +} diff --git a/crates/moa-brain/src/retrieval/hybrid.rs b/crates/moa-brain/src/retrieval/hybrid.rs index 721f3625..230559c4 100644 --- a/crates/moa-brain/src/retrieval/hybrid.rs +++ b/crates/moa-brain/src/retrieval/hybrid.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use moa_core::MoaConfig; use moa_core::RlsContext; use moa_db::ScopedConn; -use moa_memory_graph::{GraphStore, NodeIndexRow, NodeLabel}; +use moa_memory_graph::{GraphError, GraphStore, NodeIndexRow, NodeLabel}; use moa_memory_types::MemoryScope; use moa_memory_vector::{Error as VectorError, PgvectorStore, TurbopufferStore}; use moa_providers::{ConfiguredReranker, Reranker, build_reranker_from_config}; @@ -20,14 +20,14 @@ use sqlx::PgPool; use uuid::Uuid; use crate::planning::Strategy; +use crate::retrieval::enrichment::EnrichmentHandle; use crate::retrieval::graph_seed::{ GraphSeedPlan, hydrate_graph_seed_rows, interim_graph_seed_plan, semantic_entity_seed_uids, }; use crate::retrieval::legs::{ GRAPH_BUDGET, GRAPH_WEIGHT, LEXICAL_BUDGET, LEXICAL_WEIGHT, LegCandidate, RRF_K, VECTOR_BUDGET, - VECTOR_WEIGHT, bump_last_accessed, graph_expansion_leg_with_diagnostics, hydrate_nodes, - lexical_leg, rrf_fuse, timed_leg, turbopuffer_bm25_leg, vector_leg as run_vector_leg, - walk_scoring, write_retrieval_lineage, + VECTOR_WEIGHT, graph_expansion_leg_with_diagnostics, hydrate_nodes, lexical_leg, rrf_fuse, + timed_leg, turbopuffer_bm25_leg, vector_leg as run_vector_leg, walk_scoring, }; use crate::retrieval::policy::{GraphRetrievalPolicy, effective_graph_policy}; use crate::retrieval::ranking::{ @@ -61,6 +61,7 @@ pub struct HybridRetriever { lineage_enabled: bool, lineage_sample_rate: f64, graph_policy: GraphRetrievalPolicy, + enrichment: Option, } /// Deterministically decides whether one turn's retrieval writes lineage rows. @@ -108,9 +109,19 @@ impl HybridRetriever { lineage_enabled: false, lineage_sample_rate: 1.0, graph_policy: GraphRetrievalPolicy::default(), + enrichment: None, } } + /// Attaches the shared bounded enrichment worker used for post-retrieval + /// `last_accessed_at` bumps and sampled lineage writes. When absent, those + /// best-effort writes are skipped. + #[must_use] + pub(crate) fn with_enrichment(mut self, enrichment: Option) -> Self { + self.enrichment = enrichment; + self + } + /// Creates a hybrid retriever from shared config. #[must_use] pub fn from_config( @@ -241,9 +252,13 @@ impl HybridRetriever { LEXICAL_BUDGET, self.lexical_leg(&req, &backend_state), ); - let (vector_hits, lexical_hits) = tokio::join!(vector_future, lexical_future); - let mut vector_hits = vector_hits?; - let mut lexical_hits = lexical_hits?; + let (vector_outcome, lexical_outcome) = tokio::join!(vector_future, lexical_future); + // Reduce each leg to its usable hits: a fatal error aborts, a transient + // error or timeout degrades to empty while the peer leg's hits are kept. + let vector_leg = reduce_leg("vector", vector_outcome)?; + let vector_timed_out = vector_leg.timed_out; + let mut vector_hits = vector_leg.value; + let mut lexical_hits = reduce_leg("lexical", lexical_outcome)?.value; apply_lexical_boost_only_policy(&req, &vector_hits, &mut lexical_hits); let interim = rrf_fuse( &[], @@ -284,7 +299,7 @@ impl HybridRetriever { Default::default() } else { let graph_started = std::time::Instant::now(); - let mut output = run_leg( + let graph_outcome = run_leg( req.disable_leg_timeouts, "graph", GRAPH_BUDGET, @@ -299,7 +314,8 @@ impl HybridRetriever { self.ranking_config.graph_rescue_evidence_floor, ), ) - .await?; + .await; + let mut output = reduce_leg("graph", graph_outcome)?.value; output.diagnostics.graph_latency_ms = duration_ms_u64(graph_started.elapsed()); output }; @@ -320,12 +336,21 @@ impl HybridRetriever { if fused.is_empty() && should_retry_vector_after_empty_fusion( &req, - &vector_hits, + vector_timed_out, &lexical_hits.candidates, &graph_hits, ) { - vector_hits = self.vector_leg(&req, &backend_state).await?; + // Retry only an observed timeout, and under the same leg budget so a + // slow backend cannot turn one bounded timeout into an uncapped tail. + let retry_outcome = run_leg( + req.disable_leg_timeouts, + "vector", + VECTOR_BUDGET, + self.vector_leg(&req, &backend_state), + ) + .await; + vector_hits = reduce_leg("vector", retry_outcome)?.value; fused = rrf_fuse( &graph_hits, &vector_hits, @@ -379,45 +404,35 @@ impl HybridRetriever { metrics::histogram!("moa_retrieval_rrf_rerank_seconds") .record(fusion_started.elapsed().as_secs_f64()); - if req.ranking_reference_time.is_none() { - let touched_uids = final_hits.iter().map(|hit| hit.uid).collect::>(); - let pool = self.pool.clone(); - let scope = req.scope.clone(); - let assume_app_role = self.assume_app_role; - tokio::spawn(async move { - if let Err(error) = - bump_last_accessed(pool, scope, touched_uids, assume_app_role).await - { - tracing::debug!(error = %error, "failed to bump graph-memory access timestamps"); - } - }); - } - if self.lineage_enabled - && let Some(lineage) = req.lineage - && lineage_turn_sampled(&lineage, self.lineage_sample_rate) - { - let ranked_hits = final_hits - .iter() - .map(RetrievalLineageHit::from_hit) - .collect::>(); - let pool = self.pool.clone(); - let scope = req.scope.clone(); - let assume_app_role = self.assume_app_role; - let retrieved_at = Utc::now(); - tokio::spawn(async move { - if let Err(error) = write_retrieval_lineage( - pool, - scope, + // F20: post-retrieval enrichment is best-effort and is routed through the + // shared bounded worker (coalesced, drop-on-overflow, drained on shutdown) + // instead of an unbounded detached task per retrieval. When no worker is + // configured (unit/eval retrievers) the writes are simply skipped. + if let Some(enrichment) = &self.enrichment { + if req.ranking_reference_time.is_none() { + let touched_uids = final_hits.iter().map(|hit| hit.uid).collect::>(); + enrichment.enqueue_access_bump( + req.scope.clone(), + touched_uids, + self.assume_app_role, + ); + } + if self.lineage_enabled + && let Some(lineage) = req.lineage + && lineage_turn_sampled(&lineage, self.lineage_sample_rate) + { + let ranked_hits = final_hits + .iter() + .map(RetrievalLineageHit::from_hit) + .collect::>(); + enrichment.enqueue_lineage( + req.scope.clone(), lineage, ranked_hits, - retrieved_at, - assume_app_role, - ) - .await - { - tracing::debug!(error = %error, "failed to write graph-memory retrieval lineage"); - } - }); + Utc::now(), + self.assume_app_role, + ); + } } Ok(RetrievalOutput { @@ -562,11 +577,24 @@ impl HybridRetriever { hits: &[RetrievalHit], ) -> Result> { let documents = hits.iter().map(rerank_document).collect::>(); - let reranked = self + let reranked = match self .reranker .rerank(&self.rerank_model, &req.query_text, &documents, req.k_final) .await - .map_err(|error| RetrievalError::Rerank(error.to_string()))?; + { + Ok(reranked) => reranked, + // Reranking is a best-effort refinement over already-fused hits: a + // provider failure must not abort otherwise-usable results. Fall back + // to the fused pre-rerank order, mirroring the empty-output fallback. + Err(error) => { + record_leg_degraded("rerank", "error"); + tracing::warn!( + error = %error, + "reranker failed; falling back to fused pre-rerank order" + ); + return Ok(hits.iter().take(req.k_final).cloned().collect()); + } + }; let mut out = Vec::with_capacity(req.k_final.min(reranked.len())); for hit in reranked { if let Some(candidate) = hits.get(hit.index) { @@ -592,13 +620,16 @@ fn fused_candidate_limit(k_final: usize) -> usize { fn should_retry_vector_after_empty_fusion( req: &RetrievalRequest, - vector_hits: &[LegCandidate], + vector_timed_out: bool, lexical_hits: &[LegCandidate], graph_hits: &[LegCandidate], ) -> bool { + // Retry only when the vector leg specifically timed out: a genuinely empty + // vector result (or a degraded transient failure) is not masking candidates, + // so re-running it would only duplicate work. !req.disable_leg_timeouts && !req.query_embedding.is_empty() - && vector_hits.is_empty() + && vector_timed_out && lexical_hits.is_empty() && graph_hits.is_empty() } @@ -777,20 +808,160 @@ fn weights_for(strategy: Strategy) -> (f64, f64, f64) { } } -fn leg_or_empty( - name: &'static str, - result: std::result::Result, tokio::time::error::Elapsed>, -) -> Result -where - T: Default, -{ - match result { - Ok(Ok(value)) => Ok(value), - Ok(Err(error)) => Err(error), - Err(_) => { - tracing::debug!(leg = name, "hybrid retrieval leg exceeded budget"); - Ok(T::default()) +/// Classified outcome of one retrieval leg. +/// +/// Threads the *reason* a leg produced no usable hits — instead of collapsing +/// every non-success into an empty default — so the caller can keep a +/// successful peer leg, decide whether a timeout is worth one bounded retry, +/// and abort only on a fatal (RLS/privacy/scope/invalid-config) error. +enum LegOutcome { + /// The leg completed; `value` may be empty (a genuine empty result). + Completed(T), + /// The leg exceeded its time budget. + Timeout, + /// The leg hit a transient optional-backend/provider error; degrade it. + Transient(RetrievalError), + /// The leg hit an RLS/privacy/scope/invalid-config error; abort retrieval. + Fatal(RetrievalError), +} + +/// One leg reduced to its usable value plus degradation signals. +struct LegDegradation { + /// Usable hits (empty when the leg degraded or genuinely returned nothing). + value: T, + /// Whether the leg degraded (a timeout or transient error, not a real empty). + #[allow(dead_code)] + degraded: bool, + /// Whether the leg specifically timed out; gates the bounded vector retry. + timed_out: bool, +} + +/// Reduces a [`LegOutcome`] to usable hits. +/// +/// A completed leg passes through. A timeout or transient error degrades to an +/// empty result (recording a metric and warning) so the peer leg's hits are +/// kept. A fatal error aborts the whole retrieval. +fn reduce_leg(name: &'static str, outcome: LegOutcome) -> Result> { + match outcome { + LegOutcome::Completed(value) => Ok(LegDegradation { + value, + degraded: false, + timed_out: false, + }), + LegOutcome::Timeout => { + record_leg_degraded(name, "timeout"); + tracing::warn!( + leg = name, + "hybrid retrieval leg exceeded budget; degrading to empty and keeping peer legs" + ); + Ok(LegDegradation { + value: T::default(), + degraded: true, + timed_out: true, + }) } + LegOutcome::Transient(error) => { + record_leg_degraded(name, "transient"); + tracing::warn!( + leg = name, + error = %error, + "hybrid retrieval leg failed transiently; degrading to empty and keeping peer legs" + ); + Ok(LegDegradation { + value: T::default(), + degraded: true, + timed_out: false, + }) + } + LegOutcome::Fatal(error) => Err(error), + } +} + +fn record_leg_degraded(leg: &'static str, reason: &'static str) { + metrics::counter!("moa_retrieval_leg_degraded_total", "leg" => leg, "reason" => reason) + .increment(1); +} + +fn classify_leg_result(error: RetrievalError) -> LegOutcome { + if is_fatal_retrieval_error(&error) { + LegOutcome::Fatal(error) + } else { + LegOutcome::Transient(error) + } +} + +/// Classifies a retrieval error as fatal (abort) or transient (degrade). +/// +/// Fatal covers RLS/privacy/scope failures and invalid-configuration/contract +/// errors that will not self-heal and must not be silently degraded. Transient +/// covers ordinary optional backend, provider, network, and query failures that +/// should degrade one leg while its peers are kept. +fn is_fatal_retrieval_error(error: &RetrievalError) -> bool { + match error { + // Scope/RLS transaction setup failed: privacy boundary, abort. + RetrievalError::Scope(_) => true, + // A direct sidecar query failure in the fusion/hydration path is an + // ordinary backend error. + RetrievalError::Sqlx(_) => false, + RetrievalError::Graph(error) => is_fatal_graph_error(error), + RetrievalError::Vector(error) => is_fatal_vector_error(error), + } +} + +fn is_fatal_graph_error(error: &GraphError) -> bool { + match error { + // RLS/privacy/scope invariants. + GraphError::RlsDenied | GraphError::MissingScope | GraphError::Scope(_) => true, + // Invalid data/config invariants that will not self-heal. + GraphError::MissingEmbeddingMetadata + | GraphError::Conflict(_) + | GraphError::BiTemporal(_) + | GraphError::UnknownNodeLabel(_) + | GraphError::UnknownEdgeLabel(_) + | GraphError::UnknownPiiClass(_) + | GraphError::ChangelogScopeMismatch { .. } + | GraphError::InvalidChangelogScope => true, + // Ordinary backend/query failures degrade this leg. + GraphError::GraphQuery(_) + | GraphError::Sidecar(_) + | GraphError::NotFound(_) + | GraphError::Json(_) => false, + // Delegate to the vector classification. + GraphError::Vector(error) => is_fatal_vector_error(error), + } +} + +fn is_fatal_vector_error(error: &VectorError) -> bool { + match error { + // Embedding/index shape, privacy, backend-capability, and configuration + // invariants: retrying or degrading would hide a real misconfiguration. + VectorError::DimensionMismatch { .. } + | VectorError::UnknownPiiClass(_) + | VectorError::EmbedderConfig(_) + | VectorError::EmbedderMismatch { .. } + | VectorError::StoragePartitionEmbedderStateMissing { .. } + | VectorError::EmbedderModelMismatch { .. } + | VectorError::QueryLimitTooLarge(_) + | VectorError::StoragePartitionRequired { .. } + | VectorError::TurbopufferUnavailable { .. } + | VectorError::TurbopufferBaaRequired { .. } + | VectorError::TurbopufferConfig(_) + | VectorError::PromotionValidationFailed { .. } + | VectorError::InvalidPromotionState { .. } + | VectorError::TransactionalWritesUnsupported(_) + | VectorError::InvalidVectorSyncOperation(_) + | VectorError::UnsupportedVectorBackend { .. } + | VectorError::UnsupportedQueryFeature { .. } => true, + // Transient backend/provider/network/response failures degrade the leg. + VectorError::EmbeddingResponseLength { .. } + | VectorError::ProviderStatus { .. } + | VectorError::VectorProviderStatus { .. } + | VectorError::ReembedInProgress { .. } + | VectorError::TurbopufferResponse(_) + | VectorError::Core(_) + | VectorError::Sqlx(_) + | VectorError::Reqwest(_) + | VectorError::SerdeJson(_) => false, } } @@ -799,15 +970,22 @@ async fn run_leg( name: &'static str, budget: std::time::Duration, future: F, -) -> Result +) -> LegOutcome where T: Default, F: std::future::Future>, { if disable_timeout { - return future.await; + return match future.await { + Ok(value) => LegOutcome::Completed(value), + Err(error) => classify_leg_result(error), + }; + } + match timed_leg(name, budget, future).await { + Ok(Ok(value)) => LegOutcome::Completed(value), + Ok(Err(error)) => classify_leg_result(error), + Err(_elapsed) => LegOutcome::Timeout, } - leg_or_empty(name, timed_leg(name, budget, future).await) } fn build_hits(fused: Vec<(Uuid, f64, LegSources)>, nodes: Vec) -> Vec { @@ -2366,49 +2544,206 @@ mod tests { } #[test] - fn empty_fusion_retries_vector_when_timeout_can_mask_candidates() { - // Pins: a timed-out vector leg can otherwise make retrieval return no - // candidates for an embedded query; one uncapped vector retry is allowed. + fn empty_fusion_retries_vector_only_after_an_observed_timeout() { + // Pins: F09 — a timed-out vector leg can mask candidates for an embedded + // query, so exactly one bounded retry is allowed. A genuinely empty (or + // transiently degraded) vector leg is NOT retried, avoiding duplicate work. let req = vector_request(); - assert!(should_retry_vector_after_empty_fusion(&req, &[], &[], &[])); + assert!(should_retry_vector_after_empty_fusion(&req, true, &[], &[])); + assert!(!should_retry_vector_after_empty_fusion( + &req, + false, + &[], + &[] + )); } #[test] - fn empty_fusion_vector_retry_stays_off_when_any_leg_has_candidates() { - // Pins: the retry is only for complete candidate loss, not for normal - // low-recall or graph/lexical-only retrieval states. + fn empty_fusion_vector_retry_stays_off_when_a_peer_leg_has_candidates() { + // Pins: the timeout retry is only for complete candidate loss, not when + // lexical or graph already produced candidates. let req = vector_request(); let candidate = leg_candidate(Uuid::from_u128(1)); assert!(!should_retry_vector_after_empty_fusion( &req, + true, &[candidate], - &[], &[] )); assert!(!should_retry_vector_after_empty_fusion( &req, + true, &[], - &[candidate], - &[] + &[candidate] )); + } + + #[test] + fn empty_fusion_vector_retry_respects_timeout_override() { + // Pins: callers that disabled leg timeouts asked for uncapped execution, + // so there is no bounded timeout to retry. + let mut req = vector_request(); + req.disable_leg_timeouts = true; + assert!(!should_retry_vector_after_empty_fusion( &req, + true, &[], - &[], - &[candidate] + &[] )); } + #[tokio::test] + async fn run_leg_classifies_success_transient_fatal_and_timeout() { + // Pins: F09 — run_leg threads the reason a leg produced no hits instead of + // collapsing everything to an empty default. + let success = run_leg::, _>(false, "vector", VECTOR_BUDGET, async { + Ok(vec![leg_candidate(Uuid::from_u128(1))]) + }) + .await; + assert!(matches!(success, LegOutcome::Completed(ref hits) if hits.len() == 1)); + + let transient = run_leg::, _>(false, "vector", VECTOR_BUDGET, async { + Err(RetrievalError::Vector(VectorError::VectorProviderStatus { + provider: "turbopuffer", + status: 503, + body: "unavailable".to_string(), + })) + }) + .await; + assert!(matches!(transient, LegOutcome::Transient(_))); + + let fatal = run_leg::, _>(false, "vector", VECTOR_BUDGET, async { + Err(RetrievalError::Vector( + VectorError::TurbopufferUnavailable { + storage_partition_id: "sp".to_string(), + }, + )) + }) + .await; + assert!(matches!(fatal, LegOutcome::Fatal(_))); + + let timeout = run_leg::, _>( + false, + "vector", + std::time::Duration::from_millis(1), + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + Ok(Vec::new()) + }, + ) + .await; + assert!(matches!(timeout, LegOutcome::Timeout)); + } + #[test] - fn empty_fusion_vector_retry_respects_timeout_override() { - // Pins: callers that disabled leg timeouts already asked for uncapped - // leg execution, so the empty-fusion retry would only duplicate work. + fn reduce_leg_degrades_transient_and_timeout_but_aborts_fatal() { + // Pins: F09 — a transient error or timeout degrades one leg to empty + // (keeping peers), while a fatal error aborts. This is the degrade-keeps- + // peer decision: mutating the Transient arm to return Err breaks this. + let transient = reduce_leg::>( + "vector", + LegOutcome::Transient(RetrievalError::Vector(VectorError::VectorProviderStatus { + provider: "turbopuffer", + status: 503, + body: "unavailable".to_string(), + })), + ) + .expect("a transient leg error must degrade, not abort"); + assert!(transient.value.is_empty()); + assert!(transient.degraded); + assert!(!transient.timed_out); + + let timeout = reduce_leg::>("vector", LegOutcome::Timeout) + .expect("a timed-out leg must degrade, not abort"); + assert!(timeout.value.is_empty()); + assert!(timeout.timed_out); + + let completed = reduce_leg::>( + "vector", + LegOutcome::Completed(vec![leg_candidate(Uuid::from_u128(1))]), + ) + .expect("a completed leg passes through"); + assert_eq!(completed.value.len(), 1); + assert!(!completed.degraded); + + let fatal = reduce_leg::>( + "vector", + LegOutcome::Fatal(RetrievalError::Scope(moa_core::MoaError::StorageError( + "rls setup failed".to_string(), + ))), + ); + assert!(fatal.is_err(), "a fatal leg error must abort retrieval"); + } + + #[test] + fn retrieval_error_classification_matches_fatal_transient_table() { + // Pins: F09 — RLS/privacy/scope/invalid-config errors are fatal; ordinary + // backend/provider/network/query errors are transient. + assert!(is_fatal_retrieval_error(&RetrievalError::Scope( + moa_core::MoaError::StorageError("scope".to_string()) + ))); + assert!(is_fatal_retrieval_error(&RetrievalError::Graph( + GraphError::RlsDenied + ))); + assert!(is_fatal_retrieval_error(&RetrievalError::Graph( + GraphError::MissingScope + ))); + assert!(is_fatal_retrieval_error(&RetrievalError::Vector( + VectorError::TurbopufferBaaRequired { + storage_partition_id: "sp".to_string(), + } + ))); + assert!(is_fatal_retrieval_error(&RetrievalError::Vector( + VectorError::DimensionMismatch { + expected: 1024, + actual: 768, + } + ))); + + assert!(!is_fatal_retrieval_error(&RetrievalError::Vector( + VectorError::VectorProviderStatus { + provider: "turbopuffer", + status: 503, + body: "down".to_string(), + } + ))); + assert!(!is_fatal_retrieval_error(&RetrievalError::Vector( + VectorError::ReembedInProgress { + storage_partition_id: "sp".to_string(), + } + ))); + assert!(!is_fatal_retrieval_error(&RetrievalError::Graph( + GraphError::GraphQuery("backend hiccup".to_string()) + ))); + } + + #[tokio::test] + async fn rerank_failure_falls_back_to_fused_pre_rerank_order() { + // Pins: F09 — a reranker provider failure must not abort otherwise-usable + // fused hits; it degrades to the fused pre-rerank order. + let pool = PgPool::connect_lazy("postgres://unused") + .expect("lazy pool construction should not connect"); + let retriever = HybridRetriever::new( + pool.clone(), + Arc::new(EmptyGraph), + lazy_pgvector_source(&pool), + ) + .with_reranker(Arc::new(FailingReranker)); let mut req = vector_request(); - req.disable_leg_timeouts = true; + req.k_final = 2; + req.use_reranker = true; + let first = hit(Uuid::from_u128(1), "workspace", 2.0); + let second = hit(Uuid::from_u128(2), "workspace", 1.0); + + let out = retriever + .rerank_hits(&req, &[first.clone(), second.clone()]) + .await + .expect("reranker failure must degrade, not abort"); - assert!(!should_retry_vector_after_empty_fusion(&req, &[], &[], &[])); + assert_eq!(out, vec![first, second]); } #[tokio::test] @@ -2900,6 +3235,23 @@ mod tests { } } + struct FailingReranker; + + #[async_trait] + impl Reranker for FailingReranker { + async fn rerank( + &self, + _model: &str, + _query: &str, + _documents: &[String], + _top_n: usize, + ) -> moa_core::Result> { + Err(moa_core::MoaError::ProviderError( + "injected rerank failure".to_string(), + )) + } + } + struct EmptyGraph; #[async_trait] diff --git a/crates/moa-brain/src/retrieval/mod.rs b/crates/moa-brain/src/retrieval/mod.rs index 070df27f..4f4713c7 100644 --- a/crates/moa-brain/src/retrieval/mod.rs +++ b/crates/moa-brain/src/retrieval/mod.rs @@ -2,6 +2,7 @@ pub mod admission; pub mod cache; +pub(crate) mod enrichment; mod graph_seed; pub mod hybrid; pub mod legs; diff --git a/crates/moa-brain/src/retrieval/types.rs b/crates/moa-brain/src/retrieval/types.rs index 6cc73fb6..27bb395f 100644 --- a/crates/moa-brain/src/retrieval/types.rs +++ b/crates/moa-brain/src/retrieval/types.rs @@ -33,9 +33,6 @@ pub enum RetrievalError { /// Scoped Postgres connection setup failed. #[error("scope setup: {0}")] Scope(#[from] moa_core::MoaError), - /// Reranking failed. - #[error("rerank: {0}")] - Rerank(String), } /// Retrieval request supplied by the query planner. diff --git a/crates/moa-core/src/config/analytics.rs b/crates/moa-core/src/config/analytics.rs new file mode 100644 index 00000000..7e16becf --- /dev/null +++ b/crates/moa-core/src/config/analytics.rs @@ -0,0 +1,107 @@ +//! Per-query budgets for the analytics serving path. +//! +//! Analytics validation caps the returned rows and dimensions, but without a +//! wall-clock budget an interactive dashboard query (for example an exact +//! ordered percentile) can scan and sort a tenant's full event history. These +//! knobs bound the database work each query is allowed to perform on both the +//! Postgres materialized-view backend and the ClickHouse read models. + +use serde::{Deserialize, Serialize}; + +use crate::{MoaError, Result}; + +/// Per-query budgets applied by the analytics executors. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AnalyticsConfig { + /// Postgres `statement_timeout` applied to each analytics query transaction, + /// in milliseconds. A runaway scan is cancelled server-side instead of + /// holding a connection open indefinitely. + pub statement_timeout_ms: u64, + /// ClickHouse `max_execution_time` applied to each analytics query, in + /// seconds. + pub clickhouse_max_execution_time_secs: u64, + /// ClickHouse `max_rows_to_read` applied to each analytics query. + pub clickhouse_max_rows_to_read: u64, + /// ClickHouse `max_bytes_to_read` applied to each analytics query. + pub clickhouse_max_bytes_to_read: u64, +} + +impl Default for AnalyticsConfig { + fn default() -> Self { + Self { + statement_timeout_ms: 10_000, + clickhouse_max_execution_time_secs: 10, + clickhouse_max_rows_to_read: 1_000_000_000, + clickhouse_max_bytes_to_read: 10_000_000_000, + } + } +} + +impl AnalyticsConfig { + /// Validates that every budget is a positive, enforceable value. + pub fn validate(&self) -> Result<()> { + if self.statement_timeout_ms == 0 { + return Err(MoaError::ConfigError( + "analytics.statement_timeout_ms must be greater than zero".to_string(), + )); + } + if self.clickhouse_max_execution_time_secs == 0 { + return Err(MoaError::ConfigError( + "analytics.clickhouse_max_execution_time_secs must be greater than zero" + .to_string(), + )); + } + if self.clickhouse_max_rows_to_read == 0 { + return Err(MoaError::ConfigError( + "analytics.clickhouse_max_rows_to_read must be greater than zero".to_string(), + )); + } + if self.clickhouse_max_bytes_to_read == 0 { + return Err(MoaError::ConfigError( + "analytics.clickhouse_max_bytes_to_read must be greater than zero".to_string(), + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_budgets_are_positive_and_valid() { + // Pins: the default analytics budgets are enforceable (all non-zero). + let config = AnalyticsConfig::default(); + config.validate().expect("default budgets validate"); + assert_eq!(config.statement_timeout_ms, 10_000); + } + + #[test] + fn rejects_zero_budgets() { + // Pins: a zero budget disables enforcement and is rejected at startup. + for config in [ + AnalyticsConfig { + statement_timeout_ms: 0, + ..AnalyticsConfig::default() + }, + AnalyticsConfig { + clickhouse_max_execution_time_secs: 0, + ..AnalyticsConfig::default() + }, + AnalyticsConfig { + clickhouse_max_rows_to_read: 0, + ..AnalyticsConfig::default() + }, + AnalyticsConfig { + clickhouse_max_bytes_to_read: 0, + ..AnalyticsConfig::default() + }, + ] { + config + .validate() + .expect_err("zero budget should be rejected"); + } + } +} diff --git a/crates/moa-core/src/config/mod.rs b/crates/moa-core/src/config/mod.rs index 64c060d9..d9c9d30d 100644 --- a/crates/moa-core/src/config/mod.rs +++ b/crates/moa-core/src/config/mod.rs @@ -1,5 +1,6 @@ //! Configuration for MOA, organized by sub-domain. +mod analytics; mod async_authz; mod audit_security; mod auth; @@ -24,6 +25,7 @@ mod session; mod telemetry; mod token_vault; +pub use analytics::AnalyticsConfig; pub use async_authz::{AsyncAuthzConfig, AsyncAuthzKind}; pub use audit_security::AuditSecurityConfig; pub use auth::{ @@ -122,6 +124,8 @@ pub struct MoaConfig { /// Optional ClickHouse analytics store; when present, high-volume /// append-only analytics rows are stored in ClickHouse instead of Postgres. pub clickhouse: Option, + /// Per-query analytics budgets (Postgres statement timeout, ClickHouse limits). + pub analytics: AnalyticsConfig, /// Prometheus metrics export settings. pub metrics: MetricsConfig, /// Tenant budget enforcement settings. @@ -199,6 +203,7 @@ impl MoaConfig { if let Some(clickhouse) = &self.clickhouse { clickhouse.validate()?; } + self.analytics.validate()?; Ok(()) } diff --git a/crates/moa-core/src/events.rs b/crates/moa-core/src/events.rs index 222b65ad..ac6173cf 100644 --- a/crates/moa-core/src/events.rs +++ b/crates/moa-core/src/events.rs @@ -530,7 +530,105 @@ pub enum Event { }, } +/// Effect a persisted [`Event`] has on session turn scheduling. +/// +/// Every event is classified into exactly one effect by +/// [`Event::processing_effect`] so the reverse tail scan in +/// [`crate::session_engine::session_requires_processing`] can decide whether +/// turn work is still pending without any wildcard fallback. Because the +/// classification match is exhaustive, adding a new [`Event`] variant fails to +/// compile until its effect is declared deliberately — closing the class of +/// bugs where an asynchronously appended passive event (for example a detached +/// `ProgressNarrated` or a watchdog `WorkerHeartbeatStale` landing just after a +/// `ToolResult`) silently masked pending work and stalled the turn loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProcessingEffect { + /// The event carries unaddressed work: if it is the newest meaningful event + /// in the tail, a model turn must be compiled. Tool call/result/error + /// events, fresh user messages, and system-seeded coordinator + /// resume/synthesis requests are triggers. + Trigger, + /// The event is a passive breadcrumb — telemetry, liveness, enrichment, or a + /// lifecycle marker — that neither demands a turn nor concludes one. The tail + /// scan looks straight through it to older events, so a late asynchronous + /// append can never mask an earlier trigger. + Neutral, + /// The event concludes or suspends the turn loop: an assistant response, + /// successful session completion, a turn-halting error, or a tenant-admin + /// action review that is resumed by a separate decision handler rather than + /// by re-scanning the tail. If it is the newest meaningful event, no turn is + /// pending and the scan stops. + Terminal, +} + impl Event { + /// Classifies how this event affects session turn scheduling. + /// + /// See [`ProcessingEffect`] for the meaning of each effect. The match is + /// deliberately exhaustive with no wildcard arm so that a newly added + /// [`Event`] variant forces an explicit scheduling decision at compile time. + pub fn processing_effect(&self) -> ProcessingEffect { + match self { + // Triggers: unaddressed work that a model turn must consume. + Self::UserMessage { .. } + | Self::ToolCall { .. } + | Self::ToolResult { .. } + | Self::ToolError { .. } + // A guarded coordinator resume seeds its instruction via this control + // event (not a fake user message), so a trailing resume must drive the loop. + | Self::WorkerParentResumeRequested { .. } + // Completed deterministic auto-delegation seeds a synthesis turn via this + // control event, not another fake user message. + | Self::WorkerResultSynthesisRequested { .. } => ProcessingEffect::Trigger, + + // Terminals: the turn loop has concluded or is suspended awaiting an + // out-of-band decision; the tail alone implies no pending model turn. + Self::BrainResponse { .. } + | Self::SessionCompleted { .. } + // An error halts the turn; recovery is driven by durable Restate retry, + // not by re-scanning and re-triggering the tail. + | Self::Error { .. } + // Tenant-admin action review state is resumed by the decision handler + // (which appends the follow-on tool result), not by the scheduler + // re-reading the tail, so neither review event resumes the loop itself. + | Self::ActionReviewRequested { .. } + | Self::ActionReviewDecided { .. } => ProcessingEffect::Terminal, + + // Neutrals: passive telemetry, liveness, enrichment, and lifecycle + // breadcrumbs. Several are appended asynchronously off the turn path + // (ProgressNarrated, WorkerHeartbeatStale, TurnMetrics, CacheReport, + // MemoryRead, MemoryIngest, BrainThinking); classifying them transparent + // is precisely what stops a late append from masking pending work. + Self::SessionCreated { .. } + | Self::SessionStatusChanged { .. } + | Self::SessionChannelChanged { .. } + | Self::SegmentStarted { .. } + | Self::SegmentCompleted { .. } + | Self::QueuedMessage { .. } + | Self::BrainThinking { .. } + | Self::ProgressUpdate { .. } + | Self::GuardrailCheck { .. } + | Self::WorkerSpawned { .. } + | Self::WorkerMessageSent { .. } + | Self::WorkerStatusChanged { .. } + | Self::WorkerNotificationDelivered { .. } + | Self::WorkerResultBundle { .. } + | Self::TurnMetrics { .. } + | Self::WorkerSignalReceived { .. } + | Self::WorkerHeartbeatStale { .. } + | Self::ProgressNarrated { .. } + | Self::MemoryRead { .. } + | Self::MemoryWrite { .. } + | Self::MemoryIngest { .. } + | Self::HandProvisioned { .. } + | Self::HandDestroyed { .. } + | Self::HandError { .. } + | Self::Checkpoint { .. } + | Self::CacheReport { .. } + | Self::Warning { .. } => ProcessingEffect::Neutral, + } + } + /// Returns the event discriminator. pub fn event_type(&self) -> EventType { EventType::from(self) @@ -1036,6 +1134,102 @@ mod tests { ); } + #[test] + fn processing_effect_classifies_scheduling_contract() { + // Pins: the turn-scheduling classification (F04). Triggers carry pending work; + // terminals conclude or suspend the loop; the asynchronously appended passive + // vectors (ProgressNarrated, WorkerHeartbeatStale, TurnMetrics, CacheReport, + // MemoryRead/Ingest, BrainThinking) are Neutral so they cannot mask a trigger. + use crate::ProcessingEffect; + + let triggers = [ + Event::UserMessage { + text: "hi".to_string(), + attachments: Vec::new(), + }, + Event::ToolResult { + tool_id: ToolCallId::new(), + provider_tool_use_id: None, + output: crate::ToolOutput::text("ok", std::time::Duration::from_millis(1)), + original_output_tokens: None, + success: true, + duration_ms: 1, + }, + Event::WorkerResultSynthesisRequested { + user_sequence_num: 1, + turn_id: "turn-1".to_string(), + reason: "bundle ready".to_string(), + }, + ]; + for event in triggers { + assert_eq!( + event.processing_effect(), + ProcessingEffect::Trigger, + "{event:?} must be a Trigger" + ); + } + + let terminals = [ + Event::BrainResponse { + text: "done".to_string(), + thought_signature: None, + model: ModelId::new("anthropic:claude-sonnet-4-6"), + model_tier: ModelTier::Main, + input_tokens_uncached: 1, + input_tokens_cache_write: 0, + input_tokens_cache_read: 0, + output_tokens: 1, + cost_cents: 0, + duration_ms: 1, + }, + Event::SessionCompleted { + summary: "wrapped".to_string(), + total_turns: 1, + }, + Event::Error { + message: "boom".to_string(), + recoverable: false, + }, + ]; + for event in terminals { + assert_eq!( + event.processing_effect(), + ProcessingEffect::Terminal, + "{event:?} must be Terminal" + ); + } + + let neutrals = [ + Event::ProgressNarrated { + source: NarrationSource::Coordinator, + text: "working".to_string(), + segments: Vec::new(), + model: "none".to_string(), + tokens_used: 0, + }, + Event::WorkerHeartbeatStale { + worker_id: "child-1".to_string(), + last_heartbeat_at: Utc::now(), + threshold_ms: 30_000, + }, + Event::MemoryRead { + path: "notes".to_string(), + scope: "session".to_string(), + }, + Event::BrainThinking { + summary: "thinking".to_string(), + token_count: 3, + }, + ]; + for event in neutrals { + assert_eq!( + event.processing_effect(), + ProcessingEffect::Neutral, + "{event:?} must be Neutral" + ); + } + } + #[test] fn guardrail_check_event_is_metadata_only_guardrail() { // Pins: guardrail audit events persist metadata without raw guarded text. diff --git a/crates/moa-core/src/lib.rs b/crates/moa-core/src/lib.rs index c6718769..2763609f 100644 --- a/crates/moa-core/src/lib.rs +++ b/crates/moa-core/src/lib.rs @@ -38,7 +38,7 @@ pub use coordination_counters::{ }; pub use diff::compute_unified_diff; pub use error::{MoaError, Result, ToolFailureClass, classify_tool_error}; -pub use events::Event; +pub use events::{Event, ProcessingEffect}; pub use session_replay::{ TurnReplayCounters, TurnReplaySnapshot, record_pipeline_compile_duration, record_session_event_replay, scope_turn_replay_counters, diff --git a/crates/moa-core/src/session_engine.rs b/crates/moa-core/src/session_engine.rs index a660f33b..1269a207 100644 --- a/crates/moa-core/src/session_engine.rs +++ b/crates/moa-core/src/session_engine.rs @@ -1,8 +1,17 @@ //! Shared session-lifecycle rules used by multiple orchestrator adapters. -use crate::{Event, EventRecord, SessionMeta, SessionStatus}; +use crate::{EventRecord, ProcessingEffect, SessionMeta, SessionStatus}; /// Returns whether the persisted session log indicates more work is required. +/// +/// The tail is scanned newest-first and each event is classified by +/// [`crate::Event::processing_effect`]. [`ProcessingEffect::Neutral`] events — +/// passive telemetry, liveness, enrichment, and lifecycle breadcrumbs, several +/// of which are appended asynchronously off the turn path — are skipped so they +/// cannot mask an older trigger. The first non-neutral event decides: +/// [`ProcessingEffect::Trigger`] means a model turn is pending; +/// [`ProcessingEffect::Terminal`] means the loop has concluded or is suspended. +/// A tail of only neutral events (or an empty tail) requires no processing. pub fn session_requires_processing(session: &SessionMeta, events: &[EventRecord]) -> bool { if matches!(session.status, SessionStatus::Cancelled) { return false; @@ -11,33 +20,10 @@ pub fn session_requires_processing(session: &SessionMeta, events: &[EventRecord] events .iter() .rev() - .find_map(|record| match record.event { - Event::SessionStatusChanged { .. } - | Event::Warning { .. } - | Event::ProgressUpdate { .. } - | Event::GuardrailCheck { .. } - | Event::MemoryWrite { .. } - | Event::HandDestroyed { .. } - | Event::HandError { .. } - | Event::Checkpoint { .. } - | Event::QueuedMessage { .. } - | Event::WorkerStatusChanged { .. } - | Event::WorkerNotificationDelivered { .. } - | Event::WorkerSignalReceived { .. } - | Event::WorkerResultBundle { .. } => None, - Event::UserMessage { .. } - | Event::ToolResult { .. } - | Event::ToolError { .. } - | Event::ToolCall { .. } - // A guarded coordinator resume seeds its instruction via this control event - // (not a fake user message), so a trailing resume request must drive the loop. - | Event::WorkerParentResumeRequested { .. } - // Completed deterministic auto-delegation similarly seeds a synthesis turn via - // this control event, not another fake user message. - | Event::WorkerResultSynthesisRequested { .. } => Some(true), - // Action reviews are tenant-admin state and do not resume the turn loop by themselves. - Event::ActionReviewRequested { .. } | Event::ActionReviewDecided { .. } => Some(false), - _ => Some(false), + .find_map(|record| match record.event.processing_effect() { + ProcessingEffect::Neutral => None, + ProcessingEffect::Trigger => Some(true), + ProcessingEffect::Terminal => Some(false), }) .unwrap_or(false) } @@ -47,9 +33,12 @@ mod tests { use chrono::Utc; use uuid::Uuid; + use std::time::Duration; + use crate::{ AgentSignalId, ChildSignalKind, Event, EventRecord, EventType, GuardrailDirection, - GuardrailMode, InputAudience, ModelId, SessionId, SessionMeta, SignalSeverity, WorkerState, + GuardrailMode, InputAudience, ModelId, ModelTier, NarrationSource, SessionId, SessionMeta, + SignalSeverity, ToolCallId, ToolOutput, WorkerState, }; use super::session_requires_processing; @@ -69,6 +58,111 @@ mod tests { } } + fn tool_result_event() -> Event { + Event::ToolResult { + tool_id: ToolCallId::new(), + provider_tool_use_id: None, + output: ToolOutput::text("done", Duration::from_millis(5)), + original_output_tokens: None, + success: true, + duration_ms: 5, + } + } + + fn brain_response_event() -> Event { + Event::BrainResponse { + text: "all set".to_string(), + thought_signature: None, + model: ModelId::new("anthropic:claude-sonnet-4-6"), + model_tier: ModelTier::Main, + input_tokens_uncached: 1, + input_tokens_cache_write: 0, + input_tokens_cache_read: 0, + output_tokens: 1, + cost_cents: 0, + duration_ms: 1, + } + } + + fn progress_narrated_event() -> Event { + Event::ProgressNarrated { + source: NarrationSource::Coordinator, + text: "still working".to_string(), + segments: Vec::new(), + model: "none".to_string(), + tokens_used: 0, + } + } + + fn stale_heartbeat_event() -> Event { + Event::WorkerHeartbeatStale { + worker_id: "worker-1".to_string(), + last_heartbeat_at: Utc::now(), + threshold_ms: 30_000, + } + } + + #[test] + fn detached_narration_does_not_mask_pending_tool_result() { + // Pins: F04 — default-on `ProgressNarrated` is appended by a detached job that can + // land just after a `ToolResult`. It must not end the tool loop with work pending. + let session = SessionMeta::default(); + let events = vec![ + record(1, tool_result_event()), + record(2, progress_narrated_event()), + ]; + + assert!(session_requires_processing(&session, &events)); + assert_eq!(events[1].event_type, EventType::ProgressNarrated); + } + + #[test] + fn stale_heartbeat_does_not_mask_pending_tool_result() { + // Pins: F04 — the async watchdog `WorkerHeartbeatStale` is a second asynchronously + // appended vector and must not mask a pending `ToolResult`. + let session = SessionMeta::default(); + let events = vec![ + record(1, tool_result_event()), + record(2, stale_heartbeat_event()), + ]; + + assert!(session_requires_processing(&session, &events)); + assert_eq!(events[1].event_type, EventType::WorkerHeartbeatStale); + } + + #[test] + fn terminal_brain_response_requires_no_processing() { + // Pins: a completed assistant response ends the turn loop even when passive + // telemetry (`ProgressNarrated`) is appended after it. + let session = SessionMeta::default(); + let events = vec![ + record( + 1, + Event::UserMessage { + text: "hello".to_string(), + attachments: Vec::new(), + }, + ), + record(2, brain_response_event()), + record(3, progress_narrated_event()), + ]; + + assert!(!session_requires_processing(&session, &events)); + } + + #[test] + fn neutral_only_tail_requires_no_processing() { + // Pins: a tail of only passive liveness/telemetry events requires no turn, so a + // late async append on an otherwise-idle session does not resurrect the loop. + let session = SessionMeta::default(); + let events = vec![ + record(1, stale_heartbeat_event()), + record(2, progress_narrated_event()), + ]; + + assert!(!session_requires_processing(&session, &events)); + } + #[test] fn guardrail_check_does_not_mask_pending_user_message_guardrail() { // Pins: guardrail audit events are informational and do not change turn scheduling. diff --git a/crates/moa-core/src/wire/privacy.rs b/crates/moa-core/src/wire/privacy.rs index 3d02ea9c..28db052d 100644 --- a/crates/moa-core/src/wire/privacy.rs +++ b/crates/moa-core/src/wire/privacy.rs @@ -141,6 +141,22 @@ pub enum ContactErasureScope { SpecifiedAndLinkedContacts, } +/// Terminal status of a privacy erase request. +/// +/// The erase handler is synchronous within its Restate operation, so a +/// successful call returns a terminal status. Non-terminal `running`/`failed` +/// states are persisted on the durable `moa.erasure_jobs` row for resume and +/// audit; a caller only observes them through job introspection, never as a +/// successful response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyEraseStatus { + /// Candidates were enumerated without writing any erasure. + DryRun, + /// Every attributable store reached its erased end state. + Completed, +} + /// Response payload for a privacy erase request. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PrivacyEraseResponse { @@ -148,12 +164,20 @@ pub struct PrivacyEraseResponse { pub tenant_id: TenantId, /// Subject user identifier erased. pub subject_user_id: UserId, + /// Terminal status of the erasure operation. + pub status: PrivacyEraseStatus, /// Number of candidate memory nodes found. pub candidate_count: u64, /// Number of memory nodes erased. pub erased_count: u64, /// Number of PII vault rows erased. pub pii_vault_erased: u64, + /// Number of standing memory-digest rows deleted. + #[serde(default)] + pub digest_deleted: u64, + /// Number of retrieval-lineage rows deleted. + #[serde(default)] + pub lineage_deleted: u64, /// Whether the request was a dry run. pub dry_run: bool, /// Sample erase candidates for dry-run output. diff --git a/crates/moa-edge/src/main.rs b/crates/moa-edge/src/main.rs index 76da0fc4..2d620ed0 100644 --- a/crates/moa-edge/src/main.rs +++ b/crates/moa-edge/src/main.rs @@ -92,9 +92,13 @@ async fn main() -> anyhow::Result<()> { .as_ref() .map(|clickhouse| Arc::new(moa_lineage_sink::ClickHouseStore::connect(clickhouse))), clickhouse_analytics: moa_config.clickhouse.as_ref().map(|clickhouse| { - Arc::new(moa_analytics::AnalyticsClickHouseClient::connect( - clickhouse, - )) + Arc::new( + moa_analytics::AnalyticsClickHouseClient::connect(clickhouse).with_query_budgets( + moa_config.analytics.clickhouse_max_execution_time_secs, + moa_config.analytics.clickhouse_max_rows_to_read, + moa_config.analytics.clickhouse_max_bytes_to_read, + ), + ) }), }; let listener = tokio::net::TcpListener::bind(&args.bind) diff --git a/crates/moa-edge/src/routes/analytics.rs b/crates/moa-edge/src/routes/analytics.rs index 35117a19..24f8bbaa 100644 --- a/crates/moa-edge/src/routes/analytics.rs +++ b/crates/moa-edge/src/routes/analytics.rs @@ -12,19 +12,8 @@ use moa_core::TenantId; use moa_core::traits::{Identity, IdentityType}; use moa_core::wire::analytics::AnalyticsQueryRequest; use serde::Deserialize; -use std::sync::{Mutex, OnceLock, PoisonError}; -use std::time::{Duration, Instant}; use uuid::Uuid; -/// Minimum spacing between analytics materialized-view refreshes, process-wide. -const MV_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(60); - -static LAST_MV_REFRESH: OnceLock>> = OnceLock::new(); - -fn last_mv_refresh() -> &'static Mutex> { - LAST_MV_REFRESH.get_or_init(|| Mutex::new(None)) -} - use super::{ AppState, RouteTranslation, authenticate_direct_request, parse_json_body, require_direct_authz, route_error, translate_json_object_with_tenant_id, @@ -97,8 +86,10 @@ pub(super) async fn handle_query( { return response; } - // ClickHouse serving needs no matview refresh; the throttled rebuild is a - // Postgres-backend concern only. + // The edge request path is read-only for both backends. Materialized-view + // refresh is owned by the durable maintenance cron (single-flighted under a + // Postgres advisory lock), not triggered per request; the query serves the + // current read-model state and reports its freshness. let result = if let Some(clickhouse) = state.clickhouse_analytics.as_deref() { let response = AnalyticsService::clickhouse() .query_clickhouse(clickhouse, request) @@ -112,8 +103,18 @@ pub(super) async fn handle_query( Err(error) => Err(error), } } else { - maybe_refresh_analytics_materialized_views(&state); - AnalyticsService::new().query(&state.pool, request).await + let response = AnalyticsService::new() + .with_statement_timeout_ms(state.config.analytics.statement_timeout_ms) + .query(&state.pool, request) + .await; + match response { + Ok(mut response) => { + response.metadata.read_model_updated_at = + postgres_read_model_updated_at(&state.pool).await; + Ok(response) + } + Err(error) => Err(error), + } }; match result { Ok(response) => Json(response).into_response(), @@ -135,6 +136,21 @@ async fn clickhouse_read_model_updated_at( .flatten() } +/// Freshness of the Postgres materialized-view read models: the last successful +/// maintenance refresh. `None` when no refresh has succeeded yet (or the state +/// table is missing) — absence of freshness data must not fail the query. +async fn postgres_read_model_updated_at( + pool: &sqlx::PgPool, +) -> Option> { + sqlx::query_scalar( + "SELECT last_success_at FROM analytics.materialized_view_refresh_state WHERE id", + ) + .fetch_one(pool) + .await + .ok() + .flatten() +} + fn analytics_target_tenant( identity: &Identity, tenant_id: Option, @@ -155,36 +171,6 @@ fn analytics_error_response(error: AnalyticsError) -> Response { } } -/// Trigger an analytics materialized-view refresh at most once per interval. -/// -/// The refresh runs in the background so the current request never waits for it, -/// and the timestamp is stamped before spawning so concurrent requests do not -/// each fire a refresh (single-flight). Queries proceed against the current -/// materialized-view state; a failed refresh is logged, not surfaced. -fn maybe_refresh_analytics_materialized_views(state: &AppState) { - let due = { - let mut guard = last_mv_refresh() - .lock() - .unwrap_or_else(PoisonError::into_inner); - match *guard { - Some(last) if last.elapsed() < MV_REFRESH_MIN_INTERVAL => false, - _ => { - *guard = Some(Instant::now()); - true - } - } - }; - if !due { - return; - } - let store = state.session_store.clone(); - tokio::spawn(async move { - if let Err(error) = store.refresh_analytics_materialized_views().await { - tracing::warn!(error = %error, "analytics materialized view refresh failed"); - } - }); -} - pub(super) fn translate( method: &Method, uri: &Uri, diff --git a/crates/moa-edge/src/routes/auth_accounts.rs b/crates/moa-edge/src/routes/auth_accounts.rs index dc33bfae..a4812cb1 100644 --- a/crates/moa-edge/src/routes/auth_accounts.rs +++ b/crates/moa-edge/src/routes/auth_accounts.rs @@ -614,27 +614,21 @@ pub(super) async fn role_summary(state: &AppState, identity: &Identity) -> RoleS return RoleSummary::default(); }; let subject = fga_subject(identity); - let workspace_admin = fga - .check( - &subject, - "admin", - &format!("workspace:{}", moa_core::WORKSPACE_ID), - ) - .await - .unwrap_or(false); let tenant_object = format!("tenant:{}", identity.tenant_id.0); - let tenant_admin = fga - .check(&subject, "admin", &tenant_object) - .await - .unwrap_or(false); - let tenant_operator = fga - .check(&subject, "operator", &tenant_object) - .await - .unwrap_or(false); + let workspace_object = format!("workspace:{}", moa_core::WORKSPACE_ID); + // Resolve all three roles in one batched OpenFGA request instead of three + // sequential round trips. A failed batch defaults every role to false, + // matching the previous per-check `unwrap_or(false)` fail-safe. + let checks = [ + (subject.clone(), "admin".to_string(), workspace_object), + (subject.clone(), "admin".to_string(), tenant_object.clone()), + (subject, "operator".to_string(), tenant_object), + ]; + let results = fga.batch_check(&checks).await.unwrap_or_default(); RoleSummary { - workspace_admin, - tenant_admin, - tenant_operator, + workspace_admin: results.first().copied().unwrap_or(false), + tenant_admin: results.get(1).copied().unwrap_or(false), + tenant_operator: results.get(2).copied().unwrap_or(false), } } diff --git a/crates/moa-edge/src/routes/tenant_accounts.rs b/crates/moa-edge/src/routes/tenant_accounts.rs index 35247380..b80188e4 100644 --- a/crates/moa-edge/src/routes/tenant_accounts.rs +++ b/crates/moa-edge/src/routes/tenant_accounts.rs @@ -1557,7 +1557,6 @@ mod tests { #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] struct AuthzOutboxTupleRow { - idempotency_key: String, op: String, tuple_user: String, tuple_relation: String, @@ -1714,7 +1713,7 @@ mod tests { ]; let rows: Vec = sqlx::query_as( r#" - SELECT idempotency_key, op, tuple_user, tuple_relation, tuple_object, + SELECT op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id FROM authz_outbox WHERE tuple_object = ANY($1) @@ -1727,9 +1726,6 @@ mod tests { let mut expected = vec![ AuthzOutboxTupleRow { - idempotency_key: format!( - "delete-agent:{agent_with_operator_id}-can_act_as-operator:{delegate_user_id}-v{MODEL_VERSION}" - ), op: "delete".to_string(), tuple_user: format!("operator:{delegate_user_id}"), tuple_relation: "can_act_as".to_string(), @@ -1738,9 +1734,6 @@ mod tests { tenant_id: Some(tenant_id), }, AuthzOutboxTupleRow { - idempotency_key: format!( - "delete-agent:{agent_with_operator_id}-operator-operator:{operator_user_id}-v{MODEL_VERSION}" - ), op: "delete".to_string(), tuple_user: format!("operator:{operator_user_id}"), tuple_relation: "operator".to_string(), @@ -1749,9 +1742,6 @@ mod tests { tenant_id: Some(tenant_id), }, AuthzOutboxTupleRow { - idempotency_key: format!( - "delete-agent:{agent_with_operator_id}-tenant-tenant:{tenant_id}-v{MODEL_VERSION}" - ), op: "delete".to_string(), tuple_user: format!("tenant:{tenant_id}"), tuple_relation: "tenant".to_string(), @@ -1760,9 +1750,6 @@ mod tests { tenant_id: Some(tenant_id), }, AuthzOutboxTupleRow { - idempotency_key: format!( - "delete-agent:{agent_without_operator_id}-tenant-tenant:{tenant_id}-v{MODEL_VERSION}" - ), op: "delete".to_string(), tuple_user: format!("tenant:{tenant_id}"), tuple_relation: "tenant".to_string(), @@ -1781,8 +1768,8 @@ mod tests { assert_eq!(rows, expected); assert!( rows.iter() - .all(|row| row.idempotency_key.ends_with(&format!("-v{MODEL_VERSION}"))), - "agent tuple deletes must use the current authz model suffix" + .all(|row| row.model_version == MODEL_VERSION as i32), + "agent tuple deletes must use the current authz model version" ); testing::cleanup_test_schema(&database_url, &schema_name).await?; diff --git a/crates/moa-edge/tests/direct_read_routes_db.rs b/crates/moa-edge/tests/direct_read_routes_db.rs index 7abbc43e..516700f1 100644 --- a/crates/moa-edge/tests/direct_read_routes_db.rs +++ b/crates/moa-edge/tests/direct_read_routes_db.rs @@ -695,6 +695,11 @@ async fn analytics_query_uses_requested_tenant_when_authorized_db() { "tenant_id": tenant_b, "dimensions": [{ "field": "tool_name" }], "measures": [{ "aggregation": "count", "alias": "calls" }], + "filters": [{ + "field": "called_at", + "operator": "gte", + "value": (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339() + }], "order_by": [{ "field": "calls", "direction": "desc" }], "limit": 10 })) @@ -867,6 +872,11 @@ async fn analytics_query_uses_configured_schema_db() { "dataset": "events", "dimensions": [{ "field": "event_type" }, { "field": "session_id" }], "measures": [{ "aggregation": "count", "alias": "events" }], + "filters": [{ + "field": "occurred_at", + "operator": "gte", + "value": (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339() + }], "limit": 5 })) .send() diff --git a/crates/moa-eval/core/src/evaluators/output_match.rs b/crates/moa-eval/core/src/evaluators/output_match.rs index 0d9864d3..bb04280b 100644 --- a/crates/moa-eval/core/src/evaluators/output_match.rs +++ b/crates/moa-eval/core/src/evaluators/output_match.rs @@ -19,17 +19,49 @@ impl Evaluator for OutputMatchEvaluator { }; let response = result.response.as_deref().unwrap_or(""); - let score = evaluate_output(response, expected)?; - Ok(vec![EvalScore { + let outcome = evaluate_output(response, expected)?; + // Diagnostic fractional score: how many expectation rules matched. This is + // reporting only and must NOT be used as the pass/fail gate on its own, + // because a partial match above the global 0.5 threshold would otherwise + // certify a response that is missing a required fragment. + let mut scores = vec![EvalScore { evaluator: self.name().to_string(), name: "output_match".to_string(), - value: EvalScoreValue::Numeric(score.0), - comment: score.1, - }]) + value: EvalScoreValue::Numeric(outcome.fraction), + comment: outcome.comment.clone(), + }]; + // Hard all-of gate: every expectation rule (contains/not_contains/facts/ + // regex/exact) is required, so any missing required fragment or present + // exclusion fails the scenario regardless of the averaged fraction. + if outcome.rule_count > 0 { + scores.push(EvalScore { + evaluator: self.name().to_string(), + name: "output_match_required".to_string(), + value: EvalScoreValue::Boolean(outcome.required_satisfied), + comment: if outcome.required_satisfied { + None + } else { + outcome.comment + }, + }); + } + Ok(scores) } } -fn evaluate_output(response: &str, expected: &ExpectedOutput) -> Result<(f64, Option)> { +/// Outcome of scoring a response against its expectation rules. +struct OutputMatchOutcome { + /// Fraction of all expectation rules satisfied. Diagnostic reporting only. + fraction: f64, + /// Whether every required rule is satisfied. This is the hard pass/fail gate. + required_satisfied: bool, + /// Number of expectation rules evaluated. + rule_count: usize, + /// Human-readable description of every unmet rule, when any failed. + comment: Option, +} + +fn evaluate_output(response: &str, expected: &ExpectedOutput) -> Result { let response_lower = response.to_lowercase(); let mut matched = 0usize; let mut total = 0usize; @@ -80,24 +112,52 @@ fn evaluate_output(response: &str, expected: &ExpectedOutput) -> Result<(f64, Op } } - let score = if total == 0 { + let fraction = if total == 0 { 1.0 } else { matched as f64 / total as f64 }; + // Every expectation rule is required, so a satisfied outcome is exactly the + // absence of any recorded failure. The fraction stays as a diagnostic signal. + let required_satisfied = failures.is_empty(); let comment = if failures.is_empty() { None } else { Some(failures.join("; ")) }; - Ok((score, comment)) + Ok(OutputMatchOutcome { + fraction, + required_satisfied, + rule_count: total, + comment, + }) } #[cfg(test)] mod tests { use super::OutputMatchEvaluator; - use crate::{EvalResult, EvalScoreValue, Evaluator, ExpectedOutput, TestCase}; + use crate::evaluators::score_is_failure; + use crate::{EvalResult, EvalScore, EvalScoreValue, Evaluator, ExpectedOutput, TestCase}; + + fn required_gate(scores: &[EvalScore]) -> &EvalScore { + scores + .iter() + .find(|score| score.name == "output_match_required") + .expect("evaluator must emit the hard output_match_required gate") + } + + fn fraction(scores: &[EvalScore]) -> f64 { + match scores + .iter() + .find(|score| score.name == "output_match") + .expect("evaluator must emit the diagnostic output_match score") + .value + { + EvalScoreValue::Numeric(value) => value, + _ => panic!("output_match must be numeric"), + } + } #[tokio::test] async fn contains_rules_pass_when_all_terms_match() { @@ -115,11 +175,23 @@ mod tests { }; let scores = evaluator.evaluate(&case, &result).await.expect("score"); - assert_eq!(scores[0].value, EvalScoreValue::Numeric(1.0)); + assert_eq!(fraction(&scores), 1.0); + assert_eq!( + required_gate(&scores).value, + EvalScoreValue::Boolean(true), + "all required fragments present must pass the hard gate" + ); + assert!( + !score_is_failure(required_gate(&scores)), + "a satisfied gate must not downgrade the scenario" + ); } #[tokio::test] - async fn missing_contains_term_reduces_score() { + async fn one_of_two_required_contains_fails_scenario() { + // Pins (F15): a response matching only 1 of 2 required `contains` fragments + // scores 0.5 diagnostically but FAILS the hard gate, so the harness downgrades + // the scenario. Previously the 0.5 average passed the global threshold. let evaluator = OutputMatchEvaluator; let case = TestCase { expected_output: Some(ExpectedOutput { @@ -134,6 +206,69 @@ mod tests { }; let scores = evaluator.evaluate(&case, &result).await.expect("score"); - assert_eq!(scores[0].value, EvalScoreValue::Numeric(0.5)); + assert_eq!( + fraction(&scores), + 0.5, + "diagnostic fraction is still reported" + ); + assert_eq!( + required_gate(&scores).value, + EvalScoreValue::Boolean(false), + "a missing required fragment must fail the hard gate" + ); + assert!( + score_is_failure(required_gate(&scores)), + "the failed gate must downgrade the scenario to Failed" + ); + } + + #[tokio::test] + async fn present_exclusion_fails_scenario() { + // Pins (F15): a `not_contains` exclusion that appears in the response fails the + // hard gate — exclusions are safety requirements, not fractional signals. + let evaluator = OutputMatchEvaluator; + let case = TestCase { + expected_output: Some(ExpectedOutput { + contains: vec!["deployed".to_string()], + not_contains: vec!["error".to_string()], + ..ExpectedOutput::default() + }), + ..TestCase::default() + }; + let result = EvalResult { + response: Some("App deployed but hit an error".to_string()), + ..EvalResult::default() + }; + + let scores = evaluator.evaluate(&case, &result).await.expect("score"); + assert_eq!( + required_gate(&scores).value, + EvalScoreValue::Boolean(false), + "a present exclusion must fail the hard gate" + ); + } + + #[tokio::test] + async fn no_expectation_rules_emit_only_diagnostic_score() { + // Pins: an empty expectation block has no required rules, so no hard gate is + // emitted and the diagnostic fraction is a vacuous 1.0. + let evaluator = OutputMatchEvaluator; + let case = TestCase { + expected_output: Some(ExpectedOutput::default()), + ..TestCase::default() + }; + let result = EvalResult { + response: Some("anything".to_string()), + ..EvalResult::default() + }; + + let scores = evaluator.evaluate(&case, &result).await.expect("score"); + assert_eq!(fraction(&scores), 1.0); + assert!( + !scores + .iter() + .any(|score| score.name == "output_match_required"), + "no rules means no hard gate score" + ); } } diff --git a/crates/moa-eval/core/src/types.rs b/crates/moa-eval/core/src/types.rs index 4f9b4743..6c27420d 100644 --- a/crates/moa-eval/core/src/types.rs +++ b/crates/moa-eval/core/src/types.rs @@ -198,19 +198,24 @@ pub enum LongSessionInterleaving { Phased, } -/// Flexible expected-output rules for an agent response. +/// Expected-output rules for an agent response. +/// +/// Every field below is a hard requirement: the `output_match` evaluator emits a +/// diagnostic fractional score plus an `output_match_required` boolean gate, and +/// any unmet rule (a missing required fragment, a present exclusion, a regex or +/// exact mismatch) fails the scenario regardless of the fractional score. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct ExpectedOutput { - /// Response text must contain all of these fragments. + /// Response text must contain all of these fragments (required, all-of). pub contains: Vec, - /// Response text must not contain any of these fragments. + /// Response text must not contain any of these fragments (required exclusion). pub not_contains: Vec, - /// Regular expression the response should match. + /// Regular expression the response must match when set (required). pub regex: Option, - /// Exact response text expected from the agent. + /// Exact response text the agent must return when set (required). pub exact: Option, - /// Key facts that should appear in the response. + /// Key facts that must all appear in the response (required, all-of). pub facts: Vec, } diff --git a/crates/moa-eval/src/long_conversation/budgets.rs b/crates/moa-eval/src/long_conversation/budgets.rs index a9c04dd0..b1dfa447 100644 --- a/crates/moa-eval/src/long_conversation/budgets.rs +++ b/crates/moa-eval/src/long_conversation/budgets.rs @@ -75,7 +75,7 @@ impl Budgets { score.functional.task_completed, ); if let Some(max) = self.latency_p95_ms_max { - check_max( + check_max_measured( &mut violations, "latency_ms.completion_p95_ms", max, @@ -275,6 +275,28 @@ where } } +/// Upper-bound gate that fails closed when the gated metric was not measured. +/// +/// An absent (`None`) latency can no longer default to zero and slip under an +/// upper-bound budget: it records a "metric not measured" violation. +fn check_max_measured( + violations: &mut Vec, + metric: &str, + expected_max: T, + actual: Option, +) where + T: std::fmt::Display + PartialOrd, +{ + match actual { + Some(actual) => check_max(violations, metric, expected_max, actual), + None => violations.push(BudgetViolation { + metric: metric.to_string(), + expected: format!("<= {expected_max} (measured)"), + actual: "metric not measured".to_string(), + }), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/moa-eval/src/long_conversation/score_card.rs b/crates/moa-eval/src/long_conversation/score_card.rs index f61473a4..b2236f9b 100644 --- a/crates/moa-eval/src/long_conversation/score_card.rs +++ b/crates/moa-eval/src/long_conversation/score_card.rs @@ -140,17 +140,24 @@ impl Default for FunctionalScores { } /// Latency scores in milliseconds. +/// +/// Every field is `None` when the corresponding latency was not measured, rather +/// than copying an aggregate or defaulting to zero. Time-to-first-token is not +/// captured per turn today, so both TTFT fields are always absent; completion +/// percentiles are computed from the real per-turn sample set and are absent when +/// no turn produced a latency sample. An absent completion percentile fails a +/// configured upper-bound latency gate closed instead of passing on a fake zero. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct LatencyScores { - /// Median submit-to-first-token latency. - pub first_token_p50_ms: u64, - /// P95 submit-to-first-token latency. - pub first_token_p95_ms: u64, - /// Median submit-to-completion latency. - pub completion_p50_ms: u64, - /// P95 submit-to-completion latency. - pub completion_p95_ms: u64, + /// Median submit-to-first-token latency. Absent: TTFT is not measured per turn. + pub first_token_p50_ms: Option, + /// P95 submit-to-first-token latency. Absent: TTFT is not measured per turn. + pub first_token_p95_ms: Option, + /// Median submit-to-completion latency, or `None` when no turn was sampled. + pub completion_p50_ms: Option, + /// P95 submit-to-completion latency, or `None` when no turn was sampled. + pub completion_p95_ms: Option, } /// Token and cost scores. @@ -340,28 +347,36 @@ fn push_functional_rows(rows: &mut Vec, scores: &FunctionalScores) { } fn push_latency_rows(rows: &mut Vec, scores: &LatencyScores) { - push_row( + // Absent latencies emit no row: an unmeasured metric is explicitly missing + // from the flattened analytics rather than reported as a copied or zero value. + push_opt_latency_row( rows, "latency_ms.first_token_p50_ms", - number(scores.first_token_p50_ms), + scores.first_token_p50_ms, ); - push_row( + push_opt_latency_row( rows, "latency_ms.first_token_p95_ms", - number(scores.first_token_p95_ms), + scores.first_token_p95_ms, ); - push_row( + push_opt_latency_row( rows, "latency_ms.completion_p50_ms", - number(scores.completion_p50_ms), + scores.completion_p50_ms, ); - push_row( + push_opt_latency_row( rows, "latency_ms.completion_p95_ms", - number(scores.completion_p95_ms), + scores.completion_p95_ms, ); } +fn push_opt_latency_row(rows: &mut Vec, name: &str, value: Option) { + if let Some(value) = value { + push_row(rows, name, number(value)); + } +} + fn push_cost_rows(rows: &mut Vec, scores: &CostScores) { push_row( rows, diff --git a/crates/moa-eval/src/long_conversation/transcript_runner.rs b/crates/moa-eval/src/long_conversation/transcript_runner.rs index 2431c791..be0e5ff4 100644 --- a/crates/moa-eval/src/long_conversation/transcript_runner.rs +++ b/crates/moa-eval/src/long_conversation/transcript_runner.rs @@ -1208,9 +1208,27 @@ async fn build_score_card( .filter(|event| matches!(event, Event::Checkpoint { .. })) .count(); let errors_total_pre_compaction = errors_before_first_checkpoint(&events); - let errors_preserved = - metadata_u32(case, "errors_preserved").unwrap_or(errors_total_pre_compaction); - let errors_preserved_strict = errors_preserved >= errors_total_pre_compaction; + // Error preservation is credited only from explicit evidence. Missing evidence + // is NOT defaulted to the favorable pre-compaction count: when errors existed + // before compaction and no preservation evidence is provided, the strict gate + // fails closed rather than trivially passing. + let errors_preserved_evidence = metadata_u32(case, "errors_preserved"); + let errors_preserved = errors_preserved_evidence.unwrap_or(0); + let errors_preserved_strict = match errors_preserved_evidence { + Some(preserved) => preserved >= errors_total_pre_compaction, + None => errors_total_pre_compaction == 0, + }; + // Per-turn completion latency samples for real percentiles, instead of copying + // one aggregate latency into every p50/p95 field. + let completion_latencies_ms = events + .iter() + .filter_map(|event| match event { + Event::BrainResponse { duration_ms, .. } => Some(*duration_ms), + _ => None, + }) + .collect::>(); + let completion_p50_ms = percentile_ms(&completion_latencies_ms, 0.50); + let completion_p95_ms = percentile_ms(&completion_latencies_ms, 0.95); let consolidation = count_consolidation_outcomes(&events); let brain_cost_cents = events .iter() @@ -1256,16 +1274,18 @@ async fn build_score_card( timestamp, provider: provider.to_string(), functional: FunctionalScores { - task_completed: error_count == 0, + task_completed: task_completed_signal(&execution.response, error_count), turn_count, error_count, errors_preserved: errors_preserved_strict, }, latency_ms: LatencyScores { - first_token_p50_ms: execution.metrics.latency_ms, - first_token_p95_ms: execution.metrics.latency_ms, - completion_p50_ms: execution.metrics.latency_ms, - completion_p95_ms: execution.metrics.latency_ms, + // Time-to-first-token is not measured per turn; report explicit absence + // rather than copying the aggregate turn latency. + first_token_p50_ms: None, + first_token_p95_ms: None, + completion_p50_ms, + completion_p95_ms, }, cost: CostScores { input_tokens: score_input_tokens, @@ -1321,6 +1341,33 @@ async fn build_score_card( } } +/// Positive task-completion signal from the durable log. +/// +/// A run is complete only when it produced a non-empty final response AND recorded +/// no error events. Error-absence alone is insufficient: a run that died before +/// producing a final answer (no response) has not completed its task. +fn task_completed_signal(response: &Option, error_count: u32) -> bool { + error_count == 0 + && response + .as_deref() + .is_some_and(|text| !text.trim().is_empty()) +} + +/// Nearest-rank percentile (`p` in `0.0..=1.0`) of the samples, or `None` when empty. +/// +/// Returns an explicit `None` for an empty sample set so callers can represent an +/// unmeasured latency as absent rather than a fabricated zero. +fn percentile_ms(samples: &[u64], p: f64) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let rank = (p * sorted.len() as f64).ceil() as usize; + let index = rank.clamp(1, sorted.len()) - 1; + Some(sorted[index]) +} + #[derive(Debug, Clone, Copy)] struct CacheObservations { report_count: usize, @@ -1654,6 +1701,154 @@ mod tests { assert!(!score_card.functional.errors_preserved); } + #[tokio::test] + async fn score_card_missing_compaction_evidence_fails_strict() { + // Pins (F15): when a run compacted with pre-compaction errors but provides NO + // errors_preserved evidence, the strict gate fails closed instead of defaulting + // to the favorable pre-compaction count. + let session_id = SessionId::new(); + let records = vec![ + event_record( + session_id, + 1, + Event::Error { + message: "tool failure before compaction".to_string(), + recoverable: true, + }, + ), + event_record( + session_id, + 2, + Event::Checkpoint { + summary: "summary without the error".to_string(), + events_summarized: 1, + token_count: 8, + model: ModelId::new("recorded-scripted"), + model_tier: ModelTier::Auxiliary, + input_tokens: 42, + output_tokens: 8, + cost_cents: 0, + }, + ), + ]; + // No "errors_preserved" metadata: evidence is absent. + let case = TestCase { + name: "absent-error-preservation".to_string(), + ..TestCase::default() + }; + let session_store = RecordingSessionStore::default(); + + let score_card = build_score_card( + &case, + "recorded", + 1, + &records, + &CollectedExecution::default(), + Utc::now(), + session_id, + &session_store, + ) + .await; + + assert_eq!(score_card.context.errors_total_pre_compaction, 1); + assert!( + !score_card.context.errors_preserved_strict, + "absent preservation evidence with pre-compaction errors must fail strict" + ); + } + + #[tokio::test] + async fn score_card_computes_completion_percentiles_and_absent_ttft() { + // Pins (F15): completion p50/p95 come from the real per-turn latency distribution + // (distinct for a skewed sample) and TTFT is explicitly absent, not a copy of one + // aggregate latency reused for every percentile. + let session_id = SessionId::new(); + let durations = [10_u64, 10, 10, 10, 1_000]; + let records = durations + .iter() + .enumerate() + .map(|(index, duration)| { + event_record( + session_id, + index as u64 + 1, + brain_response_event(*duration), + ) + }) + .collect::>(); + let case = TestCase { + name: "latency-percentiles".to_string(), + ..TestCase::default() + }; + let session_store = RecordingSessionStore::default(); + + let score_card = build_score_card( + &case, + "recorded", + durations.len(), + &records, + &CollectedExecution::default(), + Utc::now(), + session_id, + &session_store, + ) + .await; + + assert_eq!(score_card.latency_ms.completion_p50_ms, Some(10)); + assert_eq!(score_card.latency_ms.completion_p95_ms, Some(1_000)); + assert_ne!( + score_card.latency_ms.completion_p50_ms, score_card.latency_ms.completion_p95_ms, + "a skewed latency distribution must not collapse p50 and p95 into one value" + ); + assert_eq!( + score_card.latency_ms.first_token_p50_ms, None, + "TTFT is not measured per turn and must be explicitly absent" + ); + assert_eq!(score_card.latency_ms.first_token_p95_ms, None); + } + + #[test] + fn percentile_ms_distinguishes_p50_and_p95_for_skewed_sample() { + // Pins: nearest-rank percentiles separate the median from the tail. + let samples = [10_u64, 10, 10, 10, 1_000]; + assert_eq!(percentile_ms(&samples, 0.50), Some(10)); + assert_eq!(percentile_ms(&samples, 0.95), Some(1_000)); + assert_eq!(percentile_ms(&[], 0.50), None); + } + + #[test] + fn task_completed_requires_response_and_no_errors() { + // Pins (F15): completion needs a positive final-response signal AND zero errors; + // error-absence alone (no response) is not completion. + assert!(task_completed_signal(&Some("final answer".to_string()), 0)); + assert!( + !task_completed_signal(&None, 0), + "no final response is not completion even with zero errors" + ); + assert!( + !task_completed_signal(&Some("final answer".to_string()), 1), + "a run with errors is not complete" + ); + assert!( + !task_completed_signal(&Some(" ".to_string()), 0), + "a blank final response is not completion" + ); + } + + fn brain_response_event(duration_ms: u64) -> Event { + Event::BrainResponse { + text: "response".to_string(), + thought_signature: None, + model: ModelId::new("recorded-scripted"), + model_tier: ModelTier::Main, + input_tokens_uncached: 0, + input_tokens_cache_write: 0, + input_tokens_cache_read: 0, + output_tokens: 1, + cost_cents: 0, + duration_ms, + } + } + #[tokio::test] async fn score_card_wires_planted_fact_recall_from_session_store() { // Pins: the score card's planted-fact recall comes from compute_planted_fact_recall against diff --git a/crates/moa-eval/tests/long_conversation_foundation_eval.rs b/crates/moa-eval/tests/long_conversation_foundation_eval.rs index 88652a2c..0913a37d 100644 --- a/crates/moa-eval/tests/long_conversation_foundation_eval.rs +++ b/crates/moa-eval/tests/long_conversation_foundation_eval.rs @@ -71,10 +71,10 @@ fn score_card() -> ScoreCard { errors_preserved: true, }, latency_ms: LatencyScores { - first_token_p50_ms: 10, - first_token_p95_ms: 15, - completion_p50_ms: 40, - completion_p95_ms: 50, + first_token_p50_ms: Some(10), + first_token_p95_ms: Some(15), + completion_p50_ms: Some(40), + completion_p95_ms: Some(50), }, cost: CostScores { input_tokens: 100, @@ -357,7 +357,7 @@ fn compute_prefix_stability_returns_false_when_byte_layout_drifts_at_turn_3() { #[test] fn budgets_evaluate_reports_each_violation_with_metric_name_and_actual_value() { let mut card = score_card(); - card.latency_ms.completion_p95_ms = 200; + card.latency_ms.completion_p95_ms = Some(200); card.safety.canary_leaks = 1; let budgets = Budgets { latency_p95_ms_max: Some(100), diff --git a/crates/moa-experiments/src/app.rs b/crates/moa-experiments/src/app.rs index 1aa81a23..d2f483f1 100644 --- a/crates/moa-experiments/src/app.rs +++ b/crates/moa-experiments/src/app.rs @@ -301,7 +301,14 @@ pub async fn cancel_run( .unwrap_or_else(|| "cancelled".to_string()); let store = ExperimentStore::new(pool); let existing = load_required_run(&store, &scope, request.run_uid).await?; - if existing.status.is_terminal() { + // A genuinely finished run has nothing to cancel or reconcile. An + // already-`cancelled` run is deliberately NOT short-circuited here: a retry + // must still reconcile any active trial rows that were stranded when a prior + // attempt updated the run projection but crashed before cancelling trials. + if matches!( + existing.status, + ExperimentRunStatus::Completed | ExperimentRunStatus::Failed + ) { return Ok(ExperimentCancelResponse { tenant_id: request.tenant_id, run_uid: existing.run_uid, @@ -310,25 +317,20 @@ pub async fn cancel_run( reason, }); } - let run = store - .update_run_status( - &scope, - request.run_uid, - ExperimentRunStatus::Cancelled, - Some(reason.clone()), - Some(Utc::now()), - ) - .await? - .ok_or_else(|| run_not_found(request.run_uid))?; - store - .cancel_active_trials(&scope, request.run_uid, reason.clone()) + // Cancel the run and reconcile its active trials atomically in one + // transaction so the parent projection and trial rows can never diverge. + let (run, _cancelled_trials) = store + .cancel_run_and_active_trials(&scope, request.run_uid, reason.clone()) .await?; + let run = run.ok_or_else(|| run_not_found(request.run_uid))?; record_experiment_run(run.status.as_str(), run.target_kind.as_str()); Ok(ExperimentCancelResponse { tenant_id: request.tenant_id, run_uid: run.run_uid, - cancelled: true, + // `true` on the first cancel; `false` for an idempotent retry behind an + // already-cancelled parent (which still reconciles active trials above). + cancelled: existing.status != ExperimentRunStatus::Cancelled, status: run.status.as_str().to_string(), reason, }) diff --git a/crates/moa-experiments/src/store.rs b/crates/moa-experiments/src/store.rs index 9d3ad4af..049a0f36 100644 --- a/crates/moa-experiments/src/store.rs +++ b/crates/moa-experiments/src/store.rs @@ -543,6 +543,89 @@ impl ExperimentStore { rows.iter().map(trial_from_row).collect() } + /// Atomically cancels a run and its active trials in a single transaction. + /// + /// The run-status transition to `Cancelled` and the active-trial + /// cancellation commit together, so a crash between them can never strand + /// active trial rows behind an already-terminal parent. The run update never + /// overrides a genuinely finished (`completed`/`failed`) run and is + /// idempotent for an already-`cancelled` run, so a retry behind a terminal + /// cancelled parent still reconciles any active trials. Returns the updated + /// run row (present whenever the run is cancellable) and the trials this call + /// transitioned to cancelled. + pub async fn cancel_run_and_active_trials( + &self, + scope: &ActionRuleScope, + run_uid: Uuid, + reason: String, + ) -> MoaResult<(Option, Vec)> { + let parts = ScopeParts::from_scope(scope); + let mut conn = ScopedConn::begin(&self.pool, &experiment_scope_context(scope)).await?; + ensure_run_exists_in_scope(conn.as_mut(), scope, run_uid).await?; + let run_row = sqlx::query(&format!( + r#" + UPDATE moa.experiment_run + SET status = 'cancelled', + error = CASE + WHEN status IN ('completed', 'failed', 'cancelled') THEN error + ELSE $5 + END, + completed_at = CASE + WHEN status IN ('completed', 'failed', 'cancelled') THEN completed_at + ELSE now() + END, + started_at = COALESCE(started_at, now()), + updated_at = now() + WHERE run_uid = $4 + AND scope = $1 + AND storage_partition_id IS NOT DISTINCT FROM $2 + AND user_id IS NOT DISTINCT FROM $3 + AND status NOT IN ('completed', 'failed') + RETURNING {RUN_COLUMNS} + "# + )) + .bind(parts.scope) + .bind(parts.storage_partition_id.as_deref()) + .bind(parts.user_id.as_deref()) + .bind(run_uid) + .bind(reason.clone()) + .fetch_optional(conn.as_mut()) + .await + .map_err(map_sqlx_error)?; + let trial_rows = sqlx::query(&format!( + r#" + UPDATE moa.experiment_trial + SET status = 'cancelled', + stop_reason = 'cancelled', + error = $5, + completed_at = COALESCE(completed_at, now()), + started_at = COALESCE(started_at, now()), + updated_at = now() + WHERE run_uid = $4 + AND scope = $1 + AND storage_partition_id IS NOT DISTINCT FROM $2 + AND user_id IS NOT DISTINCT FROM $3 + AND status IN ('accepted', 'dispatched', 'running') + RETURNING {TRIAL_COLUMNS} + "# + )) + .bind(parts.scope) + .bind(parts.storage_partition_id.as_deref()) + .bind(parts.user_id.as_deref()) + .bind(run_uid) + .bind(reason) + .fetch_all(conn.as_mut()) + .await + .map_err(map_sqlx_error)?; + conn.commit().await?; + let run = run_row.as_ref().map(run_from_row).transpose()?; + let trials = trial_rows + .iter() + .map(trial_from_row) + .collect::>>()?; + Ok((run, trials)) + } + /// Attaches a session to a scoped experiment trial. pub async fn attach_trial_session( &self, diff --git a/crates/moa-experiments/tests/experiment_store_db.rs b/crates/moa-experiments/tests/experiment_store_db.rs index 9b3c9917..1ef68563 100644 --- a/crates/moa-experiments/tests/experiment_store_db.rs +++ b/crates/moa-experiments/tests/experiment_store_db.rs @@ -1,5 +1,6 @@ use moa_artifacts::simulation::ExperimentTargetKind; use moa_core::RlsContext; +use moa_core::wire::experiments::ExperimentCancelRequest; use moa_core::{ ActionRuleScope, ContactId, ModelId, Result, SessionId, StoragePartitionId, TenantId, UserId, }; @@ -787,6 +788,226 @@ async fn cancel_active_trials_marks_remaining_work_without_mutating_terminal_tri Ok(()) } +#[tokio::test] +#[ignore = "requires local Postgres configured through MOA_DATABASE_URL"] +async fn cancel_run_and_active_trials_reconciles_behind_already_cancelled_parent_db() -> Result<()> +{ + // Pins (F19): the combined cancel reconciles stranded active trials even when the + // parent run is already terminal-cancelled, atomically in one transaction, without + // disturbing already-terminal trials or overwriting the original cancellation error. + let _guard = DB_TEST_LOCK.lock().await; + let test_db = moa_test_support::postgres::bootstrap_test_db().await?; + let store = ExperimentStore::new(test_db.store().pool().clone()); + let scope = tenant_scope("cancel-reconcile"); + let plan_revision_uid = insert_artifact_revision(test_db.store().pool(), &scope).await?; + let run = store + .insert_run( + &scope, + new_experiment("cancel-reconcile-parent", None, Vec::new()), + ) + .await?; + let running = store + .insert_trial( + &scope, + new_trial(run.run_uid, "running", plan_revision_uid, Vec::new()), + ) + .await?; + store + .update_trial_status( + &scope, + running.trial_uid, + ExperimentTrialStatus::Running, + None, + None, + None, + ) + .await? + .expect("running status update should return trial"); + let completed = store + .insert_trial( + &scope, + new_trial(run.run_uid, "completed", plan_revision_uid, Vec::new()), + ) + .await?; + store + .update_trial_status( + &scope, + completed.trial_uid, + ExperimentTrialStatus::Completed, + Some(ExperimentTrialStopReason::Success), + None, + Some(chrono::Utc::now()), + ) + .await? + .expect("completed status update should return trial"); + + // Simulate a first cancel that updated the run projection but crashed before + // reconciling trials: the parent is Cancelled while the running trial is stranded. + store + .update_run_status( + &scope, + run.run_uid, + ExperimentRunStatus::Cancelled, + Some("first attempt".to_string()), + Some(chrono::Utc::now()), + ) + .await? + .expect("run cancel should return run"); + + let (reconciled_run, cancelled_trials) = store + .cancel_run_and_active_trials(&scope, run.run_uid, "operator cancelled".to_string()) + .await?; + + let reconciled_run = + reconciled_run.expect("already-cancelled run is still cancellable for reconciliation"); + assert_eq!(reconciled_run.status, ExperimentRunStatus::Cancelled); + assert_eq!( + reconciled_run.error.as_deref(), + Some("first attempt"), + "reconciliation preserves the original cancellation error" + ); + assert_eq!( + cancelled_trials.len(), + 1, + "only the stranded running trial is reconciled" + ); + assert_eq!(cancelled_trials[0].trial_uid, running.trial_uid); + + let trials = store.list_trials(&scope, run.run_uid, None, 10).await?; + assert_trial_status( + &trials, + running.trial_uid, + ExperimentTrialStatus::Cancelled, + Some(ExperimentTrialStopReason::Cancelled), + ); + assert_trial_status( + &trials, + completed.trial_uid, + ExperimentTrialStatus::Completed, + Some(ExperimentTrialStopReason::Success), + ); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires local Postgres configured through MOA_DATABASE_URL"] +async fn cancel_run_and_active_trials_does_not_override_completed_run_db() -> Result<()> { + // Pins (F19): the combined cancel never flips a genuinely completed run to cancelled. + let _guard = DB_TEST_LOCK.lock().await; + let test_db = moa_test_support::postgres::bootstrap_test_db().await?; + let store = ExperimentStore::new(test_db.store().pool().clone()); + let scope = tenant_scope("cancel-completed"); + let run = store + .insert_run( + &scope, + new_experiment("cancel-completed-parent", None, Vec::new()), + ) + .await?; + store + .update_run_status( + &scope, + run.run_uid, + ExperimentRunStatus::Completed, + None, + Some(chrono::Utc::now()), + ) + .await? + .expect("run completion should return run"); + + let (reconciled_run, cancelled_trials) = store + .cancel_run_and_active_trials(&scope, run.run_uid, "late cancel".to_string()) + .await?; + + assert!( + reconciled_run.is_none(), + "a completed run must not be transitioned to cancelled" + ); + assert!(cancelled_trials.is_empty()); + let reloaded = store + .load_run(&scope, run.run_uid) + .await? + .expect("run persists"); + assert_eq!(reloaded.status, ExperimentRunStatus::Completed); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires local Postgres configured through MOA_DATABASE_URL"] +async fn cancel_run_app_reconciles_active_trials_behind_terminal_cancelled_parent_db() -> Result<()> +{ + // Pins (F19): a retried Experiments/cancel behind an already-cancelled parent no + // longer short-circuits on the terminal-parent early return; it still reconciles + // stranded active trial rows and reports the retry idempotently. + let _guard = DB_TEST_LOCK.lock().await; + let test_db = moa_test_support::postgres::bootstrap_test_db().await?; + let pool = test_db.store().pool().clone(); + let store = ExperimentStore::new(pool.clone()); + let scope = tenant_scope("app-cancel-retry"); + let ActionRuleScope::Tenant { tenant_id } = scope else { + unreachable!("tenant_scope builds a tenant scope"); + }; + let plan_revision_uid = insert_artifact_revision(&pool, &scope).await?; + let run = store + .insert_run( + &scope, + new_experiment("app-cancel-retry-parent", None, Vec::new()), + ) + .await?; + let running = store + .insert_trial( + &scope, + new_trial(run.run_uid, "running", plan_revision_uid, Vec::new()), + ) + .await?; + store + .update_trial_status( + &scope, + running.trial_uid, + ExperimentTrialStatus::Running, + None, + None, + None, + ) + .await? + .expect("running status update should return trial"); + // Strand: the run projection is already cancelled but the trial is still active. + store + .update_run_status( + &scope, + run.run_uid, + ExperimentRunStatus::Cancelled, + Some("first attempt".to_string()), + Some(chrono::Utc::now()), + ) + .await? + .expect("run cancel should return run"); + + let response = moa_experiments::app::cancel_run( + pool.clone(), + ExperimentCancelRequest { + tenant_id, + run_uid: run.run_uid, + reason: Some("retry".to_string()), + }, + ) + .await + .expect("retried cancel behind a cancelled parent should succeed"); + + assert!( + !response.cancelled, + "an idempotent retry behind an already-cancelled parent reports cancelled=false" + ); + assert_eq!(response.status, "cancelled"); + let trials = store.list_trials(&scope, run.run_uid, None, 10).await?; + assert_trial_status( + &trials, + running.trial_uid, + ExperimentTrialStatus::Cancelled, + Some(ExperimentTrialStopReason::Cancelled), + ); + Ok(()) +} + #[tokio::test] #[ignore = "requires local Postgres configured through MOA_DATABASE_URL"] async fn concurrent_trial_creation_uses_unique_storage_partitions_db() -> Result<()> { diff --git a/crates/moa-knowledge/src/ingestion.rs b/crates/moa-knowledge/src/ingestion.rs index 81010af3..ec61a8b7 100644 --- a/crates/moa-knowledge/src/ingestion.rs +++ b/crates/moa-knowledge/src/ingestion.rs @@ -628,16 +628,19 @@ where }); } - let previous_chunks = if latest_version_completed { - latest_chunks - } else if latest_version - .as_ref() - .is_some_and(|latest| latest.content_hash == content_hash) - { - Vec::new() - } else { - latest_chunks - }; + // F07: reconcile against every currently active chunk for the object, not + // just the latest version's chunks. A failed or retried same-hash version + // transition leaves the newest version incomplete; keying `previous_chunks` + // off that version (previously an empty list) forgot the real predecessor + // and left both versions' chunks active. The new version's chunk hashes are + // the desired state, and any active prior chunk whose hash is absent is + // invalidated in `persist_claimed_version` — so a same-hash chunk (including + // the current version's own chunks re-persisted on retry) is preserved while + // a stale predecessor is reliably orphaned. + let previous_chunks = self + .repository + .active_chunks_for_object(object.object_uid) + .await?; let version = if let Some(latest) = latest_version && latest.content_hash == content_hash { @@ -1032,7 +1035,19 @@ where object: KnowledgeObject, _record: ProviderRecord, ) -> Result { - self.repository.upsert_object(object.clone()).await?; + // F06: persist the provider-deleted object's latest metadata in a + // non-terminal status before cleanup. `materialize_object` stamps + // provider-deleted records with terminal `deleted`; upserting that directly + // would strand active chunks the same way the old in-`delete_object` + // ordering did (a failed invalidation would leave a `deleted` row no prune + // pass revisits). `delete_object` writes the terminal status last, only + // after invalidation and tombstoning succeed. + let pre_cleanup_object = KnowledgeObject { + status: crate::domain::ObjectStatus::Active, + deleted_at: None, + ..object.clone() + }; + self.repository.upsert_object(pre_cleanup_object).await?; self.delete_object( sync_run_uid, object, @@ -1086,9 +1101,11 @@ where counter_delta, ) .await?; - self.repository - .mark_object_deleted(object.object_uid, Utc::now()) - .await?; + // F06: invalidate the graph and tombstone chunks FIRST (both idempotent), + // then write the terminal `deleted` status LAST. If invalidation fails the + // object stays non-terminal, so the next prune pass or deletion retry still + // selects it and finishes the cleanup — instead of a `deleted` row stranding + // active graph chunks that no later pass revisits. let latest = self .repository .latest_document_version(object.object_uid) @@ -1166,6 +1183,12 @@ where })), ) .await?; + // Terminal state written last: only now that invalidation and tombstoning + // have both succeeded is it safe to mark the object `deleted` and remove it + // from the prune/retry-eligible set. + self.repository + .mark_object_deleted(object.object_uid, Utc::now()) + .await?; Ok(1) } diff --git a/crates/moa-knowledge/src/repository.rs b/crates/moa-knowledge/src/repository.rs index 5267b25e..844e53c5 100644 --- a/crates/moa-knowledge/src/repository.rs +++ b/crates/moa-knowledge/src/repository.rs @@ -206,6 +206,19 @@ pub trait KnowledgeRepository: Send + Sync { /// Gets the chunks attached to one document version. async fn chunks_for_version(&self, version_uid: Uuid) -> Result>; + /// Returns every currently active (non-tombstoned) chunk for an object across + /// all of its document versions. + /// + /// Unlike [`Self::chunks_for_version`], this spans every version so + /// ingestion and deletion can reconcile against the object's true active + /// chunk set instead of a single remembered predecessor version. A failed or + /// retried version transition can leave stale-but-active chunks under an + /// older version; those must still be diffed against the new desired state so + /// they are invalidated rather than left retrievable. Chunks whose `active` + /// retrieval flag has been set to `false` by [`Self::tombstone_chunks`] are + /// excluded. + async fn active_chunks_for_object(&self, object_uid: Uuid) -> Result>; + /// Returns whether final object ingestion completed after a version timestamp. async fn object_ingestion_completed_since( &self, @@ -1280,6 +1293,28 @@ impl KnowledgeRepository for PostgresKnowledgeRepository { rows.iter().map(chunk_from_row).collect() } + async fn active_chunks_for_object(&self, object_uid: Uuid) -> Result> { + let mut conn = self.begin().await?; + let rows = sqlx::query( + r#" + SELECT c.chunk_uid, c.document_version_id, c.graph_node_uid, c.chunk_hash, + c.block_hashes, c.heading_path, c.text, c.ordinal, c.token_count, c.metadata + FROM moa.knowledge_chunks c + JOIN moa.knowledge_document_versions v + ON v.document_version_uid = c.document_version_id + WHERE v.object_id = $1 + AND COALESCE(c.metadata->>'active', 'true') <> 'false' + ORDER BY v.created_at ASC, c.ordinal ASC + "#, + ) + .bind(object_uid) + .fetch_all(conn.as_mut()) + .await + .map_err(map_sqlx_error)?; + conn.commit().await.map_err(map_moa_error)?; + rows.iter().map(chunk_from_row).collect() + } + async fn object_ingestion_completed_since( &self, object_uid: Uuid, diff --git a/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs b/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs index 264af6ae..b2a2d758 100644 --- a/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs +++ b/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet}, sync::{ Arc, Mutex, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, }; @@ -215,9 +215,16 @@ struct FakeGraphWriter { edges: Mutex>, vectors: Mutex>, invalidated: Mutex>, + /// When set, `invalidate_chunks` fails for any non-empty request, simulating + /// a graph-invalidation backend error mid-transition or mid-deletion. + fail_nonempty_invalidate: AtomicBool, } impl FakeGraphWriter { + fn set_fail_invalidate(&self, fail: bool) { + self.fail_nonempty_invalidate.store(fail, Ordering::SeqCst); + } + fn vector_count(&self) -> usize { self.vectors .lock() @@ -307,6 +314,11 @@ impl KnowledgeGraphWriter for FakeGraphWriter { &self, graph_node_uids: &[Uuid], ) -> moa_knowledge::Result { + if !graph_node_uids.is_empty() && self.fail_nonempty_invalidate.load(Ordering::SeqCst) { + return Err(moa_knowledge::Error::Repository( + "injected invalidate_chunks failure".to_string(), + )); + } let mut deleted = 0_u64; let mut vectors = self .vectors @@ -529,6 +541,264 @@ async fn ingestion_pipeline_skips_unchanged_reembeds_edits_and_tombstones_delete assert_no_secret_text(&graph_json); } +#[tokio::test] +async fn ingestion_reconciles_stale_predecessor_when_retrying_incomplete_same_hash_version_db_memory() + { + // Pins: F07 — a version transition that persists new chunks but fails before + // invalidating its predecessor leaves the newest version same-hash-incomplete. + // The retry must reconcile against every active chunk across all versions and + // orphan the real predecessor, instead of forgetting it (an empty + // `previous_chunks`) and leaving BOTH versions' chunks active and retrievable. + let db = postgres::bootstrap_test_db() + .await + .expect("bootstrap isolated Postgres"); + let pool = db.store().pool().clone(); + let tenant_id = TenantId::from(Uuid::now_v7()); + let connection_uid = Uuid::now_v7(); + let scope = RlsContext::tenant(tenant_id); + let repository = Arc::new(PostgresKnowledgeRepository::scoped_for_app_role( + pool.clone(), + scope, + )); + let embedder = Arc::new(CountingEmbedder::default()); + let graph = Arc::new(FakeGraphWriter::default()); + let pipeline = KnowledgeIngestionPipeline::new( + repository.clone(), + Arc::new(ParagraphParser), + embedder.clone(), + graph.clone(), + KnowledgeIngestionPipelineConfig { + chunking: ChunkingConfig { + target_tokens: 1, + max_tokens: 16, + min_tokens: 1, + }, + provider: "test_provider".to_string(), + parser_label: "test_parser".to_string(), + }, + ); + repository + .upsert_connection(KnowledgeConnection { + connection_uid, + tenant_id, + provider: "test_provider".to_string(), + connector: "docs".to_string(), + provider_account_id: "acct_1".to_string(), + credential_ref: "vault://knowledge/test".to_string(), + status: ConnectionStatus::Active, + metadata: credentialish_metadata(), + source_selection: json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + last_synced_at: None, + }) + .await + .expect("upsert connection"); + let object_uid = object_uid(connection_uid); + + // Attempt A: first content completes version V1 with two active chunks. + let run_a = create_run(&repository, tenant_id, connection_uid).await; + pipeline + .ingest_record_page( + run_a, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-a", false, "Alpha one.\n\nBeta one.")], + next_cursor: None, + }, + ) + .await + .expect("ingest first content"); + assert_eq!(version_count(&pool, object_uid).await, 1); + assert_eq!(active_chunk_count(&pool, object_uid).await, 2); + + // Attempt B: new content creates V2 and persists its chunks, then fails at + // predecessor invalidation. Both V1 and V2 chunks are now active. + graph.set_fail_invalidate(true); + let run_b = create_run(&repository, tenant_id, connection_uid).await; + let attempt_b = pipeline + .ingest_record_page( + run_b, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-b", false, "Gamma two.\n\nDelta two.")], + next_cursor: None, + }, + ) + .await; + assert!( + attempt_b.is_err(), + "failed predecessor invalidation must surface as an error" + ); + assert_eq!(version_count(&pool, object_uid).await, 2); + assert_eq!( + active_chunk_count(&pool, object_uid).await, + 4, + "both versions' chunks are stranded active before the retry" + ); + + // Attempt C: retry the same content (same hash, new change token). Invalidation + // now succeeds; reconciliation must orphan V1's chunks rather than forget them. + graph.set_fail_invalidate(false); + let run_c = create_run(&repository, tenant_id, connection_uid).await; + pipeline + .ingest_record_page( + run_c, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-c", false, "Gamma two.\n\nDelta two.")], + next_cursor: None, + }, + ) + .await + .expect("retry same content"); + assert_eq!( + version_count(&pool, object_uid).await, + 2, + "the same-hash retry reuses V2 rather than creating a third version" + ); + assert_eq!( + active_chunk_count(&pool, object_uid).await, + 2, + "only the newest version's chunks remain active after reconciliation" + ); + assert_eq!( + tombstoned_chunk_count(&pool, object_uid).await, + 2, + "the stale predecessor's chunks are invalidated, not left active" + ); +} + +#[tokio::test] +async fn deletion_writes_terminal_status_last_and_stays_retryable_on_invalidation_failure_db_memory() + { + // Pins: F06 — a provider deletion whose graph invalidation fails must NOT leave + // the object in terminal `deleted` with active chunks stranded. The object stays + // non-terminal (so it is still selectable), and a later prune completes the + // deletion. This covers both the `handle_deleted_record` entry point (no direct + // upsert into terminal `deleted`) and the terminal-state-last ordering. + let db = postgres::bootstrap_test_db() + .await + .expect("bootstrap isolated Postgres"); + let pool = db.store().pool().clone(); + let tenant_id = TenantId::from(Uuid::now_v7()); + let connection_uid = Uuid::now_v7(); + let scope = RlsContext::tenant(tenant_id); + let repository = Arc::new(PostgresKnowledgeRepository::scoped_for_app_role( + pool.clone(), + scope, + )); + let embedder = Arc::new(CountingEmbedder::default()); + let graph = Arc::new(FakeGraphWriter::default()); + let pipeline = KnowledgeIngestionPipeline::new( + repository.clone(), + Arc::new(ParagraphParser), + embedder.clone(), + graph.clone(), + KnowledgeIngestionPipelineConfig { + chunking: ChunkingConfig { + target_tokens: 1, + max_tokens: 16, + min_tokens: 1, + }, + provider: "test_provider".to_string(), + parser_label: "test_parser".to_string(), + }, + ); + repository + .upsert_connection(KnowledgeConnection { + connection_uid, + tenant_id, + provider: "test_provider".to_string(), + connector: "docs".to_string(), + provider_account_id: "acct_1".to_string(), + credential_ref: "vault://knowledge/test".to_string(), + status: ConnectionStatus::Active, + metadata: credentialish_metadata(), + source_selection: json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + last_synced_at: None, + }) + .await + .expect("upsert connection"); + let object_uid = object_uid(connection_uid); + + // Ingest content so the object has one active version with two active chunks. + let run_a = create_run(&repository, tenant_id, connection_uid).await; + pipeline + .ingest_record_page( + run_a, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-a", false, "Alpha one.\n\nBeta one.")], + next_cursor: None, + }, + ) + .await + .expect("ingest content"); + assert_eq!(active_chunk_count(&pool, object_uid).await, 2); + assert_eq!(object_status(&pool, object_uid).await, "active"); + + // Provider deletion with failing invalidation: the object must remain + // non-terminal (never upserted straight into `deleted`) and keep its chunks. + graph.set_fail_invalidate(true); + let run_del = create_run(&repository, tenant_id, connection_uid).await; + let failed = pipeline + .ingest_record_page( + run_del, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-del", true, "")], + next_cursor: None, + }, + ) + .await; + assert!( + failed.is_err(), + "failed deletion invalidation must surface as an error" + ); + assert_eq!( + object_status(&pool, object_uid).await, + "active", + "terminal `deleted` must not be written before cleanup succeeds" + ); + assert_eq!( + active_chunk_count(&pool, object_uid).await, + 2, + "chunks stay active and selectable for a cleanup retry" + ); + + // Retry via prune: the still-active object is selected and deletion completes. + graph.set_fail_invalidate(false); + let run_prune = create_run(&repository, tenant_id, connection_uid).await; + pipeline + .prune_unseen_objects( + run_prune, + connection_uid, + tenant_id, + &std::collections::HashSet::new(), + ) + .await + .expect("prune completes the stranded deletion"); + assert_eq!( + object_status(&pool, object_uid).await, + "deleted", + "deletion completes once invalidation succeeds" + ); + assert_eq!( + active_chunk_count(&pool, object_uid).await, + 0, + "all chunks are invalidated once the deletion completes" + ); + assert_eq!(tombstoned_chunk_count(&pool, object_uid).await, 2); +} + #[tokio::test] async fn semantic_graph_extraction_is_cached_reported_and_written_db_memory() { // Pins: semantic graph extraction is a persisted ingestion-time cache with @@ -2149,6 +2419,23 @@ async fn tombstoned_chunk_count(pool: &sqlx::PgPool, object_uid: Uuid) -> i64 { .expect("count tombstoned chunks") } +async fn active_chunk_count(pool: &sqlx::PgPool, object_uid: Uuid) -> i64 { + sqlx::query_scalar::<_, i64>( + r#" + SELECT count(*) + FROM moa.knowledge_chunks c + JOIN moa.knowledge_document_versions v + ON v.document_version_uid = c.document_version_id + WHERE v.object_id = $1 + AND COALESCE(c.metadata->>'active', 'true') <> 'false' + "#, + ) + .bind(object_uid) + .fetch_one(pool) + .await + .expect("count active chunks") +} + async fn object_status(pool: &sqlx::PgPool, object_uid: Uuid) -> String { sqlx::query_scalar::<_, String>( "SELECT status FROM moa.knowledge_objects WHERE object_uid = $1", diff --git a/crates/moa-lineage/sink/src/fjall_journal.rs b/crates/moa-lineage/sink/src/fjall_journal.rs index 81e2e6c5..eb031e1b 100644 --- a/crates/moa-lineage/sink/src/fjall_journal.rs +++ b/crates/moa-lineage/sink/src/fjall_journal.rs @@ -102,6 +102,25 @@ impl Journal { self.partition.approximate_len() } + /// Returns the lowest-sequence pending entry, or `None` when the journal is empty. + /// + /// Used to report the oldest unacknowledged row's age. The scan starts at + /// sequence 0 and takes the first key, so it is bounded by the index seek + /// rather than the full pending set. + pub fn oldest_entry(&self) -> Result)>> { + let read_tx = self.keyspace.read_tx(); + let start = 0_u64.to_be_bytes(); + let Some(kv) = read_tx.range(&self.partition, start..).next() else { + return Ok(None); + }; + let (key, value) = kv.into_inner()?; + let bytes: [u8; 8] = key + .as_ref() + .try_into() + .map_err(|_| Error::InvalidJournalKey)?; + Ok(Some((u64::from_be_bytes(bytes), value.to_vec()))) + } + /// Returns the number of durability syncs issued so far. /// /// Exposed so group-commit tests can assert that a batch append performs exactly one sync. diff --git a/crates/moa-lineage/sink/src/writer.rs b/crates/moa-lineage/sink/src/writer.rs index 3f6139f1..0aac27b4 100644 --- a/crates/moa-lineage/sink/src/writer.rs +++ b/crates/moa-lineage/sink/src/writer.rs @@ -227,6 +227,23 @@ impl DurableJournal { tokio::task::spawn_blocking(move || journal.approximate_len()).await? } + /// Returns the age in seconds of the oldest unacknowledged journal row. + /// + /// `None` when the journal is empty. Reads the lowest-sequence pending row and + /// derives the age from its event timestamp, so it reports how long the + /// oldest still-pending lineage record has waited to reach the row store. + async fn oldest_pending_age_seconds(&self) -> Result> { + let journal = self.clone(); + let oldest = tokio::task::spawn_blocking(move || journal.lock()?.oldest_entry()).await??; + let Some((_, payload)) = oldest else { + return Ok(None); + }; + let row = decode_pending_row(&payload) + .map_err(|error| Error::Invalid(format!("decode oldest journal row: {error}")))?; + let age_ms = (Utc::now() - pending_row_ts(&row)).num_milliseconds(); + Ok(Some((age_ms.max(0) as f64) / 1000.0)) + } + fn lock(&self) -> Result> { self.inner .lock() @@ -353,7 +370,7 @@ async fn run_writer( } } - record_journal_depth(&stats, journal.approximate_len_async().await? as u64); + record_journal_metrics(&journal, &stats).await?; Ok(stats.snapshot()) } @@ -401,7 +418,7 @@ async fn replay_pending( let cursor = journal.replay_cursor(); let pending = journal.replay_from(cursor).await?; if pending.is_empty() { - record_journal_depth(stats, journal.approximate_len_async().await? as u64); + record_journal_metrics(journal, stats).await?; return Ok(()); } @@ -423,7 +440,7 @@ async fn replay_pending( if disposition == WriteDisposition::Written { record_flush(stats, rows.len()); } - record_journal_depth(stats, journal.approximate_len_async().await? as u64); + record_journal_metrics(journal, stats).await?; Ok(()) } @@ -444,7 +461,7 @@ async fn flush_events( seqs.push(seq); rows.push(row); } - record_journal_depth(stats, journal.approximate_len_async().await? as u64); + record_journal_metrics(journal, stats).await?; let disposition = write_pending_rows_or_dead_letter(store, &rows).await?; if should_ack_journal(disposition) { @@ -453,12 +470,25 @@ async fn flush_events( if disposition == WriteDisposition::Written { record_flush(stats, rows.len()); } - record_journal_depth(stats, journal.approximate_len_async().await? as u64); + record_journal_metrics(journal, stats).await?; Ok(()) } fn should_ack_journal(disposition: WriteDisposition) -> bool { - disposition == WriteDisposition::Written + // Both a successful write and a dead-letter acknowledge (remove) the journal + // sequences. A `DeadLettered` disposition is only returned AFTER the + // dead-letter row is durably committed to Postgres (see + // `write_pending_rows_or_dead_letter`), so acking here can never lose + // records: a crash between the DLQ commit and the ack replays the same + // journal bytes on restart, which re-derives the identical content-addressed + // `dead_letter_id` and upserts it (`ON CONFLICT DO UPDATE`) rather than + // duplicating the row. The dead-letter table is therefore the sole + // retention/redrive owner; leaving poison rows in the journal only grew local + // storage without bound and re-dead-lettered them on every restart. + matches!( + disposition, + WriteDisposition::Written | WriteDisposition::DeadLettered + ) } async fn write_pending_rows_or_dead_letter( @@ -567,6 +597,10 @@ async fn write_dead_letter_batch( .await?; metrics::counter!("moa_lineage_dead_lettered_total").increment(summary.row_count as u64); + // Batch-level commit counter: one increment per durably committed dead-letter + // batch (distinct from the row counter above), so operators can track + // dead-letter volume and drive redrive/alerting off the table. + metrics::counter!("moa_lineage_dead_letter_commits_total").increment(1); Ok(dead_letter_id) } @@ -1236,6 +1270,26 @@ fn record_journal_depth(stats: &SharedWriterStats, depth: u64) { metrics::gauge!("moa_lineage_journal_depth").set(depth as f64); } +/// Refreshes the journal-health gauges: pending depth and oldest-unacked age. +/// +/// After the F16 fix both successful and dead-lettered batches are acknowledged, +/// so a healthy writer keeps depth and oldest-age near zero; a rising oldest-age +/// means rows are stuck pending (the row store is failing without dead-lettering +/// yet), which the depth gauge alone cannot distinguish from steady throughput. +async fn record_journal_metrics(journal: &DurableJournal, stats: &SharedWriterStats) -> Result<()> { + record_journal_depth(stats, journal.approximate_len_async().await? as u64); + let oldest_age = journal.oldest_pending_age_seconds().await?; + metrics::gauge!("moa_lineage_journal_oldest_age_seconds").set(oldest_age.unwrap_or(0.0)); + Ok(()) +} + +fn pending_row_ts(row: &PendingRow) -> DateTime { + match row { + PendingRow::Lineage(row) => row.ts, + PendingRow::Score(row) => row.ts, + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "table", content = "row", rename_all = "snake_case")] enum PendingRow { @@ -1619,10 +1673,13 @@ mod tests { } #[test] - fn dead_letter_disposition_does_not_ack_journal() { - // Pins: poison batches remain pending so compliance verification can see the gap. + fn dead_letter_disposition_acks_journal() { + // Pins (F16): both successful writes and dead-letters acknowledge the journal + // sequences. Dead-lettered rows are durably committed to the DLQ table first, + // so acking removes them from the journal (bounding local storage) instead of + // retaining and re-dead-lettering them on every restart. assert!(super::should_ack_journal(super::WriteDisposition::Written)); - assert!(!super::should_ack_journal( + assert!(super::should_ack_journal( super::WriteDisposition::DeadLettered )); } diff --git a/crates/moa-lineage/sink/tests/writer_db.rs b/crates/moa-lineage/sink/tests/writer_db.rs index 0e3d444e..3a166a59 100644 --- a/crates/moa-lineage/sink/tests/writer_db.rs +++ b/crates/moa-lineage/sink/tests/writer_db.rs @@ -5,9 +5,10 @@ //! coverage: //! 1. graceful shutdown flushes the in-memory batch so pending rows land in //! `analytics.turn_lineage`; and -//! 2. a batch whose write fails non-retryably is persisted to -//! `analytics.lineage_dead_letters` and its journal sequence is left -//! unacknowledged (so it can be reprocessed). +//! 2. a batch whose write fails non-retryably is persisted once to +//! `analytics.lineage_dead_letters` and its journal sequence is acknowledged +//! after the DLQ commit (F16), so the journal drains and a restart neither +//! replays the row into `turn_lineage` nor re-dead-letters it. //! //! They require `MOA_DATABASE_URL` to point at a reachable Postgres superuser //! role that can `CREATE DATABASE`; each test creates and drops its own database. @@ -75,12 +76,13 @@ async fn lineage_writer_flush_on_shutdown_drains_pending_rows_db() -> TestResult } #[tokio::test] -async fn lineage_writer_poison_batch_dead_letters_without_acking_journal_db() -> TestResult<()> { - // Pins: a batch whose write fails non-retryably is moved to - // `analytics.lineage_dead_letters`, and its journal sequence is NOT acked. - // The "not acked" guarantee is proven behaviorally: a fresh writer over the - // same journal replays the retained row and finally persists it once the - // write target is healthy again. +async fn lineage_writer_poison_batch_dead_letters_and_acks_journal_db() -> TestResult<()> { + // Pins (F16): a batch whose write fails non-retryably is moved once to + // `analytics.lineage_dead_letters` AND its journal sequence is acknowledged + // after the DLQ commit, so the journal drains (depth 0) and a fresh writer + // over the same journal neither replays the row into `turn_lineage` nor + // re-dead-letters it. This is the inverse of the pre-F16 behavior, where the + // retained sequence was replayed on every restart. let (pool, database_name, cleanup_pool) = isolated_pool().await?; let journal = tempfile::tempdir()?; let journal_path = journal.path().to_path_buf(); @@ -112,9 +114,14 @@ async fn lineage_writer_poison_batch_dead_letters_without_acking_journal_db() -> stats.written, 0, "the poison batch must not count as written" ); - - let dead_letters: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM analytics.lineage_dead_letters WHERE first_turn_id = $1", + // Note: `journal_depth` reflects fjall's approximate_len, which counts the + // removal tombstone alongside the original insert, so it is not a reliable + // exact-zero signal. The acknowledgement is instead proven deterministically + // below: a restart over the same journal does not replay the row (replay reads + // real keys, not the approximate count). + + let (dead_letters, row_count, partition): (i64, i32, String) = sqlx::query_as( + "SELECT COUNT(*), MAX(row_count), MAX(first_storage_partition_id) FROM analytics.lineage_dead_letters WHERE first_turn_id = $1", ) .bind(turn_id) .fetch_one(&pool) @@ -123,13 +130,6 @@ async fn lineage_writer_poison_batch_dead_letters_without_acking_journal_db() -> dead_letters, 1, "the poison batch should be persisted exactly once to dead-letter storage" ); - - let (row_count, partition): (i32, String) = sqlx::query_as( - "SELECT row_count, first_storage_partition_id FROM analytics.lineage_dead_letters WHERE first_turn_id = $1", - ) - .bind(turn_id) - .fetch_one(&pool) - .await?; assert_eq!( row_count, 1, "the dead-letter row should record one buffered event" @@ -139,17 +139,16 @@ async fn lineage_writer_poison_batch_dead_letters_without_acking_journal_db() -> "the dead-letter row should retain the source partition for triage" ); - // Recovery: a new writer over the same journal (with the target restored by - // its own idempotent schema bootstrap) replays the retained, unacked row and - // finally persists it -- proving the journal kept it. + // Restart over the same journal with a healthy store (its schema bootstrap + // restores turn_lineage). Because the dead-lettered sequence was acked, the + // row is NOT replayed into turn_lineage and NOT re-dead-lettered. let (tx2, rx2) = mpsc::channel::(64); let handle2 = spawn_writer(rx2, config, LineageStore::Postgres(pool.clone())).await?; drop(tx2); let recovery_stats = handle2.shutdown().await?; - assert!( - recovery_stats.written >= 1, - "the replayed journal row should be written on recovery, got {}", - recovery_stats.written + assert_eq!( + recovery_stats.written, 0, + "the acked dead-letter row must not be replayed on restart" ); let written_after_restart: i64 = @@ -158,8 +157,67 @@ async fn lineage_writer_poison_batch_dead_letters_without_acking_journal_db() -> .fetch_one(&pool) .await?; assert_eq!( - written_after_restart, 1, - "the retained journal row must be replayed exactly once into turn_lineage" + written_after_restart, 0, + "the dead-lettered row lives only in the DLQ, never replayed into turn_lineage" + ); + + let dead_letters_after_restart: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM analytics.lineage_dead_letters WHERE first_turn_id = $1", + ) + .bind(turn_id) + .fetch_one(&pool) + .await?; + assert_eq!( + dead_letters_after_restart, 1, + "restart must not create a duplicate dead-letter row" + ); + + pool.close().await; + drop_database(&cleanup_pool, &database_name).await; + Ok(()) +} + +#[tokio::test] +async fn lineage_writer_repeated_dead_letter_of_same_batch_is_idempotent_db() -> TestResult<()> { + // Pins (F16): the crash-between-DLQ-commit-and-ack window is safe. Processing the + // identical batch twice (two fresh writer lifetimes over a still-poisoned target) + // re-derives the same content-addressed dead_letter_id and upserts it, so + // at-least-once dead-lettering yields exactly one DLQ row, never a duplicate. + let (pool, database_name, cleanup_pool) = isolated_pool().await?; + let turn_id = Uuid::now_v7(); + let event = retrieval_event(turn_id, "idempotent-poison"); + + for _ in 0..2 { + let journal = tempfile::tempdir()?; + let config = MpscSinkConfig { + channel_capacity: 64, + batch_size: 100, + batch_max_age: Duration::from_secs(3600), + journal_path: journal.path().to_path_buf(), + }; + let (tx, rx) = mpsc::channel::(64); + // spawn_writer bootstraps the schema, so drop the target AFTER it opens to + // keep the write path poisoned for this lifetime. + let handle = spawn_writer(rx, config, LineageStore::Postgres(pool.clone())).await?; + sqlx::query("DROP TABLE analytics.turn_lineage CASCADE") + .execute(&pool) + .await?; + tx.send(event.clone()) + .await + .map_err(|error| test_error(format!("send should enqueue event: {error}")))?; + drop(tx); + handle.shutdown().await?; + } + + let dead_letters: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM analytics.lineage_dead_letters WHERE first_turn_id = $1", + ) + .bind(turn_id) + .fetch_one(&pool) + .await?; + assert_eq!( + dead_letters, 1, + "identical re-dead-lettered content must upsert to exactly one DLQ row" ); pool.close().await; diff --git a/crates/moa-loadtest/src/merge.rs b/crates/moa-loadtest/src/merge.rs index abb0df1e..99f517e9 100644 --- a/crates/moa-loadtest/src/merge.rs +++ b/crates/moa-loadtest/src/merge.rs @@ -45,6 +45,74 @@ pub struct MergedSummary { pub resource_bill: ResourceBillReport, } +/// Campaign identity that every merged worker report must share. +/// +/// Merging sums rates/counts and adds histograms, which is only meaningful when +/// the workers ran the SAME campaign shape. This fingerprint captures the run +/// parameters that must match — mode, target endpoint, session profile, and the +/// duration/warmup windows — so reports from different campaigns are rejected +/// rather than silently combined into a meaningless aggregate. (There is no +/// distinct campaign-id field on `LoadTestReport` today; these run parameters are +/// what the report carries. A dedicated campaign id would need run-time wiring at +/// report production — deferred.) +#[derive(Debug, Clone, PartialEq)] +struct CampaignFingerprint { + mode: LoadMode, + endpoint: String, + profile: SessionProfileKind, + duration_ms: f64, + warmup_ms: f64, +} + +impl CampaignFingerprint { + fn from_report(report: &LoadTestReport) -> Self { + Self { + mode: report.mode, + endpoint: report.endpoint.clone(), + profile: report.profile, + duration_ms: report.duration_ms, + warmup_ms: report.warmup_ms, + } + } + + /// Returns human-readable descriptions of every field that differs, so an + /// incompatible-merge error names exactly what did not match. + fn mismatched_fields(&self, other: &Self) -> Vec { + let mut mismatches = Vec::new(); + if self.mode != other.mode { + mismatches.push(format!("mode ({:?} != {:?})", self.mode, other.mode)); + } + if self.endpoint != other.endpoint { + mismatches.push(format!( + "endpoint ({} != {})", + self.endpoint, other.endpoint + )); + } + if self.profile != other.profile { + mismatches.push(format!( + "profile ({:?} != {:?})", + self.profile, other.profile + )); + } + // Exact bit comparison for the config-derived window values, which are + // serialized identically across shards of the same campaign (and avoids + // the float-equality lint). + if self.duration_ms.to_bits() != other.duration_ms.to_bits() { + mismatches.push(format!( + "duration_ms ({} != {})", + self.duration_ms, other.duration_ms + )); + } + if self.warmup_ms.to_bits() != other.warmup_ms.to_bits() { + mismatches.push(format!( + "warmup_ms ({} != {})", + self.warmup_ms, other.warmup_ms + )); + } + mismatches + } +} + fn add_errors(total: &mut ErrorTaxonomy, part: &ErrorTaxonomy) { total.turn_start_failures += part.turn_start_failures; total.turn_timeouts += part.turn_timeouts; @@ -88,6 +156,7 @@ pub fn merge_report_files(paths: &[impl AsRef]) -> Result { resource_bill: ResourceBillReport::default(), }; + let mut fingerprint: Option = None; for path in paths { let path = path.as_ref(); let body = std::fs::read_to_string(path).map_err(|error| { @@ -96,6 +165,22 @@ pub fn merge_report_files(paths: &[impl AsRef]) -> Result { let report: LoadTestReport = serde_json::from_str(&body).map_err(|error| { MoaError::SerializationError(format!("parse report {}: {error}", path.display())) })?; + // Refuse to merge reports from different campaigns: summing counts and + // adding histograms across mismatched runs produces a meaningless total. + let current_fingerprint = CampaignFingerprint::from_report(&report); + match &fingerprint { + None => fingerprint = Some(current_fingerprint), + Some(expected) => { + let mismatches = expected.mismatched_fields(¤t_fingerprint); + if !mismatches.is_empty() { + return Err(MoaError::ValidationError(format!( + "cannot merge incompatible load-test report {}: mismatched campaign field(s): {}", + path.display(), + mismatches.join(", ") + ))); + } + } + } let hdr = report.hdr.as_ref().ok_or_else(|| { MoaError::ValidationError(format!( "report {} has no embedded histograms; regenerate with --output json", @@ -336,6 +421,95 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn mismatched_profile_reports_refuse_to_merge() { + // Pins (F29): reports from different campaigns (here different session profiles) + // are rejected rather than summed into a meaningless aggregate, and the error + // names the mismatched field. + let mut recorder = + LatencyRecorder::new(Duration::from_secs(10), Duration::ZERO).expect("recorder"); + recorder + .record_turn( + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_millis(1_010), + None, + None, + ) + .expect("turn"); + + let dir = std::env::temp_dir().join(format!("moa-merge-mismatch-{}", Uuid::now_v7())); + std::fs::create_dir_all(&dir).expect("temp dir"); + + let mut short_report = template_report(&recorder); + short_report.profile = SessionProfileKind::Short; + let mut long_report = template_report(&recorder); + long_report.profile = SessionProfileKind::Long; + + let short_path = dir.join("worker-short.json"); + let long_path = dir.join("worker-long.json"); + std::fs::write( + &short_path, + serde_json::to_string(&short_report).expect("serialize"), + ) + .expect("write"); + std::fs::write( + &long_path, + serde_json::to_string(&long_report).expect("serialize"), + ) + .expect("write"); + + let error = merge_report_files(&[short_path, long_path]) + .expect_err("reports from different profiles must not merge"); + let message = error.to_string(); + assert!( + message.contains("mismatched campaign field"), + "error should explain the incompatibility: {message}" + ); + assert!( + message.contains("profile"), + "error must name the mismatched profile field: {message}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn matching_reports_merge_and_sum_totals() { + // Pins (F29): reports sharing a campaign fingerprint still merge, summing the + // per-worker counters as before the compatibility check was added. + let mut recorder = + LatencyRecorder::new(Duration::from_secs(10), Duration::ZERO).expect("recorder"); + for _ in 0..2 { + recorder + .record_turn( + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_millis(1_010), + None, + None, + ) + .expect("turn"); + } + + let dir = std::env::temp_dir().join(format!("moa-merge-match-{}", Uuid::now_v7())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let report = template_report(&recorder); + let mut paths = Vec::new(); + for index in 0..2 { + let path = dir.join(format!("worker-{index}.json")); + std::fs::write(&path, serde_json::to_string(&report).expect("serialize")) + .expect("write"); + paths.push(path); + } + + let merged = merge_report_files(&paths).expect("matching reports merge"); + + assert_eq!(merged.workers, 2); + assert_eq!(merged.turns_completed, 2 * recorder.corrected_len()); + assert_eq!(merged.requested_rate_qps, 20.0, "worker rates sum"); + std::fs::remove_dir_all(&dir).ok(); + } + fn template_report(recorder: &LatencyRecorder) -> LoadTestReport { LoadTestReport { mode: LoadMode::Mock, diff --git a/crates/moa-memory/graph/src/store.rs b/crates/moa-memory/graph/src/store.rs index f07fb150..9044f1bf 100644 --- a/crates/moa-memory/graph/src/store.rs +++ b/crates/moa-memory/graph/src/store.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use moa_core::RlsContext; use moa_db::ScopedConn; -use moa_memory_vector::{VectorPostCommitSync, VectorStore}; +use moa_memory_vector::VectorStore; use sqlx::{PgConnection, PgPool}; use crate::{ @@ -19,7 +19,6 @@ pub struct PostgresGraphStore { pub(crate) scope: Option, pub(crate) assume_app_role: bool, pub(crate) vector: Option>, - pub(crate) vector_post_commit_sync: Option>, } impl PostgresGraphStore { @@ -33,7 +32,6 @@ impl PostgresGraphStore { scope: None, assume_app_role: false, vector: None, - vector_post_commit_sync: None, } } @@ -44,7 +42,6 @@ impl PostgresGraphStore { scope: Some(scope), assume_app_role: false, vector: None, - vector_post_commit_sync: None, } } @@ -58,7 +55,6 @@ impl PostgresGraphStore { scope: Some(scope), assume_app_role: true, vector: None, - vector_post_commit_sync: None, } } @@ -68,12 +64,6 @@ impl PostgresGraphStore { self } - /// Attaches a post-commit hook for external vector projection sync. - pub fn with_vector_post_commit_sync(mut self, hook: Arc) -> Self { - self.vector_post_commit_sync = Some(hook); - self - } - /// Returns the underlying Postgres pool. pub fn pool(&self) -> &PgPool { &self.pool @@ -89,11 +79,6 @@ impl PostgresGraphStore { self.vector.as_deref() } - /// Returns the post-commit vector sync hook, when configured. - pub fn vector_post_commit_sync(&self) -> Option<&dyn VectorPostCommitSync> { - self.vector_post_commit_sync.as_deref() - } - /// Creates a node using a caller-owned scoped Postgres connection. /// /// This is used by adjacent domain crates that need to compose a graph write with their own diff --git a/crates/moa-memory/graph/src/write.rs b/crates/moa-memory/graph/src/write.rs index ada5c324..140fe3c9 100644 --- a/crates/moa-memory/graph/src/write.rs +++ b/crates/moa-memory/graph/src/write.rs @@ -24,7 +24,6 @@ pub async fn create_node(store: &PostgresGraphStore, intent: NodeWriteIntent) -> let mut conn = store.begin_required().await?; let uid = create_node_in_conn(store, conn.as_mut(), intent).await?; conn.commit().await?; - sync_vector_post_commit(store, "create_node").await; Ok(uid) } @@ -166,7 +165,6 @@ pub async fn bulk_create_nodes( } conn.commit().await?; - sync_vector_post_commit(store, "bulk_create_nodes").await; Ok(uids) } @@ -179,7 +177,6 @@ pub async fn supersede_node( let mut conn = store.begin_required().await?; let uid = supersede_node_in_conn(store, conn.as_mut(), old_uid, new).await?; conn.commit().await?; - sync_vector_post_commit(store, "supersede_node").await; Ok(uid) } @@ -329,7 +326,6 @@ pub async fn invalidate_node(store: &PostgresGraphStore, uid: Uuid, reason: &str .await?; conn.commit().await?; - sync_vector_post_commit(store, "invalidate_node").await; Ok(()) } @@ -416,7 +412,6 @@ pub async fn close_existing_node_with_supersession( .await?; conn.commit().await?; - sync_vector_post_commit(store, "close_existing_node_with_supersession").await; Ok(()) } @@ -474,7 +469,6 @@ pub async fn expire_node(store: &PostgresGraphStore, intent: NodeExpiryIntent) - .await?; conn.commit().await?; - sync_vector_post_commit(store, "expire_node").await; Ok(true) } @@ -656,7 +650,6 @@ pub async fn upsert_node_embedding( .await?; conn.commit().await?; - sync_vector_post_commit(store, "upsert_node_embedding").await; Ok(()) } @@ -721,7 +714,6 @@ pub async fn hard_purge_with_audit( .await?; conn.commit().await?; - sync_vector_post_commit(store, "hard_purge").await; Ok(()) } @@ -1252,19 +1244,6 @@ fn require_vector_store(store: &PostgresGraphStore) -> Result<&dyn VectorStore> }) } -async fn sync_vector_post_commit(store: &PostgresGraphStore, operation: &'static str) { - let Some(hook) = store.vector_post_commit_sync() else { - return; - }; - if let Err(error) = hook.sync_post_commit().await { - tracing::warn!( - error = %error, - operation, - "post-commit vector sync failed; queued rows remain pending" - ); - } -} - fn create_changelog(intent: &NodeWriteIntent, cause_change_id: Option) -> ChangelogRecord { ChangelogRecord { storage_partition_id: intent.storage_partition_id.clone(), diff --git a/crates/moa-memory/graph/tests/memory_graph_db_memory/write_protocol_db_memory.rs b/crates/moa-memory/graph/tests/memory_graph_db_memory/write_protocol_db_memory.rs index cbd48f96..b6ed8872 100644 --- a/crates/moa-memory/graph/tests/memory_graph_db_memory/write_protocol_db_memory.rs +++ b/crates/moa-memory/graph/tests/memory_graph_db_memory/write_protocol_db_memory.rs @@ -1,9 +1,6 @@ //! Integration coverage for atomic graph-memory write modes. -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; +use std::sync::Arc; use chrono::{Duration, Utc}; use moa_core::RlsContext; @@ -13,9 +10,7 @@ use moa_memory_graph::{ EdgeLabel, EdgeWriteIntent, GraphStore, NodeLabel, NodeWriteIntent, PiiClass, PostgresGraphStore, }; -use moa_memory_vector::{ - PgvectorStore, VectorItem, VectorPostCommitSync, VectorQuery, VectorStore, -}; +use moa_memory_vector::{PgvectorStore, VectorItem, VectorQuery, VectorStore}; use moa_session::testing; use moa_test_support::fixtures::stable_uuid_from_label; use serde_json::json; @@ -39,13 +34,6 @@ struct EdgeIndexRow { struct RecordingVectorStore {} -struct RecordingPostCommitSync { - pool: PgPool, - target_uid: Uuid, - post_commit_calls: Arc, - visible_rows_at_sync: Arc, -} - #[async_trait::async_trait] impl VectorStore for RecordingVectorStore { fn backend(&self) -> &'static str { @@ -88,21 +76,6 @@ impl VectorStore for RecordingVectorStore { } } -#[async_trait::async_trait] -impl VectorPostCommitSync for RecordingPostCommitSync { - async fn sync_post_commit(&self) -> Result<(), moa_memory_vector::Error> { - let visible_rows = - sqlx::query_scalar::<_, i64>("SELECT count(*) FROM moa.node_index WHERE uid = $1") - .bind(self.target_uid) - .fetch_one(&self.pool) - .await?; - self.visible_rows_at_sync - .store(visible_rows as usize, Ordering::SeqCst); - self.post_commit_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } -} - fn tenant_scope(storage_partition_id: impl AsRef) -> RlsContext { let storage_partition_id = storage_partition_id.as_ref(); let tenant_id = Uuid::parse_str(storage_partition_id) @@ -615,9 +588,12 @@ async fn erase_payload_hash_exists(pool: &PgPool, storage_partition_id: &str, ui } #[tokio::test] -async fn graph_write_runs_vector_sync_after_commit_db_memory() { - // Pins: external vector sync is a post-commit hook. The hook reads the node - // through a separate connection and must see the committed graph row. +async fn graph_write_with_vector_store_commits_node_state_db_memory() { + // Pins: a graph create with an attached vector store commits the node and its + // embedder state. External projection is queue-only (F10): the write path runs + // no synchronous external drain — the vector-sync outbox row it enqueues is + // delivered by the background cron — so a slow or failing external backend + // never blocks the authoritative write. let _guard = TEST_LOCK.lock().await; let (session_store, database_url, schema_name) = testing::create_isolated_test_store() .await @@ -626,33 +602,21 @@ async fn graph_write_runs_vector_sync_after_commit_db_memory() { let intent = node_intent( &storage_partition_id, NodeLabel::Fact, - "post commit vector sync fact", + "queue only vector sync fact", Utc::now(), Some(basis_vector(0)), ); - let post_commit_calls = Arc::new(AtomicUsize::new(0)); - let visible_rows_at_sync = Arc::new(AtomicUsize::new(0)); - let vector = RecordingVectorStore {}; - let hook = RecordingPostCommitSync { - pool: session_store.pool().clone(), - target_uid: intent.uid, - post_commit_calls: post_commit_calls.clone(), - visible_rows_at_sync: visible_rows_at_sync.clone(), - }; let graph = PostgresGraphStore::scoped_for_app_role( session_store.pool().clone(), tenant_scope(storage_partition_id.clone()), ) - .with_vector_store(Arc::new(vector)) - .with_vector_post_commit_sync(Arc::new(hook)); + .with_vector_store(Arc::new(RecordingVectorStore {})); let uid = graph .create_node(intent) .await - .expect("create node with vector post-commit hook"); + .expect("create node commits without a synchronous vector drain"); - assert_eq!(post_commit_calls.load(Ordering::SeqCst), 1); - assert_eq!(visible_rows_at_sync.load(Ordering::SeqCst), 1); assert_eq!( embedding_model_for_partition(session_store.pool(), &storage_partition_id).await, "test-model" diff --git a/crates/moa-memory/ingest/src/fast_path.rs b/crates/moa-memory/ingest/src/fast_path.rs index afa14946..96716a1a 100644 --- a/crates/moa-memory/ingest/src/fast_path.rs +++ b/crates/moa-memory/ingest/src/fast_path.rs @@ -1023,8 +1023,7 @@ async fn runtime_fast_ctx_from_runtime( vector_factory.transactional_graph_backend(pool.clone(), scope.clone(), false); let graph = Arc::new( PostgresGraphStore::scoped(pool.clone(), scope.clone()) - .with_vector_store(graph_vector.vector_store()) - .with_vector_post_commit_sync(graph_vector.post_commit_sync()), + .with_vector_store(graph_vector.vector_store()), ); let embedder = runtime.embedder(); let pii: Arc = match runtime.pii_service_url() { diff --git a/crates/moa-memory/ingest/tests/fast_path_db_memory.rs b/crates/moa-memory/ingest/tests/fast_path_db_memory.rs index 396b072d..c8679b67 100644 --- a/crates/moa-memory/ingest/tests/fast_path_db_memory.rs +++ b/crates/moa-memory/ingest/tests/fast_path_db_memory.rs @@ -887,8 +887,7 @@ async fn fast_forget_does_not_select_read_vector_backend_db_memory() { ); let graph = Arc::new( PostgresGraphStore::scoped_for_app_role(session_store.pool().clone(), scope.clone()) - .with_vector_store(graph_vector.vector_store()) - .with_vector_post_commit_sync(graph_vector.post_commit_sync()), + .with_vector_store(graph_vector.vector_store()), ); let ctx = FastPathCtx::new_with_vector_factory( session_store.pool().clone(), diff --git a/crates/moa-memory/lifecycle/src/consolidate.rs b/crates/moa-memory/lifecycle/src/consolidate.rs index bb5698d1..2c491eb9 100644 --- a/crates/moa-memory/lifecycle/src/consolidate.rs +++ b/crates/moa-memory/lifecycle/src/consolidate.rs @@ -1349,9 +1349,7 @@ fn scoped_graph_for(pool: &PgPool, scope: RlsContext) -> PostgresGraphStore { scope.clone(), false, ); - PostgresGraphStore::scoped(pool.clone(), scope) - .with_vector_store(vector_backend.vector_store()) - .with_vector_post_commit_sync(vector_backend.post_commit_sync()) + PostgresGraphStore::scoped(pool.clone(), scope).with_vector_store(vector_backend.vector_store()) } /// Returns a scoped graph store for `row`, building and caching one store per diff --git a/crates/moa-memory/lifecycle/src/digest.rs b/crates/moa-memory/lifecycle/src/digest.rs index cd1ca3e5..4f0bf379 100644 --- a/crates/moa-memory/lifecycle/src/digest.rs +++ b/crates/moa-memory/lifecycle/src/digest.rs @@ -81,6 +81,8 @@ pub struct DigestStats { pub digests_rebuilt: u64, /// Digest rows skipped because they are fresher than the configured interval. pub digests_skipped_fresh: u64, + /// Persisted digest rows deleted because their identity has no active facts. + pub digests_deleted: u64, } /// Renders one deterministic digest from active Fact rows. @@ -158,6 +160,23 @@ pub(crate) async fn rebuild_storage_digests( } let mut stats = DigestStats::default(); + + // Reconcile persisted identities against the desired active-fact set. An + // identity whose facts were all erased, forgotten, or decayed forms zero + // groups above, so iterating only the formed groups can never reach it — + // its rendered digest text would otherwise survive and be re-injected into + // prompts. Enumerate persisted identities and delete the orphans. + let desired = groups + .keys() + .cloned() + .collect::>(); + for identity in persisted_digest_identities(pool, storage_partition_id).await? { + if !desired.contains(&identity) { + delete_digest_identity(pool, storage_partition_id, identity.user_id.as_deref()).await?; + stats.digests_deleted += 1; + } + } + for (identity, facts) in groups { if digest_is_fresh( pool, @@ -284,6 +303,61 @@ async fn upsert_digest( Ok(()) } +async fn persisted_digest_identities( + pool: &PgPool, + storage_partition_id: &StoragePartitionId, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT scope, user_id + FROM moa.memory_digests + WHERE storage_partition_id = $1 + "#, + ) + .bind(storage_partition_id.to_string()) + .fetch_all(pool) + .await?; + + let mut identities = Vec::with_capacity(rows.len()); + for row in rows { + let scope = row.try_get::("scope")?; + let user_id = row.try_get::, _>("user_id")?; + let scope_kind = match scope.as_str() { + "tenant" => DigestScopeKind::Tenant, + "contact" => DigestScopeKind::User, + other => { + return Err(Error::InvalidRow(format!( + "memory_digests row has unexpected scope `{other}`" + ))); + } + }; + identities.push(DigestIdentity { + scope_kind, + user_id, + }); + } + Ok(identities) +} + +async fn delete_digest_identity( + pool: &PgPool, + storage_partition_id: &StoragePartitionId, + user_id: Option<&str>, +) -> Result<()> { + sqlx::query( + r#" + DELETE FROM moa.memory_digests + WHERE storage_partition_id = $1 + AND (($2::text IS NULL AND user_id IS NULL) OR user_id = $2) + "#, + ) + .bind(storage_partition_id.to_string()) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + async fn active_digest_fact_rows( pool: &PgPool, storage_partition_id: &StoragePartitionId, diff --git a/crates/moa-memory/lifecycle/tests/memory_lifecycle_db_memory/digest_postgres_db_memory.rs b/crates/moa-memory/lifecycle/tests/memory_lifecycle_db_memory/digest_postgres_db_memory.rs index 9ebeb956..848a32c6 100644 --- a/crates/moa-memory/lifecycle/tests/memory_lifecycle_db_memory/digest_postgres_db_memory.rs +++ b/crates/moa-memory/lifecycle/tests/memory_lifecycle_db_memory/digest_postgres_db_memory.rs @@ -182,6 +182,78 @@ async fn digest_row_schema_pinned_across_writer_and_reader() { cleanup_workspaces(test_db.store().pool(), &[&storage_partition_id]).await; } +#[tokio::test] +#[ignore = "requires MOA_DATABASE_URL and a reachable Postgres instance"] +async fn digest_rebuild_deletes_identity_with_no_active_facts() { + // Pins: a persisted digest whose facts are all inactive is deleted by rebuild + // reconciliation, closing the erased-memory-in-prompt gap. Iterating only the + // formed active-fact groups can never reach a zero-fact identity. + let Some(test_db) = configured_test_db().await else { + return; + }; + let tenant_id = TenantId::new(); + let storage_partition_id = StoragePartitionId::for_tenant(tenant_id).to_string(); + let user_id = "forgotten-contact"; + let now = Utc + .with_ymd_and_hms(2026, 6, 11, 0, 0, 0) + .single() + .expect("fixed timestamp"); + + seed_fact( + test_db.store().pool(), + &storage_partition_id, + Some(user_id), + "editor theme", + "prefers", + "dark mode", + ) + .await; + + let config = MemoryDigestConfig { + enabled: true, + max_tokens: 600, + rebuild_min_interval_hours: 6, + }; + let stats = rebuild_digests(test_db.store().pool(), &tenant_id, now, &config) + .await + .expect("initial rebuild"); + assert_eq!(stats.digests_rebuilt, 1); + assert_eq!( + digest_row_count(test_db.store().pool(), &storage_partition_id).await, + 1 + ); + + // Every active fact for the contact vanishes (erased or forgotten). + sqlx::query("DELETE FROM moa.node_index WHERE storage_partition_id = $1") + .bind(&storage_partition_id) + .execute(test_db.store().pool()) + .await + .expect("remove active facts"); + + let later = now + chrono::Duration::hours(24); + let stats = rebuild_digests(test_db.store().pool(), &tenant_id, later, &config) + .await + .expect("reconciling rebuild"); + assert_eq!(stats.digests_deleted, 1); + assert_eq!(stats.digests_rebuilt, 0); + assert_eq!( + digest_row_count(test_db.store().pool(), &storage_partition_id).await, + 0 + ); + + cleanup_workspaces(test_db.store().pool(), &[&storage_partition_id]).await; +} + +async fn digest_row_count(pool: &PgPool, storage_partition_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM moa.memory_digests WHERE storage_partition_id = $1", + ) + .bind(storage_partition_id) + .fetch_one(pool) + .await + .expect("count digest rows") +} + async fn seed_fact( pool: &PgPool, storage_partition_id: &str, diff --git a/crates/moa-memory/pii/src/erasure.rs b/crates/moa-memory/pii/src/erasure.rs index 76730a7d..dd60dcba 100644 --- a/crates/moa-memory/pii/src/erasure.rs +++ b/crates/moa-memory/pii/src/erasure.rs @@ -106,13 +106,28 @@ pub async fn hard_purge_erase_candidates( let mut erased_count = 0usize; for chunk in candidates.chunks(ERASE_CHUNK_SIZE) { for candidate in chunk { - hard_purge_with_audit( + match hard_purge_with_audit( &graph, candidate.uid, &redaction_marker, Some(erase_audit_metadata(audit)), ) - .await?; + .await + { + Ok(()) => {} + // Idempotent erasure: a node already absent — because a + // concurrent purge removed it or a resumed job re-enumerated + // remaining candidates — is completed progress, not a failure. + // Restart-after-partial-progress must not terminate on the first + // already-purged node. + Err(moa_memory_graph::GraphError::NotFound(_)) => { + tracing::debug!( + uid = %candidate.uid, + "erase candidate already absent; treating as purged" + ); + } + Err(error) => return Err(error.into()), + } erased_count = erased_count.saturating_add(1); } } @@ -120,6 +135,65 @@ pub async fn hard_purge_erase_candidates( Ok(erased_count) } +/// Deletes the subject's standing memory-digest rows during privacy erasure. +/// +/// A contact whose active facts are all erased forms zero digest groups, so the +/// lifecycle rebuild never reaches that identity and its rendered digest text +/// would otherwise survive and be re-injected into prompts. This closes the +/// contact-scoped digest directly, mirroring the full-tenant purge. +pub async fn delete_subject_digests( + pool: &PgPool, + tenant_id: TenantId, + subject_user_id: &str, +) -> Result { + let contact_id = contact_id_from_subject(subject_user_id)?; + let storage_partition_id = StoragePartitionId::for_tenant(tenant_id).to_string(); + let mut tx = begin_app_scoped_tx(pool, tenant_id, subject_user_id).await?; + let deleted = sqlx::query( + r#" + DELETE FROM moa.memory_digests + WHERE storage_partition_id = $1 + AND contact_id = $2 + "#, + ) + .bind(storage_partition_id) + .bind(contact_id.0) + .execute(tx.as_mut()) + .await? + .rows_affected(); + tx.commit().await?; + Ok(deleted) +} + +/// Deletes the subject's retrieval-lineage rows during privacy erasure. +/// +/// Retrieval-lineage rows carry attributable subject/session/UID/rank/time +/// provenance and their `uid` has no foreign key to `node_index`, so purging +/// graph nodes never removes them. They belong in erasure closure. +pub async fn delete_subject_retrieval_lineage( + pool: &PgPool, + tenant_id: TenantId, + subject_user_id: &str, +) -> Result { + let contact_id = contact_id_from_subject(subject_user_id)?; + let storage_partition_id = StoragePartitionId::for_tenant(tenant_id).to_string(); + let mut tx = begin_app_scoped_tx(pool, tenant_id, subject_user_id).await?; + let deleted = sqlx::query( + r#" + DELETE FROM moa.retrieval_lineage + WHERE storage_partition_id = $1 + AND contact_id = $2 + "#, + ) + .bind(storage_partition_id) + .bind(contact_id.0) + .execute(tx.as_mut()) + .await? + .rows_affected(); + tx.commit().await?; + Ok(deleted) +} + /// Begins a transaction scoped as `moa_app` for one tenant-bound contact subject. pub async fn begin_app_scoped_tx<'a>( pool: &'a PgPool, diff --git a/crates/moa-memory/pii/tests/erasure_db_memory.rs b/crates/moa-memory/pii/tests/erasure_db_memory.rs index 21cbbd6a..91030719 100644 --- a/crates/moa-memory/pii/tests/erasure_db_memory.rs +++ b/crates/moa-memory/pii/tests/erasure_db_memory.rs @@ -8,7 +8,8 @@ use moa_memory_graph::{ PostgresGraphStore, }; use moa_memory_pii::erasure::{ - GraphErasureAudit, enumerate_erase_candidates, hard_purge_erase_candidates, + EraseCandidate, GraphErasureAudit, delete_subject_digests, delete_subject_retrieval_lineage, + enumerate_erase_candidates, hard_purge_erase_candidates, }; use moa_session::testing; use serde_json::json; @@ -203,6 +204,201 @@ async fn hard_purge_contact_candidates_writes_summary_under_app_role_db_memory() .expect("drop isolated schema"); } +#[tokio::test] +async fn hard_purge_tolerates_absent_candidate_db_memory() { + // Pins: an already-absent candidate counts as completed progress rather than a + // terminal NotFound error, so a resumed erasure that re-enumerates a partially + // purged subject never strands on the first missing node. + let (session_store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated Postgres store"); + let tenant_id = TenantId::from(Uuid::now_v7()); + let contact_id = ContactId::new(); + let subject_user_id = format!("contact:{contact_id}"); + let graph = PostgresGraphStore::scoped_for_app_role( + session_store.pool().clone(), + RlsContext::contact(tenant_id, contact_id), + ); + let present_uid = Uuid::now_v7(); + graph + .create_node(contact_node( + tenant_id, + contact_id, + present_uid, + "present fact", + )) + .await + .expect("seed present contact node"); + + let mut candidates = + enumerate_erase_candidates(session_store.pool(), tenant_id, &subject_user_id) + .await + .expect("enumerate contact erase candidates"); + assert_eq!(candidates.len(), 1); + // Prepend a candidate whose node is already gone (concurrent purge or resume). + candidates.insert( + 0, + EraseCandidate { + uid: Uuid::now_v7(), + label: "Fact".to_string(), + name: "already purged".to_string(), + pii_class: "phi".to_string(), + }, + ); + + let audit = GraphErasureAudit { + tenant_id, + subject_user: contact_id.0, + subject_user_id, + reason: "resumed erasure request".to_string(), + approver_id: "admin@example.test".to_string(), + approval_token_jti: "approval-jti-absent-candidate-db-memory".to_string(), + }; + let erased = hard_purge_erase_candidates(session_store.pool(), &audit, &candidates) + .await + .expect("hard purge tolerates already-absent candidate"); + assert_eq!(erased, 2); + assert!( + graph + .get_node(present_uid) + .await + .expect("read purged present node") + .is_none(), + "the present candidate must still be purged" + ); + + drop(session_store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +#[tokio::test] +async fn delete_subject_digest_and_lineage_rows_db_memory() { + // Pins: erasure closure deletes the subject's standing memory-digest and + // retrieval-lineage rows, which graph-node purges never touch. + let (session_store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated Postgres store"); + let tenant_id = TenantId::from(Uuid::now_v7()); + let contact_id = ContactId::new(); + let subject_user_id = format!("contact:{contact_id}"); + let storage_partition_id = StoragePartitionId::for_tenant(tenant_id).to_string(); + + seed_digest_row( + session_store.pool(), + tenant_id, + contact_id, + &storage_partition_id, + ) + .await; + seed_lineage_row( + session_store.pool(), + tenant_id, + contact_id, + &storage_partition_id, + ) + .await; + + let digests_deleted = delete_subject_digests(session_store.pool(), tenant_id, &subject_user_id) + .await + .expect("delete subject digests"); + assert_eq!(digests_deleted, 1); + let lineage_deleted = + delete_subject_retrieval_lineage(session_store.pool(), tenant_id, &subject_user_id) + .await + .expect("delete subject retrieval lineage"); + assert_eq!(lineage_deleted, 1); + + let remaining_digests = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM moa.memory_digests WHERE storage_partition_id = $1", + ) + .bind(&storage_partition_id) + .fetch_one(session_store.pool()) + .await + .expect("count remaining digest rows"); + assert_eq!(remaining_digests, 0); + let remaining_lineage = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM moa.retrieval_lineage WHERE storage_partition_id = $1", + ) + .bind(&storage_partition_id) + .fetch_one(session_store.pool()) + .await + .expect("count remaining lineage rows"); + assert_eq!(remaining_lineage, 0); + + // A re-run is idempotent: nothing remains to delete. + let digests_deleted_again = + delete_subject_digests(session_store.pool(), tenant_id, &subject_user_id) + .await + .expect("re-run digest deletion"); + assert_eq!(digests_deleted_again, 0); + + drop(session_store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +async fn seed_digest_row( + pool: &PgPool, + tenant_id: TenantId, + contact_id: ContactId, + storage_partition_id: &str, +) { + let mut conn = ScopedConn::begin_contact(pool, tenant_id, contact_id) + .await + .expect("begin contact-scoped digest seed"); + sqlx::query("SET LOCAL ROLE moa_app") + .execute(conn.as_mut()) + .await + .expect("set app role for digest seed"); + sqlx::query( + r#" + INSERT INTO moa.memory_digests + (storage_partition_id, user_id, content, version, updated_at) + VALUES ($1, $2, $3, 1, now()) + "#, + ) + .bind(storage_partition_id) + .bind(contact_id.to_string()) + .bind("What I know about this contact:\n- prefers dark mode\n") + .execute(conn.as_mut()) + .await + .expect("seed memory digest row"); + conn.commit().await.expect("commit digest seed"); +} + +async fn seed_lineage_row( + pool: &PgPool, + tenant_id: TenantId, + contact_id: ContactId, + storage_partition_id: &str, +) { + let mut conn = ScopedConn::begin_contact(pool, tenant_id, contact_id) + .await + .expect("begin contact-scoped lineage seed"); + sqlx::query("SET LOCAL ROLE moa_app") + .execute(conn.as_mut()) + .await + .expect("set app role for lineage seed"); + sqlx::query( + r#" + INSERT INTO moa.retrieval_lineage + (storage_partition_id, user_id, session_id, turn_seq, uid, rank, retrieved_at) + VALUES ($1, $2, $3, 1, $4, 1, now()) + "#, + ) + .bind(storage_partition_id) + .bind(contact_id.to_string()) + .bind(Uuid::now_v7()) + .bind(Uuid::now_v7()) + .execute(conn.as_mut()) + .await + .expect("seed retrieval lineage row"); + conn.commit().await.expect("commit lineage seed"); +} + #[tokio::test] async fn hard_purge_contact_candidates_includes_historical_versions_db_memory() { // Pins: a contact hard purge enumerates and erases every attributable node version, diff --git a/crates/moa-memory/vector/src/backend.rs b/crates/moa-memory/vector/src/backend.rs index 0d06e779..e15e8bf5 100644 --- a/crates/moa-memory/vector/src/backend.rs +++ b/crates/moa-memory/vector/src/backend.rs @@ -11,12 +11,12 @@ use sqlx::{PgConnection, PgPool}; use uuid::Uuid; use crate::{ - Error, PgvectorStore, Result, TurbopufferStore, VectorItem, VectorPostCommitSync, VectorQuery, - VectorStore, VectorSyncOperation, VectorSyncReport, + Error, PgvectorStore, Result, TurbopufferStore, VectorItem, VectorQuery, VectorStore, + VectorSyncOperation, VectorSyncReport, sync::{ VECTOR_SYNC_POST_COMMIT_LIMIT, VectorSyncJob, claim_pending_vector_sync, enqueue_external_vector_sync, fetch_current_vector_items, mark_vector_sync_failed_batch, - mark_vector_sync_processed_batch, + mark_vector_sync_processed_batch, redrive_dead_lettered_vector_sync, }, }; @@ -157,6 +157,20 @@ impl VectorStoreFactory { .await } + /// Re-queues quarantined (dead-lettered) vector-sync rows after remediation. + /// + /// Intended for an operator/maintenance action once the permanent fault + /// (embedder mismatch, backend auth/config) has been fixed. Passing `None` + /// redrives every partition; a storage-partition id scopes the reset. Returns + /// the number of rows re-queued. + pub async fn redrive_dead_lettered_external_sync( + &self, + pool: &PgPool, + storage_partition_id: Option<&str>, + ) -> Result { + redrive_dead_lettered_vector_sync(pool, storage_partition_id).await + } + async fn drain_external_sync_for_storage_partition( &self, pool: &PgPool, @@ -183,8 +197,7 @@ impl VectorStoreFactory { } Err(error) => { let job_refs = jobs.iter().collect::>(); - mark_vector_sync_failed_batch(pool, &job_refs, &error).await?; - report.failed += jobs.len() as u64; + fail_vector_sync_jobs(pool, &job_refs, &error, &mut report).await?; continue; } }; @@ -241,15 +254,13 @@ impl VectorStoreFactory { report.succeeded += found_upsert_jobs.len() as u64; } Err(error) => { - mark_vector_sync_failed_batch(pool, &found_upsert_jobs, &error).await?; - report.failed += found_upsert_jobs.len() as u64; + fail_vector_sync_jobs(pool, &found_upsert_jobs, &error, report).await?; } } } } Err(error) => { - mark_vector_sync_failed_batch(pool, &upsert_jobs, &error).await?; - report.failed += upsert_jobs.len() as u64; + fail_vector_sync_jobs(pool, &upsert_jobs, &error, report).await?; } } @@ -272,8 +283,7 @@ impl VectorStoreFactory { report.succeeded += delete_mark_jobs.len() as u64; } Err(error) => { - mark_vector_sync_failed_batch(pool, &delete_mark_jobs, &error).await?; - report.failed += delete_mark_jobs.len() as u64; + fail_vector_sync_jobs(pool, &delete_mark_jobs, &error, report).await?; } } Ok(()) @@ -316,6 +326,32 @@ impl VectorStoreFactory { } } +/// Fails a batch of claimed vector-sync jobs, classifying transient vs permanent +/// errors and folding the outcome into the drain report. +/// +/// Permanent failures (and transient ones that exhaust the attempt budget) are +/// quarantined and logged; recoverable failures back off for a later retry. +async fn fail_vector_sync_jobs( + pool: &PgPool, + jobs: &[&VectorSyncJob], + error: &Error, + report: &mut VectorSyncReport, +) -> Result<()> { + let permanent = error.is_permanent(); + let dead_lettered = mark_vector_sync_failed_batch(pool, jobs, error, permanent).await?; + report.failed += jobs.len() as u64; + report.dead_lettered += dead_lettered; + if dead_lettered > 0 { + tracing::warn!( + dead_lettered, + permanent, + error = %error, + "quarantined vector-sync jobs after a permanent or exhausted failure" + ); + } + Ok(()) +} + fn group_jobs_by_partition(jobs: Vec) -> BTreeMap> { let mut grouped = BTreeMap::new(); for job in jobs { @@ -340,15 +376,13 @@ impl TransactionalGraphVectorBackend { self.store.clone() } - /// Returns the post-commit sync hook for external vector projections. - #[must_use] - pub fn post_commit_sync(&self) -> Arc { - self.store.clone() - } - - /// Runs the post-commit sync hook. + /// Drains this graph-vector attachment's committed outbox rows to its backend. + /// + /// Used by batch ingestion paths (e.g. slow-path memory ingest) that want an + /// explicit post-batch flush; ordinary graph writes are queue-only and rely + /// on the background cron drainer instead. pub async fn sync_post_commit(&self) -> Result<()> { - VectorPostCommitSync::sync_post_commit(self.store.as_ref()).await + self.store.sync_post_commit().await } } @@ -406,8 +440,8 @@ impl VectorStore for TransactionalGraphVectorStore { } } -#[async_trait] -impl VectorPostCommitSync for TransactionalGraphVectorStore { +impl TransactionalGraphVectorStore { + /// Drains this attachment's committed vector-sync outbox rows to its backend. async fn sync_post_commit(&self) -> Result<()> { self.factory .drain_external_sync_for_storage_partition( diff --git a/crates/moa-memory/vector/src/lib.rs b/crates/moa-memory/vector/src/lib.rs index fd90655d..c15a05ab 100644 --- a/crates/moa-memory/vector/src/lib.rs +++ b/crates/moa-memory/vector/src/lib.rs @@ -20,7 +20,9 @@ pub use promotion::{ PROMOTION_BATCH_SIZE, PROMOTION_OVERLAP_THRESHOLD, PromotionOptions, PromotionReport, VectorPartitionPromotion, finalize_promotion, rollback_promotion, }; -pub use sync::{VECTOR_SYNC_POST_COMMIT_LIMIT, VectorSyncOperation, VectorSyncReport}; +pub use sync::{ + VECTOR_SYNC_MAX_ATTEMPTS, VECTOR_SYNC_POST_COMMIT_LIMIT, VectorSyncOperation, VectorSyncReport, +}; pub use turbopuffer::{TurbopufferStore, TurbopufferTextQuery}; /// Fixed graph-memory embedding dimensionality. @@ -197,6 +199,57 @@ pub enum Error { SerdeJson(#[from] serde_json::Error), } +impl Error { + /// Returns whether this failure is permanent for vector-sync retry purposes. + /// + /// Permanent failures (dimension, schema/embedder, backend configuration, or + /// 4xx client responses other than throttling/timeout) will never succeed on + /// retry without operator remediation, so the vector-sync drainer quarantines + /// them immediately instead of retrying forever. Transient failures (network, + /// Postgres, 5xx/429/408, malformed responses, in-progress re-embedding) stay + /// eligible for backed-off retries. + #[must_use] + pub fn is_permanent(&self) -> bool { + match self { + Self::DimensionMismatch { .. } + | Self::UnknownPiiClass(_) + | Self::EmbeddingResponseLength { .. } + | Self::EmbedderConfig(_) + | Self::EmbedderMismatch { .. } + | Self::EmbedderModelMismatch { .. } + | Self::StoragePartitionEmbedderStateMissing { .. } + | Self::StoragePartitionRequired { .. } + | Self::QueryLimitTooLarge(_) + | Self::TurbopufferUnavailable { .. } + | Self::TurbopufferBaaRequired { .. } + | Self::TurbopufferConfig(_) + | Self::PromotionValidationFailed { .. } + | Self::InvalidPromotionState { .. } + | Self::TransactionalWritesUnsupported(_) + | Self::InvalidVectorSyncOperation(_) + | Self::UnsupportedVectorBackend { .. } + | Self::UnsupportedQueryFeature { .. } => true, + Self::ProviderStatus { status, .. } | Self::VectorProviderStatus { status, .. } => { + is_permanent_http_status(*status) + } + Self::ReembedInProgress { .. } + | Self::TurbopufferResponse(_) + | Self::Core(_) + | Self::Sqlx(_) + | Self::Reqwest(_) + | Self::SerdeJson(_) => false, + } + } +} + +/// Returns whether an HTTP status from a vector/embedding backend is permanent. +/// +/// 4xx client errors will not self-heal, except `408 Request Timeout` and +/// `429 Too Many Requests`, which are transient and should be retried. +fn is_permanent_http_status(status: u16) -> bool { + (400..500).contains(&status) && status != 408 && status != 429 +} + impl From for moa_core::MoaError { fn from(error: Error) -> Self { match error { @@ -301,15 +354,6 @@ pub trait VectorStore: Send + Sync { } } -/// Post-commit sync hook for graph writes that maintain external vector projections. -#[async_trait] -pub trait VectorPostCommitSync: Send + Sync { - /// Flushes durable post-commit work owned by a graph-vector attachment. - async fn sync_post_commit(&self) -> Result<()> { - Ok(()) - } -} - pub(crate) fn validate_dimension(embedding: &[f32]) -> Result<()> { if embedding.len() == VECTOR_DIMENSION { Ok(()) diff --git a/crates/moa-memory/vector/src/sync.rs b/crates/moa-memory/vector/src/sync.rs index 31cd6853..63a0a80a 100644 --- a/crates/moa-memory/vector/src/sync.rs +++ b/crates/moa-memory/vector/src/sync.rs @@ -9,6 +9,15 @@ use crate::{Error, Result, VectorItem, embedding_row::EmbeddingRow}; /// Maximum outbox rows drained after a graph write commits. pub const VECTOR_SYNC_POST_COMMIT_LIMIT: i64 = 64; +/// Transient-retry ceiling before a vector-sync row is quarantined (dead-lettered). +pub const VECTOR_SYNC_MAX_ATTEMPTS: i32 = 10; + +/// Base delay, in seconds, for the exponential transient-retry backoff. +const VECTOR_SYNC_BACKOFF_BASE_SECS: f64 = 30.0; + +/// Maximum delay, in seconds, that the transient-retry backoff can reach. +const VECTOR_SYNC_BACKOFF_CAP_SECS: f64 = 3600.0; + /// Operation persisted in `moa.vector_sync_outbox`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VectorSyncOperation { @@ -48,6 +57,8 @@ pub struct VectorSyncReport { pub skipped: u64, /// Rows left pending after a processing failure. pub failed: u64, + /// Rows quarantined (dead-lettered) after a permanent or exhausted failure. + pub dead_lettered: u64, } #[derive(Debug, Clone)] @@ -107,6 +118,7 @@ pub(crate) async fn claim_pending_vector_sync( SELECT sync_id FROM moa.vector_sync_outbox WHERE processed_at IS NULL + AND dead_lettered_at IS NULL AND available_at <= now() AND (claim_expires_at IS NULL OR claim_expires_at <= now()) AND ($2::text IS NULL OR storage_partition_id = $2) @@ -181,17 +193,25 @@ pub(crate) async fn mark_vector_sync_processed_batch( Ok(()) } -/// Marks claimed vector sync jobs as failed and available for a later retry. +/// Marks claimed vector sync jobs as failed, returning how many were quarantined. +/// +/// A `permanent` failure (or a transient one that has exhausted +/// [`VECTOR_SYNC_MAX_ATTEMPTS`]) dead-letters the row: `dead_lettered_at` is set +/// and the claim predicate no longer selects it. A recoverable transient failure +/// instead schedules the next attempt with exponential backoff derived from the +/// row's `attempts`, capped at [`VECTOR_SYNC_BACKOFF_CAP_SECS`]. The returned +/// count is the number of rows that transitioned to the dead-letter state. pub(crate) async fn mark_vector_sync_failed_batch( pool: &PgPool, jobs: &[&VectorSyncJob], error: &Error, -) -> Result<()> { + permanent: bool, +) -> Result { if jobs.is_empty() { - return Ok(()); + return Ok(0); } let (sync_ids, claim_tokens) = claim_pairs(jobs); - sqlx::query( + let rows = sqlx::query( r#" WITH claimed(sync_id, claim_token) AS ( SELECT * @@ -199,21 +219,71 @@ pub(crate) async fn mark_vector_sync_failed_batch( ) UPDATE moa.vector_sync_outbox AS outbox SET last_error = left($3, 2048), + claim_token = NULL, claim_expires_at = NULL, - available_at = now() + INTERVAL '30 seconds', - updated_at = now() + updated_at = now(), + dead_lettered_at = CASE + WHEN $4 OR outbox.attempts >= $5 THEN now() + ELSE outbox.dead_lettered_at + END, + available_at = CASE + WHEN $4 OR outbox.attempts >= $5 THEN outbox.available_at + ELSE now() + make_interval( + secs => LEAST($6, $7 * power(2, GREATEST(outbox.attempts - 1, 0))) + ) + END FROM claimed WHERE outbox.sync_id = claimed.sync_id AND outbox.claim_token = claimed.claim_token AND outbox.processed_at IS NULL + RETURNING (outbox.dead_lettered_at IS NOT NULL) AS dead_lettered "#, ) .bind(sync_ids) .bind(claim_tokens) .bind(error.to_string()) + .bind(permanent) + .bind(VECTOR_SYNC_MAX_ATTEMPTS) + .bind(VECTOR_SYNC_BACKOFF_CAP_SECS) + .bind(VECTOR_SYNC_BACKOFF_BASE_SECS) + .fetch_all(pool) + .await?; + + let dead_lettered = rows + .iter() + .filter(|row| row.get::("dead_lettered")) + .count() as u64; + Ok(dead_lettered) +} + +/// Resets quarantined (dead-lettered) vector-sync rows to pending for redrive. +/// +/// Intended for an operator action after the permanent fault has been remediated. +/// Clears `dead_lettered_at`, resets the attempt counter and claim, and makes the +/// rows immediately eligible. Returns the number of rows re-queued. +pub(crate) async fn redrive_dead_lettered_vector_sync( + pool: &PgPool, + storage_partition_id: Option<&str>, +) -> Result { + let result = sqlx::query( + r#" + UPDATE moa.vector_sync_outbox + SET dead_lettered_at = NULL, + attempts = 0, + available_at = now(), + last_error = NULL, + claim_token = NULL, + claim_expires_at = NULL, + updated_at = now() + WHERE dead_lettered_at IS NOT NULL + AND processed_at IS NULL + AND ($1::text IS NULL OR storage_partition_id = $1) + "#, + ) + .bind(storage_partition_id) .execute(pool) .await?; - Ok(()) + Ok(result.rows_affected()) } /// Loads current pgvector source rows for queued upserts. diff --git a/crates/moa-memory/vector/tests/memory_vector_db_memory/vector_sync_outbox_db_memory.rs b/crates/moa-memory/vector/tests/memory_vector_db_memory/vector_sync_outbox_db_memory.rs index 5f4269d7..71eba221 100644 --- a/crates/moa-memory/vector/tests/memory_vector_db_memory/vector_sync_outbox_db_memory.rs +++ b/crates/moa-memory/vector/tests/memory_vector_db_memory/vector_sync_outbox_db_memory.rs @@ -273,6 +273,13 @@ async fn transactional_graph_vector_store_enqueues_only_external_backend_rows_db "upsert".to_string() )] ); + // Queue-only (F10): the graph write enqueues the outbox row and returns; it is + // not synchronously drained, so the row stays pending for the background cron. + assert_eq!( + pending_outbox_count(&pool).await, + 1, + "graph writes must leave the enqueued vector-sync row pending, not drain it inline" + ); drop(store); testing::cleanup_test_schema(&database_url, &schema_name) @@ -441,6 +448,7 @@ async fn vector_sync_outbox_drains_turbopuffer_upsert_and_delete_db_memory() { succeeded: 3, skipped: 0, failed: 0, + dead_lettered: 0, } ); assert_eq!(pending_outbox_count(&pool).await, 0); @@ -464,6 +472,7 @@ async fn vector_sync_outbox_drains_turbopuffer_upsert_and_delete_db_memory() { succeeded: 3, skipped: 0, failed: 0, + dead_lettered: 0, } ); assert_eq!(pending_outbox_count(&pool).await, 0); @@ -509,3 +518,242 @@ async fn vector_sync_outbox_drains_turbopuffer_upsert_and_delete_db_memory() { .await .expect("drop isolated schema"); } + +/// Seeds a Turbopuffer partition with one committed pgvector row and its queued +/// external upsert, returning the partition, item, and a factory pointed at the +/// mock backend. +async fn setup_pending_upsert( + pool: &PgPool, + server_uri: String, +) -> (String, VectorItem, VectorStoreFactory) { + let embedding_model = "test-embed"; + let partition = Uuid::now_v7().to_string(); + let item = vector_item(Uuid::now_v7(), embedding_model); + seed_storage_partition_state(pool, &partition, "turbopuffer", embedding_model).await; + insert_node_index_row(pool, &partition, &item).await; + + let mut config = MoaConfig::default(); + config.memory.vector.turbopuffer.api_key = "test-key".to_string(); + config.memory.vector.turbopuffer.base_url = Some(server_uri); + config.memory.vector.turbopuffer.environment = Some("test".to_string()); + let factory = VectorStoreFactory::from_config(&config); + let vector = factory + .transactional_graph_backend(pool.clone(), tenant_scope(&partition), true) + .vector_store(); + let mut conn = scoped_conn(pool, &partition).await; + vector + .upsert_in_tx(conn.as_mut(), std::slice::from_ref(&item)) + .await + .expect("queue external upsert"); + conn.commit().await.expect("commit source upsert"); + (partition, item, factory) +} + +/// Returns `(attempts, dead_lettered, backed_off, has_error)` for one outbox row. +async fn outbox_row_state(pool: &PgPool, uid: Uuid) -> (i32, bool, bool, bool) { + sqlx::query_as::<_, (i32, bool, bool, bool)>( + r#" + SELECT attempts, + dead_lettered_at IS NOT NULL AS dead_lettered, + available_at > now() AS backed_off, + last_error IS NOT NULL AS has_error + FROM moa.vector_sync_outbox + WHERE uid = $1 + "#, + ) + .bind(uid) + .fetch_one(pool) + .await + .expect("read vector sync outbox row state") +} + +/// Returns the remaining backoff window, in seconds, for one outbox row. +async fn seconds_until_available(pool: &PgPool, uid: Uuid) -> f64 { + sqlx::query_scalar::<_, f64>( + "SELECT EXTRACT(EPOCH FROM (available_at - now()))::float8 + FROM moa.vector_sync_outbox WHERE uid = $1", + ) + .bind(uid) + .fetch_one(pool) + .await + .expect("read available_at delta") +} + +/// Simulates the backoff window elapsing so the next drain can reclaim the row. +async fn make_available_now(pool: &PgPool, uid: Uuid) { + sqlx::query("UPDATE moa.vector_sync_outbox SET available_at = now() WHERE uid = $1") + .bind(uid) + .execute(pool) + .await + .expect("reset available_at"); +} + +#[tokio::test] +async fn permanent_failure_dead_letters_and_is_excluded_from_claims_db_memory() { + // Pins (F25): a permanent (4xx) external failure quarantines the row on the + // first attempt and the claim predicate never reclaims it, so poison jobs + // cannot consume the drainer forever. + let (store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated Postgres store"); + let pool = store.pool().clone(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_string_contains("upsert_rows")) + .respond_with(ResponseTemplate::new(400).set_body_string("schema mismatch")) + .mount(&server) + .await; + let (_partition, item, factory) = setup_pending_upsert(&pool, server.uri()).await; + + let report = factory + .drain_external_sync(&pool, 10) + .await + .expect("drain applies the permanent failure"); + assert_eq!(report.attempted, 1); + assert_eq!(report.failed, 1); + assert_eq!(report.dead_lettered, 1); + assert_eq!(report.succeeded, 0); + + let (_attempts, dead_lettered, _backed_off, has_error) = + outbox_row_state(&pool, item.uid).await; + assert!(dead_lettered, "a permanent failure must quarantine the row"); + assert!(has_error, "the quarantined row retains its last error"); + + let second = factory + .drain_external_sync(&pool, 10) + .await + .expect("second drain"); + assert_eq!( + second.attempted, 0, + "dead-lettered rows must be excluded from later claims" + ); + + drop(store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +#[tokio::test] +async fn transient_failure_backs_off_exponentially_then_succeeds_db_memory() { + // Pins (F25): a transient (5xx) failure is not quarantined; each retry backs + // off with a growing delay (not a flat 30s), and the row succeeds once the + // backend recovers. + let (store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated Postgres store"); + let pool = store.pool().clone(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_string_contains("upsert_rows")) + .respond_with(ResponseTemplate::new(500).set_body_string("upstream unavailable")) + .mount(&server) + .await; + let (_partition, item, factory) = setup_pending_upsert(&pool, server.uri()).await; + + let first = factory + .drain_external_sync(&pool, 10) + .await + .expect("first drain fails transiently"); + assert_eq!(first.failed, 1); + assert_eq!(first.dead_lettered, 0); + let (attempts, dead_lettered, backed_off, has_error) = outbox_row_state(&pool, item.uid).await; + assert!(!dead_lettered, "a transient failure must not quarantine"); + assert_eq!(attempts, 1); + assert!(backed_off, "a transient failure schedules a future retry"); + assert!(has_error); + let backoff_after_first = seconds_until_available(&pool, item.uid).await; + + make_available_now(&pool, item.uid).await; + let second = factory + .drain_external_sync(&pool, 10) + .await + .expect("second drain fails transiently again"); + assert_eq!(second.failed, 1); + assert_eq!(second.dead_lettered, 0); + let backoff_after_second = seconds_until_available(&pool, item.uid).await; + assert!( + backoff_after_second > backoff_after_first + 20.0, + "retry backoff must grow exponentially: {backoff_after_first}s then {backoff_after_second}s" + ); + + // Backend recovers; after the backoff window the row drains successfully. + server.reset().await; + Mock::given(method("POST")) + .and(body_string_contains("upsert_rows")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rows_affected": 1, + "rows_upserted": 1 + }))) + .mount(&server) + .await; + make_available_now(&pool, item.uid).await; + let third = factory + .drain_external_sync(&pool, 10) + .await + .expect("recovered drain succeeds"); + assert_eq!(third.succeeded, 1); + assert_eq!(pending_outbox_count(&pool).await, 0); + + drop(store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +#[tokio::test] +async fn redrive_returns_dead_lettered_rows_to_pending_db_memory() { + // Pins (F25): after remediation an operator redrive clears the quarantine and + // makes the rows immediately eligible for the drainer again. + let (store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated Postgres store"); + let pool = store.pool().clone(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_string_contains("upsert_rows")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .mount(&server) + .await; + let (partition, item, factory) = setup_pending_upsert(&pool, server.uri()).await; + + let report = factory + .drain_external_sync(&pool, 10) + .await + .expect("drain quarantines the row"); + assert_eq!(report.dead_lettered, 1); + let (_a, dead_lettered, _b, _c) = outbox_row_state(&pool, item.uid).await; + assert!(dead_lettered); + + let redriven = factory + .redrive_dead_lettered_external_sync(&pool, Some(&partition)) + .await + .expect("redrive quarantined rows"); + assert_eq!(redriven, 1, "the one dead-lettered row is re-queued"); + let (attempts, dead_lettered, backed_off, _has_error) = outbox_row_state(&pool, item.uid).await; + assert!(!dead_lettered, "redrive clears the quarantine"); + assert_eq!(attempts, 0, "redrive resets the attempt counter"); + assert!(!backed_off, "redriven rows are immediately eligible"); + + // The recovered backend now delivers the redriven row. + server.reset().await; + Mock::given(method("POST")) + .and(body_string_contains("upsert_rows")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rows_affected": 1, + "rows_upserted": 1 + }))) + .mount(&server) + .await; + let after = factory + .drain_external_sync(&pool, 10) + .await + .expect("post-redrive drain"); + assert_eq!(after.succeeded, 1); + assert_eq!(pending_outbox_count(&pool).await, 0); + + drop(store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} diff --git a/crates/moa-messaging/src/slack.rs b/crates/moa-messaging/src/slack.rs index 3fe10630..b0a0d14f 100644 --- a/crates/moa-messaging/src/slack.rs +++ b/crates/moa-messaging/src/slack.rs @@ -272,6 +272,147 @@ impl SlackOutboundMessageRefs { } } +/// Chunk-level Slack transport operations used by the tracked send and edit +/// orchestration. +/// +/// Extracting the three chunk-level API calls behind a trait lets the +/// multi-chunk reference-persistence and compensation logic be exercised +/// deterministically without a live Slack endpoint. +#[async_trait] +trait SlackChunkTransport: Send + Sync { + /// Posts one rendered chunk and returns its durable Slack reference. + async fn send_chunk( + &self, + target: &SlackTarget, + chunk: &SlackRenderChunk, + ) -> Result; + + /// Updates one already-sent chunk in place. + async fn update_chunk( + &self, + message_ref: &SlackMessageRef, + chunk: &SlackRenderChunk, + ) -> Result<()>; + + /// Deletes one already-sent chunk, tolerating an already-absent message. + async fn delete_ref(&self, message_ref: &SlackMessageRef) -> Result<()>; +} + +/// Sends a fresh multi-chunk Slack message, persisting each confirmed chunk +/// reference before the next send and compensating on a mid-message failure. +/// +/// Recording references only after every chunk succeeds leaves a middle-chunk +/// error with earlier chunks visible in Slack but untracked in the reference +/// store, so retry, edit, and delete cannot repair them. This persists each +/// confirmed reference immediately and, because the caller supplies no +/// idempotency key to resume under, deletes the already-sent chunks on failure +/// so Slack never shows a visible-but-untracked partial message. +async fn send_multi_chunk_tracked( + transport: &T, + refs: &SlackOutboundMessageRefs, + target: &SlackTarget, + message_id: &MessageId, + chunks: &[SlackRenderChunk], +) -> Result> { + let mut sent_refs = Vec::with_capacity(chunks.len()); + for chunk in chunks { + match transport.send_chunk(target, chunk).await { + Ok(sent_ref) => { + sent_refs.push(sent_ref); + refs.record_after_external_side_effect( + message_id, + sent_refs.clone(), + "chat.postMessage", + ) + .await; + } + Err(error) => { + compensate_partial_send(transport, refs, message_id, &sent_refs).await; + return Err(error); + } + } + } + Ok(sent_refs) +} + +/// Deletes the already-sent chunks and clears the partial reference record after +/// a multi-chunk send fails partway through, so no visible-but-untracked message +/// remains in Slack. +async fn compensate_partial_send( + transport: &T, + refs: &SlackOutboundMessageRefs, + message_id: &MessageId, + sent_refs: &[SlackMessageRef], +) { + for message_ref in sent_refs { + if let Err(error) = transport.delete_ref(message_ref).await { + warn!( + message_id = %message_id, + slack.channel_id = %message_ref.channel_id, + slack.ts = %message_ref.ts, + error = %error, + "Slack multi-chunk send failed; compensating delete of an already-sent chunk also failed" + ); + } + } + refs.remove_after_external_side_effect(message_id, "chat.postMessage_compensation") + .await; +} + +/// Applies an edit to an existing Slack message, persisting each newly grown +/// chunk reference incrementally. +/// +/// When the rendered message grows, new chunks are sent before the updated +/// reference list would historically be persisted, so a late failure left the +/// new chunks visible but untracked. Persisting after each newly-sent chunk +/// keeps every confirmed chunk tracked even if a later chunk fails. +async fn apply_edit_tracked( + transport: &T, + refs: &SlackOutboundMessageRefs, + message_id: &MessageId, + existing: &[SlackMessageRef], + rendered: &[SlackRenderChunk], +) -> Result> { + let overlap = existing.len().min(rendered.len()); + let mut updated_refs = Vec::with_capacity(rendered.len()); + + for index in 0..overlap { + let message_ref = existing[index].clone(); + transport + .update_chunk(&message_ref, &rendered[index]) + .await?; + updated_refs.push(message_ref); + } + + if rendered.len() > existing.len() { + let target = existing + .last() + .cloned() + .map(|message_ref| message_ref.target()) + .ok_or_else(|| { + MoaError::ValidationError(format!("slack message id {message_id} has no refs")) + })?; + for chunk in rendered.iter().skip(existing.len()) { + let sent_ref = transport.send_chunk(&target, chunk).await?; + updated_refs.push(sent_ref); + // Persist each newly-sent chunk before the next send so a mid-growth + // failure still leaves the chunk tracked, never visible-but-untracked. + refs.record_after_external_side_effect(message_id, updated_refs.clone(), "chat.update") + .await; + } + } + + if existing.len() > rendered.len() { + for message_ref in existing.iter().skip(rendered.len()) { + transport.delete_ref(message_ref).await?; + } + } + + refs.record_after_external_side_effect(message_id, updated_refs.clone(), "chat.update") + .await; + Ok(updated_refs) +} + /// Slack adapter implementing the generic channel abstraction. #[derive(Clone)] pub struct SlackAdapter { @@ -387,75 +528,6 @@ impl SlackAdapter { )) } - async fn send_chunk( - &self, - target: &SlackTarget, - chunk: &SlackRenderChunk, - ) -> Result { - self.rate_limiter - .wait_for_channel_slot(target.channel_id.as_ref()) - .await?; - let session = self.client.open_session(&self.bot_token); - let request = SlackApiChatPostMessageRequest { - channel: SlackChannelId(target.channel_id.to_string()), - content: slack_message_content(chunk), - as_user: None, - icon_emoji: None, - icon_url: None, - link_names: None, - parse: None, - thread_ts: target.thread_ts.clone().map(SlackTs), - username: None, - reply_broadcast: None, - unfurl_links: None, - unfurl_media: None, - }; - - async { - let response = session - .chat_post_message(&request) - .await - .map_err(|error| slack_client_error("chat.postMessage", error))?; - Ok(SlackMessageRef { - channel_id: Arc::::from(response.channel.0), - ts: response.ts.0, - thread_ts: target.thread_ts.clone(), - }) - } - .instrument(slack_api_span("slack_message_send", "chat.postMessage")) - .await - } - - async fn update_chunk( - &self, - message_ref: &SlackMessageRef, - chunk: &SlackRenderChunk, - ) -> Result<()> { - self.rate_limiter - .wait_for_channel_slot(message_ref.channel_id.as_ref()) - .await?; - let session = self.client.open_session(&self.bot_token); - let request = SlackApiChatUpdateRequest { - channel: SlackChannelId(message_ref.channel_id.to_string()), - content: slack_message_content(chunk), - ts: SlackTs(message_ref.ts.clone()), - as_user: None, - link_names: None, - parse: None, - reply_broadcast: None, - }; - - async { - session - .chat_update(&request) - .await - .map_err(|error| slack_client_error("chat.update", error))?; - Ok(()) - } - .instrument(slack_api_span("slack_message_update", "chat.update")) - .await - } - async fn record_edit_attempt(&self, message_id: &MessageId) { let min_interval = self.capabilities().min_edit_interval; let previous = self.last_edits.get(message_id.as_str()).await; @@ -565,16 +637,33 @@ impl ChannelAdapter for SlackAdapter { .resolve_target(msg.reply_to.as_deref(), msg.channel_ref.as_ref()) .await?; let rendered = self.renderer.render(&msg); - let mut sent_refs = Vec::with_capacity(rendered.len()); - for chunk in &rendered { - let sent_ref = self.send_chunk(&target, chunk).await?; - sent_refs.push(sent_ref); + + // A single-chunk (or empty) send has no partial-visibility window: the one + // send either fully succeeds or leaves nothing visible. Keep the + // deterministic id derived from the sole reference. + if rendered.len() <= 1 { + let mut sent_refs = Vec::with_capacity(rendered.len()); + for chunk in &rendered { + sent_refs.push(self.send_chunk(&target, chunk).await?); + } + let message_id = sent_refs + .first() + .map(slack_message_id_from_ref) + .unwrap_or_else(|| MessageId::new(Uuid::now_v7().to_string())); + self.outbound_refs + .record_after_external_side_effect(&message_id, sent_refs, "chat.postMessage") + .await; + return Ok(message_id); } - let message_id = if sent_refs.len() == 1 { - slack_message_id_from_ref(&sent_refs[0]) - } else { - MessageId::new(Uuid::now_v7().to_string()) - }; + + // Multi-chunk: allocate the aggregate id up front so each confirmed chunk + // reference is persisted under a stable id as it is sent, and a + // mid-message failure compensates by deleting the already-sent chunks + // instead of orphaning visible messages. + let message_id = MessageId::new(Uuid::now_v7().to_string()); + let sent_refs = + send_multi_chunk_tracked(self, &self.outbound_refs, &target, &message_id, &rendered) + .await?; self.outbound_refs .record_after_external_side_effect(&message_id, sent_refs, "chat.postMessage") .await; @@ -605,82 +694,115 @@ impl SlackAdapter { let existing = self.outbound_refs.resolve(msg_id).await?; let rendered = self.renderer.render(&msg); - let overlap = existing.len().min(rendered.len()); - let mut updated_refs = Vec::with_capacity(rendered.len()); + apply_edit_tracked(self, &self.outbound_refs, msg_id, &existing, &rendered).await?; + Ok(()) + } - for index in 0..overlap { - let message_ref = existing[index].clone(); - self.update_chunk(&message_ref, &rendered[index]).await?; - updated_refs.push(message_ref); + async fn delete_locked(&self, msg_id: &MessageId) -> Result<()> { + let refs = self.outbound_refs.resolve(msg_id).await?; + for message_ref in &refs { + self.delete_ref(message_ref).await?; } + self.outbound_refs + .remove_after_external_side_effect(msg_id, "chat.delete") + .await; + Ok(()) + } +} - if rendered.len() > existing.len() { - let target = existing - .last() - .cloned() - .map(|message_ref| message_ref.target()) - .ok_or_else(|| { - MoaError::ValidationError(format!("slack message id {msg_id} has no refs")) - })?; - for chunk in rendered.iter().skip(existing.len()) { - let sent_ref = self.send_chunk(&target, chunk).await?; - updated_refs.push(sent_ref); - } - } +#[async_trait] +impl SlackChunkTransport for SlackAdapter { + async fn send_chunk( + &self, + target: &SlackTarget, + chunk: &SlackRenderChunk, + ) -> Result { + self.rate_limiter + .wait_for_channel_slot(target.channel_id.as_ref()) + .await?; + let session = self.client.open_session(&self.bot_token); + let request = SlackApiChatPostMessageRequest { + channel: SlackChannelId(target.channel_id.to_string()), + content: slack_message_content(chunk), + as_user: None, + icon_emoji: None, + icon_url: None, + link_names: None, + parse: None, + thread_ts: target.thread_ts.clone().map(SlackTs), + username: None, + reply_broadcast: None, + unfurl_links: None, + unfurl_media: None, + }; - if existing.len() > rendered.len() { - let session = self.client.open_session(&self.bot_token); - for message_ref in existing.iter().skip(rendered.len()) { - self.rate_limiter - .wait_for_channel_slot(message_ref.channel_id.as_ref()) - .await?; - let request = SlackApiChatDeleteRequest { - channel: SlackChannelId(message_ref.channel_id.to_string()), - ts: SlackTs(message_ref.ts.clone()), - as_user: None, - }; - session - .chat_delete(&request) - .await - .map_err(|error| slack_client_error("chat.delete", error))?; - } + async { + let response = session + .chat_post_message(&request) + .await + .map_err(|error| slack_client_error("chat.postMessage", error))?; + Ok(SlackMessageRef { + channel_id: Arc::::from(response.channel.0), + ts: response.ts.0, + thread_ts: target.thread_ts.clone(), + }) } + .instrument(slack_api_span("slack_message_send", "chat.postMessage")) + .await + } - self.outbound_refs - .record_after_external_side_effect(msg_id, updated_refs, "chat.update") - .await; - Ok(()) + async fn update_chunk( + &self, + message_ref: &SlackMessageRef, + chunk: &SlackRenderChunk, + ) -> Result<()> { + self.rate_limiter + .wait_for_channel_slot(message_ref.channel_id.as_ref()) + .await?; + let session = self.client.open_session(&self.bot_token); + let request = SlackApiChatUpdateRequest { + channel: SlackChannelId(message_ref.channel_id.to_string()), + content: slack_message_content(chunk), + ts: SlackTs(message_ref.ts.clone()), + as_user: None, + link_names: None, + parse: None, + reply_broadcast: None, + }; + + async { + session + .chat_update(&request) + .await + .map_err(|error| slack_client_error("chat.update", error))?; + Ok(()) + } + .instrument(slack_api_span("slack_message_update", "chat.update")) + .await } - async fn delete_locked(&self, msg_id: &MessageId) -> Result<()> { - let refs = self.outbound_refs.resolve(msg_id).await?; + async fn delete_ref(&self, message_ref: &SlackMessageRef) -> Result<()> { + self.rate_limiter + .wait_for_channel_slot(message_ref.channel_id.as_ref()) + .await?; let session = self.client.open_session(&self.bot_token); - for message_ref in refs { - self.rate_limiter - .wait_for_channel_slot(message_ref.channel_id.as_ref()) - .await?; - let request = SlackApiChatDeleteRequest { - channel: SlackChannelId(message_ref.channel_id.to_string()), - ts: SlackTs(message_ref.ts), - as_user: None, - }; - match session.chat_delete(&request).await { - Ok(_) => {} - Err(SlackClientError::ApiError(api)) if api.code == "message_not_found" => { - tracing::debug!( - message_id = %msg_id, - slack.channel_id = %message_ref.channel_id, - slack.ts = %request.ts.0, - "Slack delete retried a chunk that was already absent" - ); - } - Err(error) => return Err(slack_client_error("chat.delete", error)), + let request = SlackApiChatDeleteRequest { + channel: SlackChannelId(message_ref.channel_id.to_string()), + ts: SlackTs(message_ref.ts.clone()), + as_user: None, + }; + match session.chat_delete(&request).await { + Ok(_) => Ok(()), + Err(SlackClientError::ApiError(api)) if api.code == "message_not_found" => { + tracing::debug!( + slack.channel_id = %message_ref.channel_id, + slack.ts = %message_ref.ts, + "Slack delete skipped a chunk that was already absent" + ); + Ok(()) } + Err(error) => Err(slack_client_error("chat.delete", error)), } - self.outbound_refs - .remove_after_external_side_effect(msg_id, "chat.delete") - .await; - Ok(()) } } @@ -1649,4 +1771,245 @@ mod tests { if message == "slack Web API returned error code invalid_auth" )); } + + #[derive(Default)] + struct FakeChunkTransport { + fail_send_at: Option, + send_calls: std::sync::Mutex, + sent: std::sync::Mutex>, + deleted: std::sync::Mutex>, + updated: std::sync::Mutex>, + // When set, each send records how many references are already persisted + // for the aggregate id at the moment the send is issued, proving the + // incremental-persistence ordering. + persistence_probe: Option<(Arc, MessageId)>, + persisted_before_send: std::sync::Mutex>, + } + + impl FakeChunkTransport { + fn new(fail_send_at: Option) -> Self { + Self { + fail_send_at, + ..Default::default() + } + } + } + + #[async_trait] + impl SlackChunkTransport for FakeChunkTransport { + async fn send_chunk( + &self, + target: &SlackTarget, + _chunk: &SlackRenderChunk, + ) -> Result { + let index = { + let mut calls = self.send_calls.lock().expect("send_calls lock"); + let index = *calls; + *calls += 1; + index + }; + if let Some((refs, message_id)) = &self.persistence_probe { + let persisted = refs + .load(message_id) + .await + .expect("probe load") + .map_or(0, |refs| refs.len()); + self.persisted_before_send + .lock() + .expect("probe lock") + .push(persisted); + } + if self.fail_send_at == Some(index) { + return Err(MoaError::ProviderError( + "simulated chunk send failure".to_string(), + )); + } + let sent_ref = SlackMessageRef { + channel_id: target.channel_id.clone(), + ts: format!("ts-{index}"), + thread_ts: target.thread_ts.clone(), + }; + self.sent.lock().expect("sent lock").push(sent_ref.clone()); + Ok(sent_ref) + } + + async fn update_chunk( + &self, + message_ref: &SlackMessageRef, + _chunk: &SlackRenderChunk, + ) -> Result<()> { + self.updated + .lock() + .expect("updated lock") + .push(message_ref.ts.clone()); + Ok(()) + } + + async fn delete_ref(&self, message_ref: &SlackMessageRef) -> Result<()> { + self.deleted + .lock() + .expect("deleted lock") + .push(message_ref.ts.clone()); + Ok(()) + } + } + + fn test_chunk(text: &str) -> SlackRenderChunk { + SlackRenderChunk { + text: text.to_string(), + } + } + + fn test_target() -> SlackTarget { + SlackTarget { + channel_id: Arc::::from("C123"), + thread_ts: Some("1700000000.000100".to_string()), + } + } + + fn memory_outbound_refs() -> SlackOutboundMessageRefs { + SlackOutboundMessageRefs::new(Some(Arc::new(MemoryRuntimeCacheStore::default()))) + } + + fn test_message_ref(ts: &str) -> SlackMessageRef { + SlackMessageRef { + channel_id: Arc::::from("C123"), + ts: ts.to_string(), + thread_ts: Some("1700000000.000100".to_string()), + } + } + + #[tokio::test] + async fn multi_chunk_send_compensates_already_sent_chunk_on_middle_failure() { + // Pins: a middle-chunk send failure deletes the already-sent chunks and + // clears the partial record, so Slack never shows a visible-but-untracked + // partial message and no stale aggregate ref remains. + let refs = memory_outbound_refs(); + let transport = FakeChunkTransport::new(Some(1)); + let message_id = MessageId::new(Uuid::now_v7().to_string()); + let chunks = vec![test_chunk("one"), test_chunk("two"), test_chunk("three")]; + + let result = + send_multi_chunk_tracked(&transport, &refs, &test_target(), &message_id, &chunks).await; + + assert!(result.is_err(), "the middle-chunk failure should surface"); + assert_eq!( + transport.deleted.lock().expect("deleted lock").as_slice(), + ["ts-0"], + "the one already-sent chunk must be compensated-deleted" + ); + assert!( + refs.load(&message_id).await.expect("load refs").is_none(), + "no partial refs may remain for the aggregate id after compensation" + ); + } + + #[tokio::test] + async fn multi_chunk_send_persists_each_chunk_before_the_next() { + // Pins: each confirmed chunk reference is persisted before the next chunk is + // sent, so an interruption never leaves a sent chunk unrecorded. + let refs = Arc::new(memory_outbound_refs()); + let message_id = MessageId::new(Uuid::now_v7().to_string()); + let mut transport = FakeChunkTransport::new(None); + transport.persistence_probe = Some((refs.clone(), message_id.clone())); + let chunks = vec![test_chunk("one"), test_chunk("two"), test_chunk("three")]; + + let sent = + send_multi_chunk_tracked(&transport, &refs, &test_target(), &message_id, &chunks) + .await + .expect("all chunks send"); + + assert_eq!(sent.len(), 3); + assert_eq!( + transport + .persisted_before_send + .lock() + .expect("probe lock") + .as_slice(), + [0, 1, 2], + "before sending chunk i, exactly chunks 0..i must already be persisted" + ); + assert_eq!( + refs.load(&message_id) + .await + .expect("load refs") + .map(|refs| refs.len()), + Some(3) + ); + } + + #[tokio::test] + async fn edit_growth_persists_new_chunk_before_a_later_new_chunk_fails() { + // Pins: when an edit grows the message, each newly-sent chunk is persisted + // before the next send, so a later new-chunk failure still leaves the earlier + // new chunk tracked rather than visible-but-untracked. + let refs = memory_outbound_refs(); + let message_id = MessageId::new(Uuid::now_v7().to_string()); + let existing = vec![test_message_ref("existing-0")]; + refs.store(&message_id, existing.clone()) + .await + .expect("seed existing refs"); + // overlap = 1 update, then two new chunks; fail the second new send. + let transport = FakeChunkTransport::new(Some(1)); + let rendered = vec![ + test_chunk("edit-0"), + test_chunk("new-1"), + test_chunk("new-2"), + ]; + + let result = apply_edit_tracked(&transport, &refs, &message_id, &existing, &rendered).await; + + assert!( + result.is_err(), + "the second new-chunk failure should surface" + ); + let stored = refs + .load(&message_id) + .await + .expect("load refs") + .expect("refs remain after partial growth"); + assert_eq!( + stored.len(), + 2, + "the overlap chunk and the first new chunk must both stay tracked" + ); + assert_eq!(stored[0].ts, "existing-0"); + assert_eq!(stored[1].ts, "ts-0"); + } + + #[tokio::test] + async fn edit_growth_persists_all_new_chunks_on_success() { + // Pins: a successful growth edit updates overlap chunks in place and tracks + // every newly-sent chunk. + let refs = memory_outbound_refs(); + let message_id = MessageId::new(Uuid::now_v7().to_string()); + let existing = vec![test_message_ref("existing-0")]; + refs.store(&message_id, existing.clone()) + .await + .expect("seed existing refs"); + let transport = FakeChunkTransport::new(None); + let rendered = vec![ + test_chunk("edit-0"), + test_chunk("new-1"), + test_chunk("new-2"), + ]; + + let updated = apply_edit_tracked(&transport, &refs, &message_id, &existing, &rendered) + .await + .expect("edit grows the message"); + + assert_eq!(updated.len(), 3); + assert_eq!( + refs.load(&message_id) + .await + .expect("load refs") + .map(|refs| refs.len()), + Some(3) + ); + assert_eq!( + transport.updated.lock().expect("updated lock").as_slice(), + ["existing-0"], + "only the overlap chunk is edited in place" + ); + } } diff --git a/crates/moa-migrations/migrations/postgres/V000101__auth_baseline.sql b/crates/moa-migrations/migrations/postgres/V000101__auth_baseline.sql index ec4c67bb..2b2896c4 100644 --- a/crates/moa-migrations/migrations/postgres/V000101__auth_baseline.sql +++ b/crates/moa-migrations/migrations/postgres/V000101__auth_baseline.sql @@ -5,17 +5,25 @@ -- -- A handler that mutates Postgres state queues tuple operations into this -- table inside the same transaction. The outbox poller claims pending rows --- and applies them to OpenFGA. Idempotency keys prevent duplicate rows for --- the same desired tuple state. +-- and applies them to OpenFGA. +-- +-- Each row holds the LATEST DESIRED STATE of one tuple, not the lifetime +-- history of one operation. Tuple identity is +-- `(tuple_user, tuple_relation, tuple_object, model_version)` and is unique; +-- the desired `op` (write|delete) and a monotonically increasing `generation` +-- live on that single row. Enqueue upserts the desired op and bumps the +-- generation, so `write -> delete -> write` converges to the final desired +-- state instead of the first operation permanently owning the identity. The +-- poller applies only the newest generation (compare-and-set on success). CREATE TABLE authz_outbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - idempotency_key TEXT NOT NULL UNIQUE, op TEXT NOT NULL CHECK (op IN ('write', 'delete')), tuple_user TEXT NOT NULL, tuple_relation TEXT NOT NULL, tuple_object TEXT NOT NULL, model_version INTEGER NOT NULL, + generation BIGINT NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_flight', 'succeeded', 'dead_letter')), attempts INTEGER NOT NULL DEFAULT 0, @@ -23,7 +31,12 @@ CREATE TABLE authz_outbox ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - tenant_id UUID + tenant_id UUID, + -- Lease fencing so any pod can safely reclaim an abandoned in-flight row. + lease_token UUID, + lease_expires_at TIMESTAMPTZ, + CONSTRAINT authz_outbox_tuple_identity_key + UNIQUE (tuple_user, tuple_relation, tuple_object, model_version) ); CREATE INDEX idx_authz_outbox_pending diff --git a/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql b/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql index be94669a..6840aa3a 100644 --- a/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql +++ b/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql @@ -55,20 +55,32 @@ BEGIN AND tuple_object LIKE 'tenant:%' AND tuple_relation IN ('member', 'scim_admin'); + -- Desired state = write the workspace->tenant edge. Mirror the outbox + -- upsert semantics: reactivate the identity only if it currently carries a + -- different op or was dead-lettered, so an already-pending/succeeded write + -- is left untouched. INSERT INTO authz_outbox - (idempotency_key, op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id) + (op, tuple_user, tuple_relation, tuple_object, model_version, tenant_id, + generation, status, attempts, next_attempt_at) SELECT - format( - 'write-tenant:%s-workspace-workspace:%s-v4', - tenant_id, - default_workspace_id - ), 'write', 'workspace:' || default_workspace_id, 'workspace', 'tenant:' || tenant_id, 4, - tenant_id + tenant_id, + 1, 'pending', 0, NOW() FROM workspace_authz_backfill_tenants - ON CONFLICT (idempotency_key) DO NOTHING; + ON CONFLICT (tuple_user, tuple_relation, tuple_object, model_version) DO UPDATE + SET op = EXCLUDED.op, + generation = authz_outbox.generation + 1, + status = 'pending', + attempts = 0, + last_error = NULL, + lease_token = NULL, + lease_expires_at = NULL, + next_attempt_at = NOW(), + updated_at = NOW() + WHERE authz_outbox.op IS DISTINCT FROM EXCLUDED.op + OR authz_outbox.status = 'dead_letter'; END $$; diff --git a/crates/moa-migrations/migrations/postgres/V000328__privacy_erasure_jobs.sql b/crates/moa-migrations/migrations/postgres/V000328__privacy_erasure_jobs.sql new file mode 100644 index 00000000..b4745ca5 --- /dev/null +++ b/crates/moa-migrations/migrations/postgres/V000328__privacy_erasure_jobs.sql @@ -0,0 +1,51 @@ +-- Durable, resumable privacy-erasure jobs. +-- +-- Privacy erasure runs inside one Restate `ctx.run`, but its side effects +-- (approval-token consumption, PII-vault erasure, per-node graph purges, digest +-- and retrieval-lineage deletion) each commit independently. A crash or Restate +-- re-execution after some side effects committed previously stranded the +-- erasure: the approval token was spent, part of the data was erased, and the +-- terminal "approval token replayed" conflict blocked any resume. +-- +-- `moa.erasure_jobs` binds each approval-token JTI to exactly one idempotent job +-- keyed by that JTI. A replay of the same request recognizes that it owns the +-- JTI and resumes from the persisted stage; a reuse of the same token for a +-- materially different request (different request fingerprint) is rejected so +-- approval tokens never become generally reusable. +-- +-- This is a control-plane bookkeeping table written only by the admin-gated +-- privacy service, so it follows the `moa.audit_jti_used` precedent: explicit +-- role grants, no row-level security. + +CREATE TABLE IF NOT EXISTS moa.erasure_jobs ( + jti TEXT PRIMARY KEY, + tenant_id UUID NOT NULL, + subject_user_id TEXT NOT NULL, + -- Deterministic fingerprint of the request parameters bound to this token. + -- Distinguishes a resume of the same request from a reuse of the token for a + -- different request. + request_fingerprint TEXT NOT NULL, + approver_id TEXT NOT NULL, + approval_claims JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running', 'completed', 'failed')), + -- Highest stage the job still needs to run. Stages execute in this order and + -- a resumed job jumps straight to the persisted stage. + stage TEXT NOT NULL DEFAULT 'vault' + CHECK (stage IN ('vault', 'graph', 'digest', 'lineage', 'done')), + candidate_count BIGINT NOT NULL DEFAULT 0, + pii_vault_erased BIGINT NOT NULL DEFAULT 0, + graph_erased BIGINT NOT NULL DEFAULT 0, + digest_deleted BIGINT NOT NULL DEFAULT 0, + lineage_deleted BIGINT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS erasure_jobs_subject_idx + ON moa.erasure_jobs (tenant_id, subject_user_id, created_at DESC); + +GRANT SELECT, INSERT, UPDATE ON moa.erasure_jobs TO moa_app, moa_promoter; +GRANT SELECT ON moa.erasure_jobs TO moa_auditor; diff --git a/crates/moa-migrations/migrations/postgres/V000329__vector_sync_outbox_dead_letter.sql b/crates/moa-migrations/migrations/postgres/V000329__vector_sync_outbox_dead_letter.sql new file mode 100644 index 00000000..701573e8 --- /dev/null +++ b/crates/moa-migrations/migrations/postgres/V000329__vector_sync_outbox_dead_letter.sql @@ -0,0 +1,26 @@ +-- Quarantine poison vector-sync jobs so permanent failures stop retrying forever. +-- +-- A row is dead-lettered when it hits a permanent (config/schema/dimension/auth) +-- failure or exhausts its transient-retry budget. Dead-lettered rows keep +-- `processed_at` NULL but are excluded from the claim predicate by +-- `dead_lettered_at IS NOT NULL`, and an operator redrive resets them to pending. + +ALTER TABLE moa.vector_sync_outbox + ADD COLUMN IF NOT EXISTS dead_lettered_at TIMESTAMPTZ; + +-- The pending indexes must exclude dead-lettered rows so the drainer's claim +-- scan never revisits quarantined work. +DROP INDEX IF EXISTS moa.vector_sync_outbox_pending_idx; +CREATE INDEX IF NOT EXISTS vector_sync_outbox_pending_idx + ON moa.vector_sync_outbox (available_at, sync_id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL; + +DROP INDEX IF EXISTS moa.vector_sync_outbox_partition_pending_idx; +CREATE INDEX IF NOT EXISTS vector_sync_outbox_partition_pending_idx + ON moa.vector_sync_outbox (storage_partition_id, available_at, sync_id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL; + +-- Dedicated index for surfacing and redriving quarantined rows per partition. +CREATE INDEX IF NOT EXISTS vector_sync_outbox_dead_letter_idx + ON moa.vector_sync_outbox (storage_partition_id, sync_id) + WHERE dead_lettered_at IS NOT NULL AND processed_at IS NULL; diff --git a/crates/moa-migrations/migrations/postgres/V000330__analytics_mv_refresh_state.sql b/crates/moa-migrations/migrations/postgres/V000330__analytics_mv_refresh_state.sql new file mode 100644 index 00000000..abd99a49 --- /dev/null +++ b/crates/moa-migrations/migrations/postgres/V000330__analytics_mv_refresh_state.sql @@ -0,0 +1,28 @@ +-- Freshness and outcome state for the analytics materialized-view refresh. +-- +-- Refresh ownership moved off the edge request path (where every replica could +-- herd a refresh) to the durable maintenance cron, single-flighted under a +-- Postgres advisory lock. This singleton row records the last successful and +-- failed refresh so the edge can report read-model staleness without triggering +-- work, and operators can see refresh health. +-- +-- Deployment-global (one row, keyed by a constant), so no tenant RLS. It follows +-- the `analytics.clickhouse_export_state` precedent: owned by the migration +-- role, no explicit per-role grants. + +CREATE SCHEMA IF NOT EXISTS analytics; + +CREATE TABLE IF NOT EXISTS analytics.materialized_view_refresh_state ( + -- Singleton guard: only the `true` row exists. + id BOOLEAN PRIMARY KEY DEFAULT TRUE, + -- Completion time of the most recent fully successful refresh. + last_success_at TIMESTAMPTZ, + -- Start/observation time of the most recent failed refresh. + last_failure_at TIMESTAMPTZ, + -- Error text of the most recent failed refresh. + last_error TEXT, + -- Wall-clock duration of the most recent refresh attempt, in milliseconds. + last_duration_ms BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT materialized_view_refresh_state_singleton CHECK (id) +); diff --git a/crates/moa-migrations/src/lib.rs b/crates/moa-migrations/src/lib.rs index 0e627a54..209b1b77 100644 --- a/crates/moa-migrations/src/lib.rs +++ b/crates/moa-migrations/src/lib.rs @@ -179,6 +179,18 @@ const SESSION_SCHEMA_MIGRATIONS: &[SchemaMigration] = &[ name: "V000327__clickhouse_analytics_export_state.sql", sql: include_str!("../migrations/postgres/V000327__clickhouse_analytics_export_state.sql"), }, + SchemaMigration { + name: "V000328__privacy_erasure_jobs.sql", + sql: include_str!("../migrations/postgres/V000328__privacy_erasure_jobs.sql"), + }, + SchemaMigration { + name: "V000329__vector_sync_outbox_dead_letter.sql", + sql: include_str!("../migrations/postgres/V000329__vector_sync_outbox_dead_letter.sql"), + }, + SchemaMigration { + name: "V000330__analytics_mv_refresh_state.sql", + sql: include_str!("../migrations/postgres/V000330__analytics_mv_refresh_state.sql"), + }, ]; const AUTH_SCHEMA_MIGRATIONS: &[SchemaMigration] = &[ diff --git a/crates/moa-ocsf/Cargo.toml b/crates/moa-ocsf/Cargo.toml index 04552be5..827e4620 100644 --- a/crates/moa-ocsf/Cargo.toml +++ b/crates/moa-ocsf/Cargo.toml @@ -11,6 +11,7 @@ chrono = { workspace = true, features = ["serde"] } constant_time_eq.workspace = true hex.workspace = true hmac.workspace = true +metrics.workspace = true moa-core.workspace = true moka.workspace = true rand = { workspace = true, features = ["std", "std_rng"] } diff --git a/crates/moa-ocsf/src/audit_sink.rs b/crates/moa-ocsf/src/audit_sink.rs index c6335cb7..7a695273 100644 --- a/crates/moa-ocsf/src/audit_sink.rs +++ b/crates/moa-ocsf/src/audit_sink.rs @@ -46,6 +46,7 @@ impl AuditSink { fn enqueue(&self, item: QueuedAudit) { if self.tx.try_send(item).is_err() { let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + record_dropped_metric("queue_full", 1); tracing::warn!( dropped_total = dropped, "security audit queue full; dropping event" @@ -54,6 +55,15 @@ impl AuditSink { } } +/// Emits the alertable audit-drop counter alongside the in-process atomic. +/// +/// The `DROPPED` atomic still backs the synchronous [`dropped_audit_count`] +/// accessor; this counter makes the same loss visible to metrics/alerting, +/// labeled by the reason the event was dropped. +fn record_dropped_metric(reason: &'static str, count: u64) { + metrics::counter!("moa_ocsf_audit_events_dropped_total", "reason" => reason).increment(count); +} + /// Total number of audit events dropped since process start. /// /// A non-zero value means the audit trail is incomplete because the queue was @@ -91,6 +101,7 @@ pub(crate) fn enqueue(tenant_id: Uuid, value: Value, target_resource_uid: Option let Some(sink) = SINK.get() else { // Uninitialized (e.g. no runtime configured): drop rather than fail. DROPPED.fetch_add(1, Ordering::Relaxed); + record_dropped_metric("uninitialized", 1); return; }; sink.enqueue(QueuedAudit { @@ -158,6 +169,7 @@ async fn flush_batch(pool: &PgPool, batch: &mut Vec) { }), Err(error) => { let dropped = DROPPED.fetch_add(1, Ordering::Relaxed) + 1; + record_dropped_metric("signing_failed", 1); tracing::warn!(error = %error, dropped_total = dropped, "security audit signing failed; dropping event"); } } @@ -168,6 +180,7 @@ async fn flush_batch(pool: &PgPool, batch: &mut Vec) { if let Err(error) = insert_rows(pool, &rows).await { let count = rows.len() as u64; let dropped = DROPPED.fetch_add(count, Ordering::Relaxed) + count; + record_dropped_metric("insert_failed", count); tracing::warn!(error = %error, dropped_total = dropped, batch = rows.len(), "security audit batch insert failed; dropping events"); } } diff --git a/crates/moa-orchestrator/src/runtime/jobs.rs b/crates/moa-orchestrator/src/runtime/jobs.rs index b723e690..88c40573 100644 --- a/crates/moa-orchestrator/src/runtime/jobs.rs +++ b/crates/moa-orchestrator/src/runtime/jobs.rs @@ -17,6 +17,10 @@ use crate::{ const DEFAULT_RESTATE_INGRESS_PORT: u16 = 8080; const CRON_BOOTSTRAP_ATTEMPTS: u32 = 60; const CRON_BOOTSTRAP_INTERVAL: Duration = Duration::from_secs(2); +/// Initial delay before retrying default cron jobs that failed a reconcile pass. +const CRON_RECONCILE_INITIAL_BACKOFF: Duration = Duration::from_secs(5); +/// Upper bound on the exponential backoff between cron reconcile passes. +const CRON_RECONCILE_MAX_BACKOFF: Duration = Duration::from_secs(300); /// Starts the OpenFGA outbox poller that drains queued authorization tuple changes. pub fn start_authz_outbox_poller(pool: &PgPool, fga_client: FgaClient) -> moa_authz::PollerHandle { @@ -54,11 +58,21 @@ where for attempt in 1..=CRON_BOOTSTRAP_ATTEMPTS { match fetch_deployments().await { Ok(deployments) if services_registered(&deployments) => { - if let Err(error) = install_default_cron_jobs(&ingress_url).await { - tracing::warn!( + match cron_bootstrap_client() { + Ok(client) => { + let ingress = ingress_url.trim_end_matches('/').to_string(); + reconcile_default_cron_jobs( + &client, + &ingress, + default_cron_jobs(), + CronReconcileBackoff::default(), + ) + .await; + } + Err(error) => tracing::error!( error = %error, - "failed to install default cron jobs; will not retry" - ); + "failed to build cron-bootstrap HTTP client; cannot install default cron jobs" + ), } return; } @@ -92,44 +106,145 @@ pub fn restate_ingress_base_url(configured_ingress_url: &str) -> String { trimmed.to_string() } -async fn install_default_cron_jobs(ingress_url: &str) -> Result<()> { - let client = Client::builder() +/// Bounded exponential backoff between default cron reconcile passes. +#[derive(Debug, Clone, Copy)] +struct CronReconcileBackoff { + initial: Duration, + max: Duration, +} + +impl Default for CronReconcileBackoff { + fn default() -> Self { + Self { + initial: CRON_RECONCILE_INITIAL_BACKOFF, + max: CRON_RECONCILE_MAX_BACKOFF, + } + } +} + +/// Builds the HTTP client used to configure default cron jobs. +fn cron_bootstrap_client() -> Result { + Client::builder() .timeout(Duration::from_secs(10)) .build() - .context("build cron-bootstrap HTTP client")?; - let ingress_url = ingress_url.trim_end_matches('/'); - - for job in default_cron_jobs() { - let response = client - .post(format!( - "{ingress_url}/restate/call/CronJob/{}/configure", - job.key - )) - .header( - "idempotency-key", - format!("cron-config-{}-{}", job.key, job.version), - ) - .header("content-type", "application/json") - .json(&job.body) - .send() - .await - .with_context(|| format!("configure cron job {}", job.key))?; - - if !response.status().is_success() { - let status = response.status(); - let text = match response.text().await { - Ok(text) => text, - Err(error) => format!(""), - }; - bail!("cron configure {} returned {status}: {text}", job.key); + .context("build cron-bootstrap HTTP client") +} + +/// Reconciles every default cron job independently, retrying failures forever. +/// +/// Each pass attempts all still-pending jobs; a per-job failure is collected and +/// retried on the next pass rather than aborting the remaining jobs, so a single +/// failing job can no longer silently omit later ones (vector-outbox drains, +/// consolidation, or materialized-view maintenance). The delay between passes +/// grows exponentially up to `backoff.max`. A degraded gauge and per-pass warn +/// remain published until every required job is installed. This intentionally +/// never gives up: leaving required maintenance uninstalled until process +/// restart is worse than a bounded-backoff background retry. +async fn reconcile_default_cron_jobs( + client: &Client, + ingress_url: &str, + jobs: Vec, + backoff: CronReconcileBackoff, +) { + let required = jobs.len(); + set_cron_pending_gauge(required); + let mut pending = jobs; + let mut delay = backoff.initial; + let mut pass: u32 = 0; + loop { + pass += 1; + pending = configure_pending_cron_jobs(client, ingress_url, pending, pass).await; + set_cron_pending_gauge(pending.len()); + if pending.is_empty() { + tracing::info!(required, passes = pass, "all default cron jobs installed"); + return; + } + tracing::warn!( + pass, + outstanding = pending.len(), + required, + backoff_secs = delay.as_secs(), + "default cron installation degraded; retrying uninstalled jobs after backoff" + ); + tokio::time::sleep(delay).await; + delay = delay.saturating_mul(2).min(backoff.max); + } +} + +/// Attempts one configure request per pending job, returning the ones that failed. +/// +/// Every job is attempted regardless of earlier failures in the same pass; the +/// returned vector preserves the still-failing jobs for the caller to retry. +async fn configure_pending_cron_jobs( + client: &Client, + ingress_url: &str, + jobs: Vec, + pass: u32, +) -> Vec { + let mut still_failed = Vec::new(); + for job in jobs { + match configure_cron_job(client, ingress_url, &job).await { + Ok(()) => tracing::info!(key = job.key, "default cron job configured"), + Err(error) => { + metrics::counter!( + "moa_cron_bootstrap_configure_failures_total", + "job" => job.key + ) + .increment(1); + tracing::warn!( + key = job.key, + pass, + error = %error, + "failed to configure default cron job; will retry" + ); + still_failed.push(job); + } } + } + still_failed +} + +/// Sends a single idempotent configure request for one default cron job. +async fn configure_cron_job( + client: &Client, + ingress_url: &str, + job: &DefaultCronJob, +) -> Result<()> { + let response = client + .post(format!( + "{ingress_url}/restate/call/CronJob/{}/configure", + job.key + )) + .header( + "idempotency-key", + format!("cron-config-{}-{}", job.key, job.version), + ) + .header("content-type", "application/json") + .json(&job.body) + .send() + .await + .with_context(|| format!("configure cron job {}", job.key))?; - tracing::info!(key = job.key, "cron job configured"); + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .await + .unwrap_or_else(|error| format!("")); + bail!("cron configure {} returned {status}: {text}", job.key); } Ok(()) } +/// Publishes the count of default cron jobs not yet installed as a degraded gauge. +/// +/// A non-zero value signals degraded background maintenance readiness; the gauge +/// returns to zero once every required job is installed. +fn set_cron_pending_gauge(pending: usize) { + metrics::gauge!("moa_cron_bootstrap_jobs_pending").set(pending as f64); +} + struct DefaultCronJob { key: &'static str, body: serde_json::Value, @@ -171,6 +286,17 @@ fn default_cron_jobs() -> Vec { }), version: "v1", }, + DefaultCronJob { + key: "analytics_materialized_views_refresh", + body: serde_json::json!({ + "schedule": "0 */15 * * * *", + "timezone": "UTC", + "target_service": "SessionStore", + "target_handler": "refresh_analytics_materialized_views", + "payload": {} + }), + version: "v1", + }, DefaultCronJob { key: "neon_prune_branches", body: serde_json::json!({ @@ -188,6 +314,139 @@ fn default_cron_jobs() -> Vec { #[cfg(test)] mod tests { use super::*; + use axum::{Router, extract::Path, extract::State, http::StatusCode, routing::post}; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Shared state for the mock Restate CronJob configure endpoint. + struct MockCronState { + hits: Mutex>, + fail_first_n: HashMap, + } + + async fn configure_handler( + State(state): State>, + Path(key): Path, + ) -> StatusCode { + let count = { + let mut hits = state.hits.lock().expect("mock cron hits lock"); + let count = hits.entry(key.clone()).or_insert(0); + *count += 1; + *count + }; + let fail_n = state.fail_first_n.get(&key).copied().unwrap_or(0); + if count <= fail_n { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + + async fn spawn_mock_cron_admin( + fail_first_n: HashMap, + ) -> (String, Arc) { + let state = Arc::new(MockCronState { + hits: Mutex::new(HashMap::new()), + fail_first_n, + }); + let app = Router::new() + .route( + "/restate/call/CronJob/{key}/configure", + post(configure_handler), + ) + .with_state(Arc::clone(&state)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock cron admin"); + let address = listener.local_addr().expect("mock cron admin address"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{address}"), state) + } + + fn fast_backoff() -> CronReconcileBackoff { + CronReconcileBackoff { + initial: Duration::from_millis(1), + max: Duration::from_millis(1), + } + } + + fn hit(state: &Arc, key: &str) -> usize { + state + .hits + .lock() + .expect("mock cron hits lock") + .get(key) + .copied() + .unwrap_or(0) + } + + #[tokio::test] + async fn one_failing_job_does_not_prevent_later_jobs_and_failures_are_collected() { + // Pins (F17): a single pass attempts every pending job regardless of + // earlier failures, and returns exactly the still-failing jobs so later + // jobs are never skipped by a first-error-wins abort. + let mut fail_first_n = HashMap::new(); + // Fail the first job and a middle job on every attempt this pass. + fail_first_n.insert("graph_memory_compact".to_string(), usize::MAX); + fail_first_n.insert("segment_materialized_views_refresh".to_string(), usize::MAX); + let (base_url, state) = spawn_mock_cron_admin(fail_first_n).await; + let client = cron_bootstrap_client().expect("client"); + + let still_failed = + configure_pending_cron_jobs(&client, &base_url, default_cron_jobs(), 1).await; + + // Every job was attempted exactly once even though the first one failed. + for key in [ + "graph_memory_compact", + "vector_sync_outbox_drain", + "segment_materialized_views_refresh", + "neon_prune_branches", + ] { + assert_eq!(hit(&state, key), 1, "job {key} should be attempted once"); + } + + // Only the two failing jobs are collected for retry, in original order. + let failed_keys: Vec<&str> = still_failed.iter().map(|job| job.key).collect(); + assert_eq!( + failed_keys, + vec!["graph_memory_compact", "segment_materialized_views_refresh"], + ); + } + + #[tokio::test] + async fn reconcile_retries_failed_jobs_until_every_job_is_installed() { + // Pins (F17): a job that fails its first attempt is retried on a later + // pass and eventually installed; reconciliation only returns once every + // required job succeeds. + let mut fail_first_n = HashMap::new(); + // This job returns 500 on its first attempt, then succeeds. + fail_first_n.insert("vector_sync_outbox_drain".to_string(), 1); + let (base_url, state) = spawn_mock_cron_admin(fail_first_n).await; + let client = cron_bootstrap_client().expect("client"); + + // Returning at all proves it did not give up after the first failed pass. + reconcile_default_cron_jobs(&client, &base_url, default_cron_jobs(), fast_backoff()).await; + + // The flaky job was retried; the others installed on the first pass. + assert_eq!( + hit(&state, "vector_sync_outbox_drain"), + 2, + "flaky job should be retried once after its initial failure" + ); + for key in [ + "graph_memory_compact", + "segment_materialized_views_refresh", + "neon_prune_branches", + ] { + assert_eq!( + hit(&state, key), + 1, + "already-installed job {key} should not be re-attempted" + ); + } + } #[test] fn default_cron_jobs_include_vector_sync_drain() { diff --git a/crates/moa-orchestrator/src/services/authz_admin.rs b/crates/moa-orchestrator/src/services/authz_admin.rs index e138a2a9..bab22fe7 100644 --- a/crates/moa-orchestrator/src/services/authz_admin.rs +++ b/crates/moa-orchestrator/src/services/authz_admin.rs @@ -117,86 +117,67 @@ impl Authz for AuthzImpl { annotate_restate_handler_span("Authz", "write_tuple"); let identity = require_identity(&ctx)?; let request = request.into_inner(); - let pool = OrchestratorCtx::current_graph_pool(); - let load_pool = pool.clone(); - let load_request = request.clone(); - let object_tenant_id = ctx - .run(|| async move { - load_api_key_tenant_for_tuple(load_pool, &load_request) - .await - .map(Json::from) - }) - .name("authz_load_api_key_tenant") - .await? - .into_inner(); + + // Authorize the caller against the request's supplied tenant before any + // resource read. Loading the target API key first would let an + // authenticated but unauthorized principal probe key existence and + // cross-tenant ownership through distinguishable pre-authz errors. let fga = require_fga_client()?; require_authz_with_delegation( &fga, &identity, ObjectType::Tenant, - TenantId::from(object_tenant_id), + TenantId::from(request.tenant_id()), Relation::Admin, ) .await .map_err(translate_authz_error)?; + let pool = OrchestratorCtx::current_graph_pool(); Ok(ctx - .run(|| async move { - enqueue_typed_tuple_write_for_tenant(pool, request, object_tenant_id).await - }) + .run(|| async move { enqueue_typed_tuple_write(pool, request).await }) .name("authz_write_typed_tuple") .await?) } } -/// Load and verify the target API-key tenant for a typed tuple write. -pub async fn load_api_key_tenant_for_tuple( +/// Enqueue one typed public tuple write after confirming tenant-scoped ownership. +/// +/// The API key is looked up by both its id and the request's tenant, with the +/// active-key predicate, inside the same transaction that enqueues the outbox +/// tuple. A nonexistent, revoked, or foreign-tenant key all yield the same +/// not-found result so that a caller cannot distinguish key existence or +/// cross-tenant ownership. Callers MUST authorize the request's supplied tenant +/// before invoking this. +pub async fn enqueue_typed_tuple_write( pool: PgPool, - request: &WriteTupleRequest, -) -> Result { - let api_key_id = request.api_key_id(); - let stored_tenant_id: Option = sqlx::query_scalar( + request: WriteTupleRequest, +) -> Result<(), HandlerError> { + let tenant_id = request.tenant_id(); + let mut transaction = pool + .begin() + .await + .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; + + let matched_key: Option = sqlx::query_scalar( r#" - SELECT tenant_id + SELECT id FROM api_keys - WHERE id = $1 AND revoked_at IS NULL + WHERE id = $1 AND tenant_id = $2 AND revoked_at IS NULL "#, ) - .bind(api_key_id) - .fetch_optional(&pool) + .bind(request.api_key_id()) + .bind(tenant_id) + .fetch_optional(&mut *transaction) .await .map_err(|error| TerminalError::new(format!("load api key for authz tuple: {error}")))?; - let Some(stored_tenant_id) = stored_tenant_id else { + if matched_key.is_none() { return Err(TerminalError::new_with_code(404, "API key not found").into()); - }; - if stored_tenant_id != request.tenant_id() { - return Err(TerminalError::new_with_code(403, "API key tenant mismatch").into()); } - Ok(stored_tenant_id) -} - -/// Enqueue one typed public tuple write after validating API-key ownership. -pub async fn enqueue_typed_tuple_write( - pool: PgPool, - request: WriteTupleRequest, -) -> Result<(), HandlerError> { - let object_tenant_id = load_api_key_tenant_for_tuple(pool.clone(), &request).await?; - enqueue_typed_tuple_write_for_tenant(pool, request, object_tenant_id).await -} -async fn enqueue_typed_tuple_write_for_tenant( - pool: PgPool, - request: WriteTupleRequest, - object_tenant_id: Uuid, -) -> Result<(), HandlerError> { - let tuple = request.tuple_key(object_tenant_id); + let tuple = request.tuple_key(tenant_id); let op = request.tuple_op(); - let mut transaction = pool - .begin() - .await - .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; - - enqueue(&mut *transaction, op, &tuple, Some(object_tenant_id)) + enqueue(&mut *transaction, op, &tuple, Some(tenant_id)) .await .map_err(|error| TerminalError::new(format!("authz outbox: {error}")))?; transaction diff --git a/crates/moa-orchestrator/src/services/contacts.rs b/crates/moa-orchestrator/src/services/contacts.rs index 3835e523..ef05e373 100644 --- a/crates/moa-orchestrator/src/services/contacts.rs +++ b/crates/moa-orchestrator/src/services/contacts.rs @@ -24,8 +24,8 @@ use moa_core::{ ContactSessionPromotionResponse, ContactTokenClaims, ContactTokenIssueRequest, ContactTokenIssueResponse, ContactVerificationCompleteRequest, ContactVerificationCompleteResponse, ContactVerificationStartRequest, - ContactVerificationStartResponse, Event, ModelId, SessionActorRef, SessionMeta, SessionStatus, - StoragePartitionId, TenantId, + ContactVerificationStartResponse, Event, ModelId, SessionActorRef, SessionChannelBindingId, + SessionId, SessionMeta, SessionStatus, StoragePartitionId, TenantId, }; use moa_core::{MoaError, SessionStore}; use moa_observability::restate_observability::annotate_restate_handler_span; @@ -38,7 +38,8 @@ use crate::handlers::authz_shim::authorize_tenant; use crate::objects::session::SessionClient; use crate::restate_identity::with_identity_headers; use crate::services::session_store::inner::{ - create_session_for_identity, resolve_agent_context_for_session, + change_contact_session_channel_atomic, initialize_contact_session_atomic, + resolve_agent_context_for_session, }; /// Restate surface for contact identity and contact-scoped sessions. @@ -377,7 +378,17 @@ impl Contacts for ContactsImpl { .name("contacts_prepare_session_channel") .await? .into_inner(); + // Journal a replay-stable session id in a side-effect-free step so a + // handler replay reuses the same identity instead of minting a second + // complete session. The idempotent creation transaction below keys all + // product writes on this id. + let session_id: SessionId = ctx + .run(|| async move { Ok::<_, HandlerError>(Json::from(SessionId::new())) }) + .name("contacts_allocate_session_id") + .await? + .into_inner(); let meta = SessionMeta { + id: session_id, tenant_id, title: request.title, status: SessionStatus::Created, @@ -395,7 +406,7 @@ impl Contacts for ContactsImpl { let storage_partition_id_for_create = storage_partition_id.clone(); let resolver_pool = pool.clone(); let create_pool = pool.clone(); - let (session_id, meta_for_vo) = ctx + let meta_for_vo = ctx .run(|| async move { let agent_context = resolve_agent_context_for_session(resolver_pool, &meta, &agent_selection) @@ -403,48 +414,47 @@ impl Contacts for ContactsImpl { let mut meta = meta; meta.agent_context = Some(agent_context); let identity = contact_identity(contact.contact_id, tenant_id); - let session_id = create_session_for_identity( + let binding = SessionChannelBindingUpdate { + tenant_id, + storage_partition_id: storage_partition_id_for_create, + session_id, + contact_id: contact.contact_id, + channel_account_id: channel_account + .as_ref() + .map(|account| account.channel_account_id), + contact_point_id, + channel_ref, + reason: channel_reason, + }; + let created_event = Event::SessionCreated { + tenant_id, + contact_id: Some(contact.contact_id), + created_by: Some(SessionActorRef::Contact { + id: contact.contact_id, + }), + model, + channel: event_channel, + }; + // Session row, agent sidecar, authz tuples, initial binding, and + // the SessionCreated event commit atomically here. The binding id + // is fresh but only consumed when the session is freshly + // inserted, so a replay (session already present) never creates a + // second binding or event. + initialize_contact_session_atomic( store_backend.as_ref(), &create_pool, meta, identity, + SessionChannelBindingId::new(), + binding, + created_event, ) .await?; - store - .replace_session_channel_binding(SessionChannelBindingUpdate { - tenant_id, - storage_partition_id: storage_partition_id_for_create, - session_id, - contact_id: contact.contact_id, - channel_account_id: channel_account - .as_ref() - .map(|account| account.channel_account_id), - contact_point_id, - channel_ref, - reason: channel_reason, - }) - .await - .map_err(session_store_handler_error)?; - store - .emit_event( - session_id, - Event::SessionCreated { - tenant_id, - contact_id: Some(contact.contact_id), - created_by: Some(SessionActorRef::Contact { - id: contact.contact_id, - }), - model, - channel: event_channel, - }, - ) - .await - .map_err(session_store_handler_error)?; let meta_for_vo = store .get_session(session_id) .await .map_err(session_store_handler_error)?; - Ok::<_, HandlerError>(Json::from((session_id, meta_for_vo))) + Ok::<_, HandlerError>(Json::from(meta_for_vo)) }) .name("contacts_create_session") .await? @@ -482,6 +492,18 @@ impl Contacts for ContactsImpl { let pool = OrchestratorCtx::current_graph_pool(); let store = OrchestratorCtx::current_session_store(); let storage_partition_id = StoragePartitionId::for_tenant(tenant_id); + let store_backend = OrchestratorCtx::current().session_store_backend(); + let change_pool = pool.clone(); + // Journal a replay-stable binding id so the channel-change transaction is + // idempotent: a replay reuses this id, the binding insert conflicts, and + // no duplicate binding or SessionChannelChanged event is written. + let binding_id: SessionChannelBindingId = ctx + .run( + || async move { Ok::<_, HandlerError>(Json::from(SessionChannelBindingId::new())) }, + ) + .name("contacts_allocate_channel_binding_id") + .await? + .into_inner(); let ChannelChangeResult { contact, @@ -507,39 +529,38 @@ impl Contacts for ContactsImpl { resolve_contact_session_channel(&pool, &contact, request.channel_ref) .await .map_err(contact_error_handler_error)?; - let binding_id = store - .replace_session_channel_binding(SessionChannelBindingUpdate { - tenant_id, - storage_partition_id, - session_id, - contact_id: contact.contact_id, - channel_account_id: resolved - .channel_account - .as_ref() - .map(|account| account.channel_account_id), - contact_point_id: resolved.contact_point_id, - channel_ref: resolved.channel_ref.clone(), - reason: request.reason.clone(), - }) - .await - .map_err(session_store_handler_error)?; - store - .emit_event( - session_id, - Event::SessionChannelChanged { - from: existing_meta.channel, - to: resolved.channel_ref.channel(), - contact_id: Some(contact.contact_id), - from_binding_id: existing_meta.active_channel_binding_id, - to_binding_id: Some(binding_id), - changed_by: Some(SessionActorRef::Contact { - id: contact.contact_id, - }), - reason: request.reason, - }, - ) - .await - .map_err(session_store_handler_error)?; + let binding = SessionChannelBindingUpdate { + tenant_id, + storage_partition_id, + session_id, + contact_id: contact.contact_id, + channel_account_id: resolved + .channel_account + .as_ref() + .map(|account| account.channel_account_id), + contact_point_id: resolved.contact_point_id, + channel_ref: resolved.channel_ref.clone(), + reason: request.reason.clone(), + }; + let changed_event = Event::SessionChannelChanged { + from: existing_meta.channel, + to: resolved.channel_ref.channel(), + contact_id: Some(contact.contact_id), + from_binding_id: existing_meta.active_channel_binding_id, + to_binding_id: Some(binding_id), + changed_by: Some(SessionActorRef::Contact { + id: contact.contact_id, + }), + reason: request.reason, + }; + change_contact_session_channel_atomic( + store_backend.as_ref(), + &change_pool, + binding_id, + binding, + changed_event, + ) + .await?; let meta = store .get_session(session_id) .await diff --git a/crates/moa-orchestrator/src/services/experiments.rs b/crates/moa-orchestrator/src/services/experiments.rs index c1c15fe4..6325d532 100644 --- a/crates/moa-orchestrator/src/services/experiments.rs +++ b/crates/moa-orchestrator/src/services/experiments.rs @@ -26,7 +26,7 @@ use moa_experiments::app::{ plan_generation_request, propose_improvement_candidate, scores, store_generated_plan, trial_status, }; -use moa_experiments::model::{ExperimentTrialStatus, ExperimentVariant}; +use moa_experiments::model::{ExperimentRunStatus, ExperimentTrialStatus, ExperimentVariant}; use moa_experiments::store::ExperimentStore; use moa_observability::record_experiment_learning_candidates; use moa_observability::restate_observability::annotate_restate_handler_span; @@ -273,11 +273,25 @@ impl Experiments for ExperimentsImpl { let request = request.into_inner(); authorize_tenant_operator_or_admin(&ctx, request.tenant_id).await?; let pool = OrchestratorCtx::current_graph_pool(); + let run_uid = request.run_uid; - Ok(ctx + let response = ctx .run(|| async move { cancel_inner(pool, request).await.map(Json::from) }) .name("experiments_cancel") - .await?) + .await? + .into_inner(); + + // After the projection is cancelled, durably propagate cancellation to + // the run workflow so it stops live child work (its own target and every + // active trial). Best-effort one-way; only meaningful once the run is + // cancelled, so genuinely finished runs are not signaled. + if response.status == ExperimentRunStatus::Cancelled.as_str() { + ctx.workflow_client::(run_uid.to_string()) + .request_cancel(Json::from(response.reason.clone())) + .send(); + } + + Ok(Json::from(response)) } #[tracing::instrument(skip(self, ctx, request))] diff --git a/crates/moa-orchestrator/src/services/graph_memory_maint.rs b/crates/moa-orchestrator/src/services/graph_memory_maint.rs index cc1668f1..baadf91c 100644 --- a/crates/moa-orchestrator/src/services/graph_memory_maint.rs +++ b/crates/moa-orchestrator/src/services/graph_memory_maint.rs @@ -57,6 +57,21 @@ impl Default for VectorSyncDrainRequest { } } +/// Request payload for redriving quarantined (dead-lettered) vector sync rows. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct VectorSyncRedriveRequest { + /// Optional storage-partition scope; `None` redrives every partition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage_partition_id: Option, +} + +/// Report returned by a vector-sync redrive pass. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct VectorSyncRedriveReport { + /// Number of quarantined rows returned to the pending state. + pub redriven: u64, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ConsolidationDispatch { workflow_id: String, @@ -73,6 +88,15 @@ pub trait GraphMemoryMaint { async fn sync_vectors( req: Json, ) -> Result, HandlerError>; + + /// Redrives quarantined vector sync rows after an operator remediates the fault. + /// + /// Operator-invoked, not cron-scheduled: quarantined (dead-lettered) rows only + /// return to the drainer when an operator calls this after fixing the + /// underlying permanent fault (embedder mismatch, backend auth/config). + async fn redrive_dead_lettered_vectors( + req: Json, + ) -> Result, HandlerError>; } /// Concrete graph-memory maintenance service implementation. @@ -150,6 +174,38 @@ impl GraphMemoryMaint for GraphMemoryMaintImpl { .name("graph-memory-vector-sync") .await?) } + + #[tracing::instrument(skip(self, ctx, request))] + // SAFETY: Internal operator/maintenance handler; re-queues quarantined vector projection outbox rows only. + async fn redrive_dead_lettered_vectors( + &self, + ctx: Context<'_>, + request: Json, + ) -> Result, HandlerError> { + annotate_restate_handler_span("GraphMemoryMaint", "redrive_dead_lettered_vectors"); + let request = request.into_inner(); + let scope_label = request.storage_partition_id.clone(); + let pool = OrchestratorCtx::current_graph_pool(); + let config = OrchestratorCtx::current_config(); + let partition = request.storage_partition_id; + let report = ctx + .run(|| async move { + let redriven = VectorStoreFactory::from_config(config.as_ref()) + .redrive_dead_lettered_external_sync(&pool, partition.as_deref()) + .await + .map_err(|error| TerminalError::new(format!("redrive vector sync: {error}")))?; + Ok::<_, HandlerError>(Json::from(VectorSyncRedriveReport { redriven })) + }) + .name("graph-memory-vector-redrive") + .await? + .into_inner(); + tracing::info!( + storage_partition_id = ?scope_label, + redriven = report.redriven, + "graph-memory maintenance redrove quarantined vector-sync rows" + ); + Ok(Json::from(report)) + } } fn default_vector_sync_drain_limit() -> i64 { diff --git a/crates/moa-orchestrator/src/services/knowledge/ingest.rs b/crates/moa-orchestrator/src/services/knowledge/ingest.rs index af79b6ba..96f5cc15 100644 --- a/crates/moa-orchestrator/src/services/knowledge/ingest.rs +++ b/crates/moa-orchestrator/src/services/knowledge/ingest.rs @@ -160,9 +160,7 @@ fn build_ingestion_pipeline( false, ); let graph_store = Arc::new( - PostgresGraphStore::scoped(pool, scope) - .with_vector_store(vector_backend.vector_store()) - .with_vector_post_commit_sync(vector_backend.post_commit_sync()), + PostgresGraphStore::scoped(pool, scope).with_vector_store(vector_backend.vector_store()), ); let graph = Arc::new(MemoryKnowledgeGraphWriter::new( graph_store, diff --git a/crates/moa-orchestrator/src/services/privacy/erase.rs b/crates/moa-orchestrator/src/services/privacy/erase.rs index da117211..4d69ba5d 100644 --- a/crates/moa-orchestrator/src/services/privacy/erase.rs +++ b/crates/moa-orchestrator/src/services/privacy/erase.rs @@ -1,17 +1,20 @@ //! Privacy erasure orchestration across graph memory and PII vault state. -use moa_core::wire::privacy::{ContactErasureScope, PrivacyEraseResponse}; +use moa_core::wire::privacy::{ContactErasureScope, PrivacyEraseResponse, PrivacyEraseStatus}; use moa_core::{StoragePartitionId, UserId}; use moa_lineage_audit::PiiVault; use moa_memory_pii::erasure::{ - EraseCandidate, GraphErasureAudit, enumerate_erase_candidates, hard_purge_erase_candidates, + EraseCandidate, GraphErasureAudit, delete_subject_digests, delete_subject_retrieval_lineage, + enumerate_erase_candidates, hard_purge_erase_candidates, }; use restate_sdk::prelude::*; use serde_json::json; use super::context::{PrivacyEraseContext, PrivacySubject}; use super::repository::{ - ContactLinkedSubjectPolicy, PrivacySubjectKind, consume_approval_jti, resolve_privacy_subjects, + ClaimedErasureJob, ContactLinkedSubjectPolicy, ErasureJobProgress, ErasureJobStage, + PrivacySubjectKind, claim_erasure_job, complete_erasure_job, resolve_privacy_subjects, + save_erasure_job_progress, }; use super::{handler_error, usize_to_u64}; @@ -46,38 +49,188 @@ pub async fn run_privacy_erase( let candidates = flatten_erase_candidates(&candidate_groups); if ctx.dry_run { - return Ok(erase_response(&ctx, &candidates, 0, 0)); + return Ok(dry_run_response(&ctx, &candidates)); } - consume_approval_jti(&ctx.pool, &ctx.claims).await?; - let pii_vault_erased = erase_pii_vault_subjects(&ctx, &resolved.subjects).await?; + // Bind the approval JTI to one durable, resumable job. A Restate re-execution + // of the same request resumes from the persisted stage instead of hitting a + // terminal "token replayed" conflict and stranding a partial erasure. + let job = claim_erasure_job( + &ctx.pool, + &ctx.claims, + &request_fingerprint(&ctx), + ctx.tenant_id.0, + usize_to_u64(candidates.len()), + ) + .await?; - if candidates.is_empty() { - return Ok(erase_response(&ctx, &candidates, 0, pii_vault_erased)); + // The originally enumerated candidate count is authoritative: a resumed run + // re-enumerates only the not-yet-purged remainder. + let candidate_count = if job.fresh { + usize_to_u64(candidates.len()) + } else { + job.candidate_count + }; + + if job.completed { + return Ok(erase_response( + &ctx, + candidate_count, + &candidates, + job.progress, + )); } - let mut erased_count = 0usize; - for (subject, subject_candidates) in candidate_groups { - if subject_candidates.is_empty() { - continue; - } - erased_count = erased_count.saturating_add( + let progress = run_erasure_stages( + &ctx, + &resolved.subjects, + &candidate_groups, + candidate_count, + job, + ) + .await?; + + complete_erasure_job(&ctx.pool, &ctx.claims.jti, &progress).await?; + + Ok(erase_response(&ctx, candidate_count, &candidates, progress)) +} + +/// Runs the remaining erasure stages in order, persisting progress after each so +/// a resumed job continues from where it stopped rather than restarting. +async fn run_erasure_stages( + ctx: &PrivacyEraseContext, + subjects: &[PrivacySubject], + candidate_groups: &[(PrivacySubject, Vec)], + candidate_count: u64, + job: ClaimedErasureJob, +) -> Result { + let mut progress = job.progress; + + if progress.stage == ErasureJobStage::Vault { + progress.pii_vault_erased = erase_pii_vault_subjects(ctx, subjects).await?; + progress.stage = ErasureJobStage::Graph; + save_erasure_job_progress(&ctx.pool, &ctx.claims.jti, &progress).await?; + } + + if progress.stage == ErasureJobStage::Graph { + for (subject, subject_candidates) in candidate_groups { + if subject_candidates.is_empty() { + continue; + } hard_purge_erase_candidates( &ctx.pool, - &graph_erasure_audit(&ctx, &subject), - &subject_candidates, + &graph_erasure_audit(ctx, subject), + subject_candidates, ) .await - .map_err(handler_error)?, - ); + .map_err(handler_error)?; + } + // After the graph stage completes without error, every enumerated + // candidate is absent — purged in this run or an earlier partial run — + // so the authoritative candidate count is the erased count. + progress.graph_erased = candidate_count; + progress.stage = ErasureJobStage::Digest; + save_erasure_job_progress(&ctx.pool, &ctx.claims.jti, &progress).await?; } - Ok(erase_response( - &ctx, - &candidates, - erased_count, - pii_vault_erased, - )) + if progress.stage == ErasureJobStage::Digest { + let mut deleted = 0u64; + for subject in subjects { + deleted = deleted.saturating_add( + delete_subject_digests(&ctx.pool, ctx.tenant_id, &subject.user_id) + .await + .map_err(handler_error)?, + ); + } + progress.digest_deleted = deleted; + progress.stage = ErasureJobStage::Lineage; + save_erasure_job_progress(&ctx.pool, &ctx.claims.jti, &progress).await?; + } + + if progress.stage == ErasureJobStage::Lineage { + let mut deleted = 0u64; + for subject in subjects { + deleted = deleted.saturating_add( + delete_subject_retrieval_lineage(&ctx.pool, ctx.tenant_id, &subject.user_id) + .await + .map_err(handler_error)?, + ); + } + progress.lineage_deleted = deleted; + progress.stage = ErasureJobStage::Done; + save_erasure_job_progress(&ctx.pool, &ctx.claims.jti, &progress).await?; + } + + Ok(progress) +} + +/// Builds a stable fingerprint of the request parameters bound to one approval +/// token, distinguishing a resume of the same request from a reuse of the token +/// for a different request. +fn request_fingerprint(ctx: &PrivacyEraseContext) -> String { + fingerprint_parts( + &ctx.tenant_id.to_string(), + &ctx.subject_user_id, + ctx.contact_erasure_scope, + &ctx.reason, + ) +} + +fn fingerprint_parts( + tenant_id: &str, + subject_user_id: &str, + scope: Option, + reason: &str, +) -> String { + let scope = match scope { + Some(ContactErasureScope::SpecifiedContact) => "specified", + Some(ContactErasureScope::SpecifiedAndLinkedContacts) => "specified_and_linked", + None => "none", + }; + // The reason length precedes the reason so the delimiter can never be forged + // by a reason that contains it. + format!( + "v1|tenant={tenant_id}|subject={subject_user_id}|scope={scope}|reason_len={}|reason={reason}", + reason.len(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_fingerprint_is_stable_for_identical_requests() { + // Pins: an identical erase request produces a byte-identical fingerprint so a + // Restate re-execution resumes rather than being seen as a new request. + let first = fingerprint_parts("tenant-1", "contact:abc", None, "gdpr erasure"); + let second = fingerprint_parts("tenant-1", "contact:abc", None, "gdpr erasure"); + assert_eq!(first, second); + } + + #[test] + fn request_fingerprint_discriminates_reason_and_scope() { + // Pins: reusing a token for a materially different request yields a different + // fingerprint, so the token cannot be repurposed across requests. + let base = fingerprint_parts("tenant-1", "contact:abc", None, "gdpr erasure"); + assert_ne!( + base, + fingerprint_parts("tenant-1", "contact:abc", None, "different reason") + ); + assert_ne!( + base, + fingerprint_parts( + "tenant-1", + "contact:abc", + Some(ContactErasureScope::SpecifiedContact), + "gdpr erasure", + ) + ); + assert_ne!( + base, + fingerprint_parts("tenant-2", "contact:abc", None, "gdpr erasure") + ); + } } fn validate_contact_erasure_scope( @@ -177,32 +330,57 @@ fn graph_erasure_audit(ctx: &PrivacyEraseContext, subject: &PrivacySubject) -> G } } -fn erase_response( +fn dry_run_response( ctx: &PrivacyEraseContext, candidates: &[SubjectEraseCandidate], - erased_count: usize, - pii_vault_erased: u64, ) -> PrivacyEraseResponse { PrivacyEraseResponse { tenant_id: ctx.tenant_id, subject_user_id: UserId::new(ctx.subject_user_id.clone()), + status: PrivacyEraseStatus::DryRun, candidate_count: usize_to_u64(candidates.len()), - erased_count: usize_to_u64(erased_count), - pii_vault_erased, - dry_run: ctx.dry_run, - sample: candidates - .iter() - .take(ERASE_SAMPLE_LIMIT) - .map(|candidate| { - json!({ - "uid": candidate.candidate.uid, - "label": candidate.candidate.label, - "name": candidate.candidate.name, - "pii_class": candidate.candidate.pii_class, - "privacy_subject_user_id": candidate.subject.user_id.as_str(), - "privacy_subject_provenance": candidate.subject.provenance.as_str(), - }) - }) - .collect(), + erased_count: 0, + pii_vault_erased: 0, + digest_deleted: 0, + lineage_deleted: 0, + dry_run: true, + sample: candidate_sample(candidates), } } + +fn erase_response( + ctx: &PrivacyEraseContext, + candidate_count: u64, + candidates: &[SubjectEraseCandidate], + progress: ErasureJobProgress, +) -> PrivacyEraseResponse { + PrivacyEraseResponse { + tenant_id: ctx.tenant_id, + subject_user_id: UserId::new(ctx.subject_user_id.clone()), + status: PrivacyEraseStatus::Completed, + candidate_count, + erased_count: progress.graph_erased, + pii_vault_erased: progress.pii_vault_erased, + digest_deleted: progress.digest_deleted, + lineage_deleted: progress.lineage_deleted, + dry_run: false, + sample: candidate_sample(candidates), + } +} + +fn candidate_sample(candidates: &[SubjectEraseCandidate]) -> Vec { + candidates + .iter() + .take(ERASE_SAMPLE_LIMIT) + .map(|candidate| { + json!({ + "uid": candidate.candidate.uid, + "label": candidate.candidate.label, + "name": candidate.candidate.name, + "pii_class": candidate.candidate.pii_class, + "privacy_subject_user_id": candidate.subject.user_id.as_str(), + "privacy_subject_provenance": candidate.subject.provenance.as_str(), + }) + }) + .collect() +} diff --git a/crates/moa-orchestrator/src/services/privacy/repository.rs b/crates/moa-orchestrator/src/services/privacy/repository.rs index 681ac571..9968a42e 100644 --- a/crates/moa-orchestrator/src/services/privacy/repository.rs +++ b/crates/moa-orchestrator/src/services/privacy/repository.rs @@ -45,14 +45,15 @@ pub(super) struct ResolvedPrivacySubjects { } /// Records an approval-token JTI and rejects token replay. +/// +/// Used by the single-shot export path. Erasure uses [`claim_erasure_job`], +/// which owns the JTI through a durable, resumable job instead of a single-use +/// ledger insert. pub(super) async fn consume_approval_jti( pool: &PgPool, claims: &ApprovalClaims, ) -> Result<(), HandlerError> { - let expires_at = Utc - .timestamp_opt(claims.exp, 0) - .single() - .ok_or_else(|| TerminalError::new_with_code(400, "approval token exp is out of range"))?; + let expires_at = jti_expires_at(claims)?; let inserted = sqlx::query_scalar::<_, String>( r#" INSERT INTO moa.audit_jti_used @@ -74,6 +75,264 @@ pub(super) async fn consume_approval_jti( ensure_jti_inserted(inserted.as_deref()) } +fn jti_expires_at(claims: &ApprovalClaims) -> Result, HandlerError> { + Utc.timestamp_opt(claims.exp, 0).single().ok_or_else(|| { + TerminalError::new_with_code(400, "approval token exp is out of range").into() + }) +} + +/// Ordered stages of a durable, resumable erasure job. +/// +/// A resumed job jumps straight to the persisted stage and runs the remaining +/// stages in order, so each stage runs at most once per completed job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ErasureJobStage { + /// PII vault subject keys not yet erased. + Vault, + /// Graph-memory candidates not yet purged. + Graph, + /// Standing memory-digest rows not yet deleted. + Digest, + /// Retrieval-lineage rows not yet deleted. + Lineage, + /// All stages complete. + Done, +} + +impl ErasureJobStage { + fn as_str(self) -> &'static str { + match self { + Self::Vault => "vault", + Self::Graph => "graph", + Self::Digest => "digest", + Self::Lineage => "lineage", + Self::Done => "done", + } + } + + fn parse(raw: &str) -> Result { + match raw { + "vault" => Ok(Self::Vault), + "graph" => Ok(Self::Graph), + "digest" => Ok(Self::Digest), + "lineage" => Ok(Self::Lineage), + "done" => Ok(Self::Done), + other => Err(TerminalError::new(format!("unknown erasure job stage: {other}")).into()), + } + } +} + +/// Accumulated per-store progress persisted on a durable erasure job. +#[derive(Debug, Clone, Copy)] +pub(super) struct ErasureJobProgress { + /// Stage the job should run next. + pub(super) stage: ErasureJobStage, + /// PII vault rows erased so far. + pub(super) pii_vault_erased: u64, + /// Graph-memory nodes purged so far. + pub(super) graph_erased: u64, + /// Standing memory-digest rows deleted so far. + pub(super) digest_deleted: u64, + /// Retrieval-lineage rows deleted so far. + pub(super) lineage_deleted: u64, +} + +/// A durable erasure job after claiming or resuming ownership of its JTI. +#[derive(Debug, Clone, Copy)] +pub(super) struct ClaimedErasureJob { + /// True when this call inserted the job (rather than resuming an existing one). + pub(super) fresh: bool, + /// Whether the job already reached the completed terminal state. + pub(super) completed: bool, + /// Original candidate count enumerated when the job was first claimed. + pub(super) candidate_count: u64, + /// Resumable progress, seeded from the persisted counters. + pub(super) progress: ErasureJobProgress, +} + +#[derive(Debug, sqlx::FromRow)] +struct ErasureJobRow { + request_fingerprint: String, + status: String, + stage: String, + candidate_count: i64, + pii_vault_erased: i64, + graph_erased: i64, + digest_deleted: i64, + lineage_deleted: i64, +} + +impl ErasureJobRow { + fn into_claimed(self, fresh: bool) -> Result { + Ok(ClaimedErasureJob { + fresh, + completed: self.status == "completed", + candidate_count: nonneg_u64(self.candidate_count), + progress: ErasureJobProgress { + stage: ErasureJobStage::parse(&self.stage)?, + pii_vault_erased: nonneg_u64(self.pii_vault_erased), + graph_erased: nonneg_u64(self.graph_erased), + digest_deleted: nonneg_u64(self.digest_deleted), + lineage_deleted: nonneg_u64(self.lineage_deleted), + }, + }) + } +} + +fn nonneg_u64(value: i64) -> u64 { + u64::try_from(value).unwrap_or(0) +} + +fn u64_to_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +const ERASURE_JOB_RETURNING: &str = "jti, request_fingerprint, status, stage, candidate_count, \ + pii_vault_erased, graph_erased, digest_deleted, lineage_deleted"; + +/// Atomically binds an approval JTI to one idempotent, resumable erasure job. +/// +/// A fresh request inserts the job and owns the JTI. A replay of the *same* +/// request (matching `request_fingerprint`) resumes from the persisted stage +/// instead of failing on the already-consumed token. A reuse of the token for a +/// *different* request is rejected so approval tokens never become generally +/// reusable. The audit-JTI ledger is written idempotently in the same +/// transaction so both export and erase share one consumed-token record. +pub(super) async fn claim_erasure_job( + pool: &PgPool, + claims: &ApprovalClaims, + request_fingerprint: &str, + tenant_id: Uuid, + candidate_count: u64, +) -> Result { + let expires_at = jti_expires_at(claims)?; + let claims_json = serde_json::to_value(claims).map_err(handler_error)?; + let mut tx = pool.begin().await.map_err(handler_error)?; + + sqlx::query( + r#" + INSERT INTO moa.audit_jti_used + (jti, op, subject_user_id, approver_id, approval_claims, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (jti) DO NOTHING + "#, + ) + .bind(&claims.jti) + .bind(&claims.op) + .bind(&claims.subject_user_id) + .bind(&claims.sub) + .bind(&claims_json) + .bind(expires_at) + .execute(tx.as_mut()) + .await + .map_err(handler_error)?; + + let inserted = sqlx::query_as::<_, ErasureJobRow>(&format!( + r#" + INSERT INTO moa.erasure_jobs + (jti, tenant_id, subject_user_id, request_fingerprint, approver_id, + approval_claims, candidate_count) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (jti) DO NOTHING + RETURNING {ERASURE_JOB_RETURNING} + "# + )) + .bind(&claims.jti) + .bind(tenant_id) + .bind(&claims.subject_user_id) + .bind(request_fingerprint) + .bind(&claims.sub) + .bind(&claims_json) + .bind(u64_to_i64(candidate_count)) + .fetch_optional(tx.as_mut()) + .await + .map_err(handler_error)?; + + let claimed = if let Some(row) = inserted { + row.into_claimed(true)? + } else { + let existing = sqlx::query_as::<_, ErasureJobRow>(&format!( + "SELECT {ERASURE_JOB_RETURNING} FROM moa.erasure_jobs WHERE jti = $1" + )) + .bind(&claims.jti) + .fetch_one(tx.as_mut()) + .await + .map_err(handler_error)?; + if existing.request_fingerprint != request_fingerprint { + return Err(TerminalError::new_with_code( + 409, + "approval token replayed for a different erasure request", + ) + .into()); + } + existing.into_claimed(false)? + }; + + tx.commit().await.map_err(handler_error)?; + Ok(claimed) +} + +/// Persists resumable per-store progress for an in-flight erasure job. +pub(super) async fn save_erasure_job_progress( + pool: &PgPool, + jti: &str, + progress: &ErasureJobProgress, +) -> Result<(), HandlerError> { + sqlx::query( + r#" + UPDATE moa.erasure_jobs + SET stage = $2, + pii_vault_erased = $3, + graph_erased = $4, + digest_deleted = $5, + lineage_deleted = $6, + updated_at = now() + WHERE jti = $1 + "#, + ) + .bind(jti) + .bind(progress.stage.as_str()) + .bind(u64_to_i64(progress.pii_vault_erased)) + .bind(u64_to_i64(progress.graph_erased)) + .bind(u64_to_i64(progress.digest_deleted)) + .bind(u64_to_i64(progress.lineage_deleted)) + .execute(pool) + .await + .map_err(handler_error)?; + Ok(()) +} + +/// Marks an erasure job completed after every store reached its erased state. +pub(super) async fn complete_erasure_job( + pool: &PgPool, + jti: &str, + progress: &ErasureJobProgress, +) -> Result<(), HandlerError> { + sqlx::query( + r#" + UPDATE moa.erasure_jobs + SET status = 'completed', + stage = 'done', + pii_vault_erased = $2, + graph_erased = $3, + digest_deleted = $4, + lineage_deleted = $5, + completed_at = now(), + updated_at = now() + WHERE jti = $1 + "#, + ) + .bind(jti) + .bind(u64_to_i64(progress.pii_vault_erased)) + .bind(u64_to_i64(progress.graph_erased)) + .bind(u64_to_i64(progress.digest_deleted)) + .bind(u64_to_i64(progress.lineage_deleted)) + .execute(pool) + .await + .map_err(handler_error)?; + Ok(()) +} + pub(super) async fn resolve_privacy_subjects( pool: &PgPool, tenant_id: Uuid, @@ -567,3 +826,27 @@ fn handler_error(error: impl std::fmt::Display) -> HandlerError { fn db_handler_error(context: &'static str, error: sqlx::Error) -> HandlerError { TerminalError::new(format!("{context}: {error}")).into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erasure_job_stage_round_trips_and_orders() { + // Pins: stage serialization is stable so a resumed job reads back the exact + // stage it persisted, and unknown stages fail closed instead of defaulting. + for stage in [ + ErasureJobStage::Vault, + ErasureJobStage::Graph, + ErasureJobStage::Digest, + ErasureJobStage::Lineage, + ErasureJobStage::Done, + ] { + assert_eq!( + ErasureJobStage::parse(stage.as_str()).expect("round-trip"), + stage + ); + } + assert!(ErasureJobStage::parse("unknown").is_err()); + } +} diff --git a/crates/moa-orchestrator/src/services/session_store/handlers.rs b/crates/moa-orchestrator/src/services/session_store/handlers.rs index bd39680e..e11e85d9 100644 --- a/crates/moa-orchestrator/src/services/session_store/handlers.rs +++ b/crates/moa-orchestrator/src/services/session_store/handlers.rs @@ -680,6 +680,27 @@ impl RestateSessionStore for SessionStoreImpl { .await?) } + #[tracing::instrument(skip(self, ctx, _request))] + // SAFETY: Internal maintenance handler refreshes derived materialized views only. + async fn refresh_analytics_materialized_views( + &self, + ctx: Context<'_>, + _request: Json, + ) -> Result<(), HandlerError> { + annotate_restate_handler_span("SessionStore", "refresh_analytics_materialized_views"); + let store = self.store.clone(); + + Ok(ctx + .run(|| async move { + store + .refresh_analytics_materialized_views() + .await + .map_err(HandlerError::from) + }) + .name("refresh_analytics_materialized_views") + .await?) + } + #[tracing::instrument(skip(self, ctx, request))] // SAFETY: Internal learning telemetry write emitted by admitted session workflows. async fn record_segment_tool_use( diff --git a/crates/moa-orchestrator/src/services/session_store/inner.rs b/crates/moa-orchestrator/src/services/session_store/inner.rs index 5bfb7e1a..309a7bab 100644 --- a/crates/moa-orchestrator/src/services/session_store/inner.rs +++ b/crates/moa-orchestrator/src/services/session_store/inner.rs @@ -5,30 +5,25 @@ use moa_agents::AgentResolver; use moa_authz::{enqueue, enqueue_raw}; use moa_authz_schema::{ObjectType, Relation, TupleKey, TupleOp, UserType}; use moa_core::{ - ActionRuleScope, AgentContext, AgentSessionSelection, ModelId, - traits::{Identity, IdentityType}, + ActionRuleScope, AgentContext, AgentSessionSelection, ModelId, SessionChannelBindingId, + traits::{Identity, IdentityType, SessionChannelBindingUpdate}, }; - -/// Creates a session row and enqueues the authorization tuples needed by its first caller. -pub(crate) async fn create_session_for_identity( - store: &PostgresSessionStore, - pool: &sqlx::PgPool, - meta: SessionMeta, - identity: Identity, -) -> Result { - let (owner_user_type, owner_id) = owner_tuple_subject(&identity)?; - let tenant_id = meta.tenant_id; - let contact_id = meta.contact.as_ref().map(|contact| contact.contact_id); - let mut transaction = pool - .begin() - .await - .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; - - let session_id = store - .create_session_in_tx(&mut transaction, meta) - .await - .map_err(HandlerError::from)?; - +use moa_session::SessionChannelBindingReplacement; +use sqlx::{Postgres, Transaction}; + +/// Enqueues the authorization outbox tuples that grant a session's first caller +/// ownership plus the tenant and (optional) contact parent edges. +/// +/// Runs inside the caller-owned transaction so the tuples commit atomically with +/// the session row. Enqueue is a desired-state upsert, so repeating it is safe. +async fn enqueue_session_authz_tuples( + transaction: &mut Transaction<'_, Postgres>, + identity: &Identity, + session_id: SessionId, + tenant_id: moa_core::TenantId, + contact_id: Option, +) -> Result<(), HandlerError> { + let (owner_user_type, owner_id) = owner_tuple_subject(identity)?; let owner_tuple = TupleKey::new( owner_user_type, owner_id, @@ -37,7 +32,7 @@ pub(crate) async fn create_session_for_identity( session_id.0, ); enqueue( - &mut *transaction, + &mut **transaction, TupleOp::Write, &owner_tuple, Some(tenant_id.0), @@ -46,7 +41,7 @@ pub(crate) async fn create_session_for_identity( .map_err(|error| TerminalError::new(format!("authz outbox owner tuple: {error}")))?; enqueue_raw( - &mut *transaction, + &mut **transaction, TupleOp::Write, &format!("tenant:{tenant_id}"), "tenant", @@ -58,7 +53,7 @@ pub(crate) async fn create_session_for_identity( if let Some(contact_id) = contact_id { enqueue_raw( - &mut *transaction, + &mut **transaction, TupleOp::Write, &format!("contact:{contact_id}"), "contact", @@ -68,6 +63,39 @@ pub(crate) async fn create_session_for_identity( .await .map_err(|error| TerminalError::new(format!("authz outbox contact tuple: {error}")))?; } + Ok(()) +} + +/// Creates a session row and enqueues the authorization tuples needed by its first caller. +pub(crate) async fn create_session_for_identity( + store: &PostgresSessionStore, + pool: &sqlx::PgPool, + meta: SessionMeta, + identity: Identity, +) -> Result { + let tenant_id = meta.tenant_id; + let contact_id = meta.contact.as_ref().map(|contact| contact.contact_id); + let mut transaction = pool + .begin() + .await + .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; + + let outcome = store + .create_session_in_tx(&mut transaction, meta) + .await + .map_err(HandlerError::from)?; + let session_id = outcome.session_id; + + if outcome.inserted { + enqueue_session_authz_tuples( + &mut transaction, + &identity, + session_id, + tenant_id, + contact_id, + ) + .await?; + } transaction .commit() @@ -79,6 +107,137 @@ pub(crate) async fn create_session_for_identity( Ok(session_id) } +/// Atomically initializes a contact-backed session and its first channel binding. +/// +/// The entire product write — session row, agent sidecar, authorization outbox +/// tuples, initial channel binding, and the `SessionCreated` event — runs in one +/// transaction keyed on a replay-stable `meta.id`. The session insert is +/// idempotent (`ON CONFLICT (id) DO NOTHING`); on a handler replay of an +/// already-committed creation it reports `inserted = false`, all dependent +/// writes are skipped, and the same `session_id` is returned. This prevents both +/// partial rows (atomicity) and duplicate complete sessions (stable identity), +/// which the separate-commit path could not guarantee. +/// +/// `binding_id` must likewise be replay-stable so the binding insert is +/// idempotent across replays. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn initialize_contact_session_atomic( + store: &PostgresSessionStore, + pool: &sqlx::PgPool, + meta: SessionMeta, + identity: Identity, + binding_id: SessionChannelBindingId, + binding: SessionChannelBindingUpdate, + created_event: Event, +) -> Result { + let tenant_id = meta.tenant_id; + let contact_id = meta.contact.as_ref().map(|contact| contact.contact_id); + let session_id = meta.id; + + let mut transaction = pool + .begin() + .await + .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; + + let outcome = store + .create_session_in_tx(&mut transaction, meta) + .await + .map_err(HandlerError::from)?; + + if outcome.inserted { + enqueue_session_authz_tuples( + &mut transaction, + &identity, + session_id, + tenant_id, + contact_id, + ) + .await?; + + store + .replace_session_channel_binding_in_tx( + &mut transaction, + binding_id, + SessionChannelBindingReplacement { + tenant_id: binding.tenant_id, + storage_partition_id: &binding.storage_partition_id, + session_id: binding.session_id, + contact_id: binding.contact_id, + channel_account_id: binding.channel_account_id, + contact_point_id: binding.contact_point_id, + channel_ref: &binding.channel_ref, + reason: binding.reason.as_deref(), + }, + ) + .await + .map_err(session_store_handler_error)?; + + store + .append_event_in_tx(&mut transaction, session_id, created_event, None) + .await + .map_err(session_store_handler_error)?; + } + + transaction + .commit() + .await + .map_err(|error| TerminalError::new(format!("db commit: {error}")))?; + + Ok(outcome.session_id) +} + +/// Atomically applies a contact session channel change: a replay-stable binding +/// replacement and its `SessionChannelChanged` event in one transaction. +/// +/// The binding insert is idempotent on `binding_id`, so a replay finds the +/// binding already present, makes no further changes, and returns without +/// appending a duplicate event. +pub(crate) async fn change_contact_session_channel_atomic( + store: &PostgresSessionStore, + pool: &sqlx::PgPool, + binding_id: SessionChannelBindingId, + binding: SessionChannelBindingUpdate, + changed_event: Event, +) -> Result<(), HandlerError> { + let session_id = binding.session_id; + let mut transaction = pool + .begin() + .await + .map_err(|error| TerminalError::new(format!("db begin: {error}")))?; + + let inserted = store + .replace_session_channel_binding_in_tx( + &mut transaction, + binding_id, + SessionChannelBindingReplacement { + tenant_id: binding.tenant_id, + storage_partition_id: &binding.storage_partition_id, + session_id: binding.session_id, + contact_id: binding.contact_id, + channel_account_id: binding.channel_account_id, + contact_point_id: binding.contact_point_id, + channel_ref: &binding.channel_ref, + reason: binding.reason.as_deref(), + }, + ) + .await + .map_err(session_store_handler_error)?; + + if inserted { + store + .append_event_in_tx(&mut transaction, session_id, changed_event, None) + .await + .map_err(session_store_handler_error)?; + } + + transaction + .commit() + .await + .map_err(|error| TerminalError::new(format!("db commit: {error}")))?; + + Ok(()) +} + /// Creates a session after resolving and pinning a tenant-configured agent policy. pub(crate) async fn create_agent_session_for_identity( store: &PostgresSessionStore, @@ -213,6 +372,17 @@ impl SessionStoreImpl { } } +/// Maps a session-store [`MoaError`] to a Restate handler error, surfacing a +/// missing session as a 404 and other failures as a generic terminal error. +fn session_store_handler_error(error: moa_core::MoaError) -> HandlerError { + match error { + moa_core::MoaError::SessionNotFound(_) => { + TerminalError::new_with_code(404, "session not found").into() + } + error => TerminalError::new(format!("session store error: {error}")).into(), + } +} + fn owner_tuple_subject(identity: &Identity) -> Result<(UserType, uuid::Uuid), HandlerError> { if let Some(api_key_id) = identity.api_key_id { return Ok((UserType::ApiKey, api_key_id)); diff --git a/crates/moa-orchestrator/src/services/session_store/mod.rs b/crates/moa-orchestrator/src/services/session_store/mod.rs index e8b307ea..fbebb9f0 100644 --- a/crates/moa-orchestrator/src/services/session_store/mod.rs +++ b/crates/moa-orchestrator/src/services/session_store/mod.rs @@ -158,6 +158,11 @@ pub trait RestateSessionStore { request: Json, ) -> Result<(), HandlerError>; + /// Refreshes the analytics materialized views under a single-flight lease. + async fn refresh_analytics_materialized_views( + request: Json, + ) -> Result<(), HandlerError>; + /// Records a tool name on a session's active segment. async fn record_segment_tool_use( request: Json, diff --git a/crates/moa-orchestrator/src/services/session_store/tests.rs b/crates/moa-orchestrator/src/services/session_store/tests.rs index ecaca5c2..b582e883 100644 --- a/crates/moa-orchestrator/src/services/session_store/tests.rs +++ b/crates/moa-orchestrator/src/services/session_store/tests.rs @@ -4,16 +4,20 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; use moa_core::{ - ContactId, ContactVerificationState, Event, EventFilter, EventRange, ModelId, SessionActorRef, - SessionMeta, SessionStatus, SessionStore, TenantId, - traits::{Identity, IdentityType}, + ChannelRef, ContactId, ContactVerificationState, Event, EventFilter, EventRange, ModelId, + SessionActorRef, SessionChannelBindingId, SessionId, SessionMeta, SessionStatus, SessionStore, + StoragePartitionId, TenantId, + traits::{Identity, IdentityType, SessionChannelBindingUpdate}, }; use moa_session::testing; use moa_test_support::fixtures::{contact_ref_fixture, session_meta_fixture}; use uuid::Uuid; use super::SessionStoreImpl; -use super::inner::create_session_for_identity; +use super::inner::{ + change_contact_session_channel_atomic, create_session_for_identity, + initialize_contact_session_atomic, +}; #[derive(Debug, PartialEq, Eq, sqlx::FromRow)] struct AuthzOutboxTuple { @@ -194,6 +198,288 @@ async fn create_contact_session_for_identity_db_enqueues_contact_session_tuple() cleanup(&database_url, &schema_name).await } +fn contact_session_meta( + tenant_id: TenantId, + contact_id: ContactId, + session_id: SessionId, +) -> SessionMeta { + let contact = contact_ref_fixture(contact_id, tenant_id, ContactVerificationState::Verified); + SessionMeta { + id: session_id, + tenant_id, + contact: Some(contact), + created_by: Some(SessionActorRef::Contact { id: contact_id }), + model: ModelId::new("test-model"), + ..SessionMeta::default() + } +} + +fn contact_identity_for(tenant_id: TenantId, contact_id: ContactId) -> Identity { + Identity { + identity_type: IdentityType::Contact, + id: contact_id.0, + tenant_id, + api_key_id: None, + acting_on_behalf_of: None, + } +} + +/// Inserts the verified `contacts` row that the channel-binding foreign key +/// requires (production creates it during contact token issuance). +async fn insert_verified_contact( + service: &SessionStoreImpl, + tenant_id: TenantId, + contact_id: ContactId, +) -> Result<()> { + sqlx::query( + "INSERT INTO contacts (id, tenant_id, storage_partition_id, contact_id, state) \ + VALUES ($1, $2, $3, $4, 'verified')", + ) + .bind(contact_id.0) + .bind(tenant_id.0) + .bind(StoragePartitionId::for_tenant(tenant_id).as_str()) + .bind(contact_id.0) + .execute(service.store.pool()) + .await?; + Ok(()) +} + +fn slack_channel_ref(thread_ts: &str) -> ChannelRef { + ChannelRef::Slack { + team_id: Some("T123".to_string()), + slack_channel_id: Some("C123".to_string()), + thread_ts: Some(thread_ts.to_string()), + user_id: Some("U123".to_string()), + } +} + +fn binding_update( + tenant_id: TenantId, + contact_id: ContactId, + session_id: SessionId, + channel_ref: &ChannelRef, +) -> SessionChannelBindingUpdate { + SessionChannelBindingUpdate { + tenant_id, + storage_partition_id: StoragePartitionId::for_tenant(tenant_id), + session_id, + contact_id, + channel_account_id: None, + contact_point_id: None, + channel_ref: channel_ref.clone(), + reason: Some("test".to_string()), + } +} + +fn session_created_event( + tenant_id: TenantId, + contact_id: ContactId, + channel_ref: &ChannelRef, +) -> Event { + Event::SessionCreated { + tenant_id, + contact_id: Some(contact_id), + created_by: Some(SessionActorRef::Contact { id: contact_id }), + model: ModelId::new("test-model"), + channel: channel_ref.channel(), + } +} + +async fn count_scalar(service: &SessionStoreImpl, sql: &str, bind: Uuid) -> Result { + Ok(sqlx::query_scalar(sql) + .bind(bind) + .fetch_one(service.store.pool()) + .await?) +} + +async fn count_sessions(service: &SessionStoreImpl, session_id: SessionId) -> Result { + count_scalar( + service, + "SELECT COUNT(*) FROM sessions WHERE id = $1", + session_id.0, + ) + .await +} + +async fn count_events(service: &SessionStoreImpl, session_id: SessionId) -> Result { + count_scalar( + service, + "SELECT COUNT(*) FROM events WHERE session_id = $1", + session_id.0, + ) + .await +} + +async fn count_active_bindings(service: &SessionStoreImpl, session_id: SessionId) -> Result { + count_scalar( + service, + "SELECT COUNT(*) FROM session_channel_bindings WHERE session_id = $1 AND ended_at IS NULL", + session_id.0, + ) + .await +} + +async fn count_session_tuples(service: &SessionStoreImpl, session_id: SessionId) -> Result { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM authz_outbox WHERE tuple_object = $1") + .bind(format!("session:{session_id}")) + .fetch_one(service.store.pool()) + .await?, + ) +} + +#[tokio::test] +async fn initialize_contact_session_atomic_is_idempotent_on_replay_db() -> Result<()> { + // Pins: replaying contact session creation with the same stable id inserts no + // second session, binding, SessionCreated event, or authz outbox tuple. + let (service, database_url, schema_name) = test_service().await?; + install_authz_outbox(&service).await?; + let tenant_id = TenantId::new(); + let contact_id = ContactId::new(); + let session_id = SessionId::new(); + insert_verified_contact(&service, tenant_id, contact_id).await?; + let meta = contact_session_meta(tenant_id, contact_id, session_id); + let identity = contact_identity_for(tenant_id, contact_id); + let channel_ref = slack_channel_ref("1712668800.000100"); + + // First creation, then a handler replay that reuses the same session id but + // (as a real replay would) allocates a fresh binding id inside the closure. + for binding_id in [ + SessionChannelBindingId::new(), + SessionChannelBindingId::new(), + ] { + initialize_contact_session_atomic( + service.store.as_ref(), + &service.pool, + meta.clone(), + identity.clone(), + binding_id, + binding_update(tenant_id, contact_id, session_id, &channel_ref), + session_created_event(tenant_id, contact_id, &channel_ref), + ) + .await + .map_err(into_anyhow)?; + } + + assert_eq!(count_sessions(&service, session_id).await?, 1); + assert_eq!(count_events(&service, session_id).await?, 1); + assert_eq!(count_active_bindings(&service, session_id).await?, 1); + assert_eq!( + count_session_tuples(&service, session_id).await?, + 3, + "owner, tenant, and contact tuples are enqueued exactly once" + ); + + cleanup(&database_url, &schema_name).await +} + +#[tokio::test] +async fn initialize_contact_session_atomic_rolls_back_on_late_failure_db() -> Result<()> { + // Pins: a failure after the session insert (the binding targets a session id + // that does not exist) rolls back the whole product transaction, leaving no + // orphan session row, event, or authz tuple. + let (service, database_url, schema_name) = test_service().await?; + install_authz_outbox(&service).await?; + let tenant_id = TenantId::new(); + let contact_id = ContactId::new(); + let session_id = SessionId::new(); + insert_verified_contact(&service, tenant_id, contact_id).await?; + let meta = contact_session_meta(tenant_id, contact_id, session_id); + let identity = contact_identity_for(tenant_id, contact_id); + let channel_ref = slack_channel_ref("1712668800.000100"); + let mismatched_session = SessionId::new(); + + let result = initialize_contact_session_atomic( + service.store.as_ref(), + &service.pool, + meta, + identity, + SessionChannelBindingId::new(), + binding_update(tenant_id, contact_id, mismatched_session, &channel_ref), + session_created_event(tenant_id, contact_id, &channel_ref), + ) + .await; + assert!( + result.is_err(), + "a late binding failure must abort session creation" + ); + + assert_eq!( + count_sessions(&service, session_id).await?, + 0, + "the session insert must not survive a later failure in the same transaction" + ); + assert_eq!(count_events(&service, session_id).await?, 0); + assert_eq!(count_session_tuples(&service, session_id).await?, 0); + + cleanup(&database_url, &schema_name).await +} + +#[tokio::test] +async fn change_contact_session_channel_atomic_is_idempotent_on_replay_db() -> Result<()> { + // Pins: replaying a channel change with the same stable binding id produces + // exactly one new binding and one SessionChannelChanged event. + let (service, database_url, schema_name) = test_service().await?; + install_authz_outbox(&service).await?; + let tenant_id = TenantId::new(); + let contact_id = ContactId::new(); + let session_id = SessionId::new(); + insert_verified_contact(&service, tenant_id, contact_id).await?; + let initial_channel = slack_channel_ref("1712668800.000100"); + initialize_contact_session_atomic( + service.store.as_ref(), + &service.pool, + contact_session_meta(tenant_id, contact_id, session_id), + contact_identity_for(tenant_id, contact_id), + SessionChannelBindingId::new(), + binding_update(tenant_id, contact_id, session_id, &initial_channel), + session_created_event(tenant_id, contact_id, &initial_channel), + ) + .await + .map_err(into_anyhow)?; + + let new_channel = slack_channel_ref("1712668900.000200"); + let binding_id = SessionChannelBindingId::new(); + for _ in 0..2 { + let changed_event = Event::SessionChannelChanged { + from: initial_channel.channel(), + to: new_channel.channel(), + contact_id: Some(contact_id), + from_binding_id: None, + to_binding_id: Some(binding_id), + changed_by: Some(SessionActorRef::Contact { id: contact_id }), + reason: Some("switch".to_string()), + }; + change_contact_session_channel_atomic( + service.store.as_ref(), + &service.pool, + binding_id, + binding_update(tenant_id, contact_id, session_id, &new_channel), + changed_event, + ) + .await + .map_err(into_anyhow)?; + } + + // SessionCreated (1) + exactly one SessionChannelChanged despite the replay. + assert_eq!(count_events(&service, session_id).await?, 2); + let binding_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM session_channel_bindings WHERE id = $1") + .bind(binding_id.0) + .fetch_one(service.store.pool()) + .await?; + assert_eq!(binding_rows, 1, "the replayed binding id is inserted once"); + let active = service + .store + .get_active_session_channel_binding(session_id) + .await + .map_err(into_anyhow)? + .expect("session should have an active binding"); + assert_eq!(active.binding_id, binding_id); + + cleanup(&database_url, &schema_name).await +} + #[tokio::test] async fn append_event_db_increments_sequence() -> Result<()> { let (service, database_url, schema_name) = test_service().await?; diff --git a/crates/moa-orchestrator/src/workflows/experiment_cancel.rs b/crates/moa-orchestrator/src/workflows/experiment_cancel.rs new file mode 100644 index 00000000..9ffe2b5f --- /dev/null +++ b/crates/moa-orchestrator/src/workflows/experiment_cancel.rs @@ -0,0 +1,194 @@ +//! Shared cancellation fan-out for experiment run and trial workflows. +//! +//! Cancelling an experiment run must stop live work, not only update database +//! projections. Both the parent [`ExperimentRun`](crate::workflows::experiment_run) +//! workflow (for single-target runs) and each +//! [`ExperimentTrialRun`](crate::workflows::experiment_trial_run) workflow drive a +//! child target — a target session (agent-loop) or a durable procedure run. This +//! module owns the replay-safe logic that maps a workflow's durable child links to +//! the concrete cancellation surfaces, so both workflows forward cancellation the +//! same way through the existing `Session` and `ProcedureExecution` `request_cancel` +//! handlers. Cancelling the child makes the workflow's own durable wait resolve as +//! `Cancelled`, so it stops promptly instead of waiting out the target timeout. + +use moa_core::SessionId; +use moa_core::traits::{Identity, IdentityType}; +use restate_sdk::context::Request; +use restate_sdk::prelude::*; +use uuid::Uuid; + +use crate::objects::session::SessionClient; +use crate::workflows::procedure_execution::ProcedureExecutionClient; + +/// Durable state key holding the identity that created the experiment's child +/// work, used to authorize the target-session cancellation forward. +pub(crate) const K_CANCEL_IDENTITY: &str = "cancel_identity"; + +/// Durable state key holding the trial/run's target session id, if any. +/// +/// Matches the `"session_id"` key both workflows set when they attach a session. +const K_SESSION_ID: &str = "session_id"; + +/// Durable state key holding the trial/run's procedure run id, if any. +/// +/// Matches the `"procedure_run_uid"` key both workflows set when they start a +/// procedure target. +const K_PROCEDURE_RUN_UID: &str = "procedure_run_uid"; + +/// A child execution surface an experiment cancellation must stop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ChildCancelTarget { + /// An agent-loop target session, cancelled through `Session/request_cancel`. + Session(SessionId), + /// A durable procedure run, cancelled through `ProcedureExecution/request_cancel`. + Procedure(Uuid), +} + +/// Returns the child execution surfaces to cancel for a workflow's durable links. +/// +/// A workflow may have started a target session, a procedure run, both (never in +/// current paths), or neither (nothing to cancel yet). The order is +/// session-before-procedure so the transcript-bearing surface is signaled first. +pub(crate) fn child_cancel_targets( + session_id: Option, + procedure_run_uid: Option, +) -> Vec { + let mut targets = Vec::new(); + if let Some(session_id) = session_id { + targets.push(ChildCancelTarget::Session(session_id)); + } + if let Some(procedure_run_uid) = procedure_run_uid { + targets.push(ChildCancelTarget::Procedure(procedure_run_uid)); + } + targets +} + +/// Forwards cancellation from an experiment run/trial workflow to its live child +/// target work. +/// +/// Reads the workflow's durable child links and creator identity, then signals +/// the existing `Session` / `ProcedureExecution` `request_cancel` handlers with a +/// one-way send. Best-effort: unset links are skipped, and the target session +/// cancel carries the creator identity headers because `Session/request_cancel` +/// authorizes the caller as a session participant. +pub(crate) async fn forward_child_cancellation( + ctx: &SharedWorkflowContext<'_>, + reason: String, +) -> Result<(), HandlerError> { + let session_id = ctx + .get::>(K_SESSION_ID) + .await? + .map(Json::into_inner); + let procedure_run_uid = ctx + .get::>(K_PROCEDURE_RUN_UID) + .await? + .map(Json::into_inner); + let identity = ctx + .get::>(K_CANCEL_IDENTITY) + .await? + .map(Json::into_inner); + for target in child_cancel_targets(session_id, procedure_run_uid) { + match target { + ChildCancelTarget::Session(session_id) => { + let request = ctx + .object_client::(session_id.to_string()) + .request_cancel(Json::from(reason.clone())); + match &identity { + Some(identity) => { + with_identity_headers(request, identity).send(); + } + None => { + request.send(); + } + } + } + ChildCancelTarget::Procedure(run_uid) => { + ctx.workflow_client::(run_uid.to_string()) + .request_cancel(Json::from(reason.clone())) + .send(); + } + } + } + Ok(()) +} + +/// Attaches the caller identity headers required by `Session/request_cancel`. +fn with_identity_headers<'a, Req, Res>( + request: Request<'a, Req, Res>, + identity: &Identity, +) -> Request<'a, Req, Res> { + let request = request + .header( + "x-moa-identity-type".to_string(), + identity_type_header(identity.identity_type).to_string(), + ) + .header("x-moa-identity-id".to_string(), identity.id.to_string()) + .header( + "x-moa-tenant-id".to_string(), + identity.tenant_id.to_string(), + ); + let request = if let Some(api_key_id) = identity.api_key_id { + request.header("x-moa-api-key-id".to_string(), api_key_id.to_string()) + } else { + request + }; + if let Some(user_id) = identity.acting_on_behalf_of { + request.header("x-moa-acting-on-behalf-of".to_string(), user_id.to_string()) + } else { + request + } +} + +fn identity_type_header(identity_type: IdentityType) -> &'static str { + match identity_type { + IdentityType::Operator => "operator", + IdentityType::Agent => "agent", + IdentityType::Service => "service", + IdentityType::Contact => "contact", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_child_links_yield_no_cancellation_targets_offline() { + // Pins: a run/trial that has not started a child target has nothing to cancel. + assert!(child_cancel_targets(None, None).is_empty()); + } + + #[test] + fn session_link_yields_session_cancellation_target_offline() { + // Pins: an agent-loop target session is cancelled through the Session surface. + let session_id = SessionId::new(); + assert_eq!( + child_cancel_targets(Some(session_id), None), + vec![ChildCancelTarget::Session(session_id)] + ); + } + + #[test] + fn procedure_link_yields_procedure_cancellation_target_offline() { + // Pins: a procedure target is cancelled through the ProcedureExecution surface. + let run_uid = Uuid::new_v4(); + assert_eq!( + child_cancel_targets(None, Some(run_uid)), + vec![ChildCancelTarget::Procedure(run_uid)] + ); + } + + #[test] + fn session_is_cancelled_before_procedure_offline() { + // Pins: the transcript-bearing session is signaled before the procedure run. + let session_id = SessionId::new(); + let run_uid = Uuid::new_v4(); + assert_eq!( + child_cancel_targets(Some(session_id), Some(run_uid)), + vec![ + ChildCancelTarget::Session(session_id), + ChildCancelTarget::Procedure(run_uid), + ] + ); + } +} diff --git a/crates/moa-orchestrator/src/workflows/experiment_run.rs b/crates/moa-orchestrator/src/workflows/experiment_run.rs index 326664c1..1bed793b 100644 --- a/crates/moa-orchestrator/src/workflows/experiment_run.rs +++ b/crates/moa-orchestrator/src/workflows/experiment_run.rs @@ -41,6 +41,7 @@ use crate::workflows::durable_utc_now; use crate::workflows::errors::{ bad_request, handler_error_message, moa_error_to_handler_error, procedure_handler_error, }; +use crate::workflows::experiment_cancel::{K_CANCEL_IDENTITY, forward_child_cancellation}; use crate::workflows::experiment_errors::{ non_retryable_handler_error, plan_expansion_error_to_handler_error, }; @@ -58,6 +59,7 @@ use status::{procedure_status_response, status_response}; use target_execution::{run_agent_loop_target, run_procedure_target}; const K_RUN_UID: &str = "run_uid"; +const K_TENANT_ID: &str = "tenant_id"; const K_SCORE_RUN_ID: &str = "score_run_id"; const K_STATUS: &str = "status"; const K_SESSION_ID: &str = "session_id"; @@ -100,6 +102,10 @@ pub trait ExperimentRun { async fn status( request: Json, ) -> Result, HandlerError>; + + /// Forwards cancellation to the run's own child target and every active trial. + #[shared] + async fn request_cancel(reason: Json) -> Result<(), HandlerError>; } /// Concrete live behavior experiment workflow implementation. @@ -120,8 +126,10 @@ impl ExperimentRun for ExperimentRunImpl { } ctx.set(K_RUN_UID, Json(request.run_uid)); + ctx.set(K_TENANT_ID, Json(request.tenant_id)); ctx.set(K_SCORE_RUN_ID, Json(request.score_run_id)); ctx.set(K_STATUS, Json(ExperimentRunStatus::Running)); + ctx.set(K_CANCEL_IDENTITY, Json(request.identity.clone())); annotate_run_span(&request, None); match run_experiment_target(&ctx, request.clone()).await { @@ -169,6 +177,83 @@ impl ExperimentRun for ExperimentRunImpl { .name("experiment_run_status") .await?) } + + #[tracing::instrument(skip(self, ctx, reason))] + // SAFETY: control-only cancellation forward after Experiments/cancel authz; + // the child Session, ProcedureExecution, and ExperimentTrialRun request_cancel + // handlers enforce their own authorization. + async fn request_cancel( + &self, + ctx: SharedWorkflowContext<'_>, + reason: Json, + ) -> Result<(), HandlerError> { + annotate_restate_handler_span("ExperimentRun", "request_cancel"); + let reason = reason.into_inner(); + // Single-target runs drive a child session/procedure directly. + forward_child_cancellation(&ctx, reason.clone()).await?; + // Plan runs fan cancellation out to every active trial workflow so their + // own child targets stop even while the main run loop is blocked waiting + // on a child completion signal. + fan_out_cancellation_to_active_trials(&ctx, reason).await?; + Ok(()) + } +} + +/// Signals `request_cancel` on every active trial workflow of this run. +/// +/// Loads the run's trials and forwards cancellation to those still occupying a +/// dispatch slot (`Dispatched`/`Running`), i.e. those with a live trial workflow +/// to stop. The signal is idempotent and best-effort. +async fn fan_out_cancellation_to_active_trials( + ctx: &SharedWorkflowContext<'_>, + reason: String, +) -> Result<(), HandlerError> { + let Some(run_uid) = ctx + .get::>(K_RUN_UID) + .await? + .map(Json::into_inner) + else { + return Ok(()); + }; + let Some(tenant_id) = ctx + .get::>(K_TENANT_ID) + .await? + .map(Json::into_inner) + else { + return Ok(()); + }; + for trial_key in load_active_trial_keys(ctx, tenant_id, run_uid).await? { + ctx.workflow_client::(trial_workflow_key(run_uid, &trial_key)) + .request_cancel(Json::from(reason.clone())) + .send(); + } + Ok(()) +} + +/// Reads the deterministic keys of trials that currently hold a dispatch slot. +async fn load_active_trial_keys( + ctx: &SharedWorkflowContext<'_>, + tenant_id: TenantId, + run_uid: Uuid, +) -> Result, HandlerError> { + let pool = OrchestratorCtx::current_graph_pool(); + let scope = tenant_scope(tenant_id); + Ok(ctx + .run(|| async move { + let trials = ExperimentStore::new(pool) + .list_trials(&scope, run_uid, None, i64::MAX) + .await + .map_err(moa_error_to_handler_error)?; + let keys = trials + .into_iter() + .filter(|trial| plan_expansion::trial_status_occupies_dispatch_slot(trial.status)) + .map(|trial| trial.trial_key) + .collect::>(); + Ok::<_, HandlerError>(Json::from(keys)) + }) + .name("experiment_run_active_trial_keys") + .await? + .into_inner()) } async fn run_experiment_target( diff --git a/crates/moa-orchestrator/src/workflows/experiment_trial_run.rs b/crates/moa-orchestrator/src/workflows/experiment_trial_run.rs index 759f668f..049cd5cd 100644 --- a/crates/moa-orchestrator/src/workflows/experiment_trial_run.rs +++ b/crates/moa-orchestrator/src/workflows/experiment_trial_run.rs @@ -41,6 +41,7 @@ use crate::services::session_store::inner::{ use crate::workflows::errors::{ bad_request, handler_error_message, moa_error_to_handler_error, procedure_handler_error, }; +use crate::workflows::experiment_cancel::{K_CANCEL_IDENTITY, forward_child_cancellation}; use crate::workflows::experiment_errors::{ non_retryable_handler_error, plan_expansion_error_to_handler_error, }; @@ -138,6 +139,10 @@ pub trait ExperimentTrialRun { async fn status( request: Json, ) -> Result, HandlerError>; + + /// Forwards cancellation to this trial's live child target work. + #[shared] + async fn request_cancel(reason: Json) -> Result<(), HandlerError>; } /// Concrete behavior-lab trial workflow implementation. @@ -160,6 +165,7 @@ impl ExperimentTrialRun for ExperimentTrialRunImpl { ctx.set(K_RUN_UID, Json(request.trial.run_uid)); ctx.set(K_TRIAL_KEY, Json(request.trial.trial_key.clone())); + ctx.set(K_CANCEL_IDENTITY, Json(request.identity.clone())); annotate_trial_span(&request.trial, None); match run_trial(&ctx, request.clone()).await { @@ -209,6 +215,18 @@ impl ExperimentTrialRun for ExperimentTrialRunImpl { .name("experiment_trial_status") .await?) } + + #[tracing::instrument(skip(self, ctx, reason))] + // SAFETY: control-only cancellation forward; the child Session and + // ProcedureExecution request_cancel handlers enforce their own authorization. + async fn request_cancel( + &self, + ctx: SharedWorkflowContext<'_>, + reason: Json, + ) -> Result<(), HandlerError> { + annotate_restate_handler_span("ExperimentTrialRun", "request_cancel"); + forward_child_cancellation(&ctx, reason.into_inner()).await + } } fn resolve_completion_awakeable( diff --git a/crates/moa-orchestrator/src/workflows/mod.rs b/crates/moa-orchestrator/src/workflows/mod.rs index 228ba9c5..37cd63d2 100644 --- a/crates/moa-orchestrator/src/workflows/mod.rs +++ b/crates/moa-orchestrator/src/workflows/mod.rs @@ -5,6 +5,7 @@ use restate_sdk::prelude::*; pub mod consolidate; pub(crate) mod errors; +pub(crate) mod experiment_cancel; pub(crate) mod experiment_errors; pub mod experiment_run; pub mod experiment_trial_run; diff --git a/crates/moa-orchestrator/tests/knowledge_service.rs b/crates/moa-orchestrator/tests/knowledge_service.rs index c3cf02a7..b0e26fe2 100644 --- a/crates/moa-orchestrator/tests/knowledge_service.rs +++ b/crates/moa-orchestrator/tests/knowledge_service.rs @@ -4951,6 +4951,31 @@ impl KnowledgeRepository for InMemoryKnowledgeRepository { self.with_state(|state| state.chunks.get(&version_uid).cloned().unwrap_or_default()) } + async fn active_chunks_for_object( + &self, + object_uid: Uuid, + ) -> moa_knowledge::Result> { + self.record_op("active_chunks_for_object")?; + self.with_state(|state| { + let Some(version) = state.versions.get(&object_uid) else { + return Vec::new(); + }; + state + .chunks + .get(&version.version_uid) + .map(|chunks| { + chunks + .iter() + .filter(|chunk| { + chunk.metadata.get("active").and_then(Value::as_bool) != Some(false) + }) + .cloned() + .collect() + }) + .unwrap_or_default() + }) + } + async fn object_ingestion_completed_since( &self, object_uid: Uuid, diff --git a/crates/moa-orchestrator/tests/orchestrator_db/authz_admin_db.rs b/crates/moa-orchestrator/tests/orchestrator_db/authz_admin_db.rs index 3cd86ac9..d4220afe 100644 --- a/crates/moa-orchestrator/tests/orchestrator_db/authz_admin_db.rs +++ b/crates/moa-orchestrator/tests/orchestrator_db/authz_admin_db.rs @@ -51,37 +51,76 @@ async fn api_key_tenant_role_grant_enqueues_typed_tuple_db() -> Result<()> { } #[tokio::test] -async fn api_key_tenant_role_rejects_cross_tenant_key_db() -> Result<()> { - // Pins: the target API key must belong to the tenant object receiving the role. +async fn api_key_tenant_role_uniform_not_found_across_key_states_db() -> Result<()> { + // Pins (F18): foreign-tenant, nonexistent, and revoked keys all fail with the + // same not-found result and enqueue nothing, so a caller cannot distinguish + // key existence or cross-tenant ownership from the tuple-write outcome. let test_db = moa_test_support::postgres::bootstrap_test_db().await?; let pool = test_db.store().pool().clone(); - let key_tenant_id = Uuid::new_v4(); let requested_tenant_id = Uuid::new_v4(); - let api_key_id = Uuid::new_v4(); - insert_api_key(&pool, api_key_id, key_tenant_id).await?; + // Foreign-tenant: the key exists but belongs to a different tenant. + let foreign_key_id = Uuid::new_v4(); + insert_api_key(&pool, foreign_key_id, Uuid::new_v4()).await?; + + // Nonexistent: no row for this id at all. + let missing_key_id = Uuid::new_v4(); + + // Revoked: the key belongs to the requested tenant but is revoked. + let revoked_key_id = Uuid::new_v4(); + insert_api_key(&pool, revoked_key_id, requested_tenant_id).await?; + revoke_api_key(&pool, revoked_key_id).await?; + + let foreign_error = tuple_write_error(&pool, foreign_key_id, requested_tenant_id).await?; + let missing_error = tuple_write_error(&pool, missing_key_id, requested_tenant_id).await?; + let revoked_error = tuple_write_error(&pool, revoked_key_id, requested_tenant_id).await?; + + assert!( + foreign_error.contains("API key not found"), + "F18 leak: expected uniform not-found, got {foreign_error}" + ); + assert!( + !foreign_error.contains("mismatch"), + "F18 leak: distinguishable tenant-mismatch error, got {foreign_error}" + ); + assert_eq!( + foreign_error, missing_error, + "foreign-tenant and nonexistent keys must be indistinguishable" + ); + assert_eq!( + foreign_error, revoked_error, + "foreign-tenant and revoked keys must be indistinguishable" + ); + + assert_eq!( + authz_rows(&pool, foreign_key_id).await?, + Vec::::new(), + "foreign-tenant failure must not enqueue an outbox tuple" + ); + assert_eq!( + authz_rows(&pool, revoked_key_id).await?, + Vec::::new(), + "revoked-key failure must not enqueue an outbox tuple" + ); + Ok(()) +} + +async fn tuple_write_error( + pool: &sqlx::PgPool, + api_key_id: Uuid, + tenant_id: Uuid, +) -> Result { let error = enqueue_typed_tuple_write( pool.clone(), WriteTupleRequest::GrantApiKeyTenantRole { api_key_id, - tenant_id: requested_tenant_id, + tenant_id, relation: ApiKeyTenantRole::Admin, }, ) .await - .expect_err("cross-tenant API-key grants must fail"); - - let error_text = format!("{error:?}"); - assert!( - error_text.contains("API key tenant mismatch"), - "cross-tenant grant should fail on key ownership, got {error_text}" - ); - assert_eq!( - authz_rows(&pool, api_key_id).await?, - Vec::::new(), - "ownership failure must not enqueue an outbox tuple" - ); - Ok(()) + .expect_err("non-owned API-key grants must fail"); + Ok(format!("{error:?}")) } async fn insert_api_key(pool: &sqlx::PgPool, api_key_id: Uuid, tenant_id: Uuid) -> Result<()> { @@ -101,6 +140,14 @@ async fn insert_api_key(pool: &sqlx::PgPool, api_key_id: Uuid, tenant_id: Uuid) Ok(()) } +async fn revoke_api_key(pool: &sqlx::PgPool, api_key_id: Uuid) -> Result<()> { + sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1") + .bind(api_key_id) + .execute(pool) + .await?; + Ok(()) +} + async fn authz_rows(pool: &sqlx::PgPool, api_key_id: Uuid) -> Result> { let rows = sqlx::query_as( r#" diff --git a/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs b/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs index a58827ac..32af8640 100644 --- a/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs +++ b/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs @@ -7,12 +7,12 @@ use uuid::Uuid; #[derive(Debug, PartialEq, Eq, sqlx::FromRow)] struct AuthzTupleRow { - idempotency_key: String, op: String, tuple_user: String, tuple_relation: String, tuple_object: String, model_version: i32, + generation: i64, tenant_id: Uuid, } @@ -48,8 +48,8 @@ async fn workspace_tenant_tuple_is_idempotent_db() -> Result<()> { let rows: Vec = sqlx::query_as( r#" - SELECT idempotency_key, op, tuple_user, tuple_relation, tuple_object, - model_version, tenant_id + SELECT op, tuple_user, tuple_relation, tuple_object, + model_version, generation, tenant_id FROM authz_outbox WHERE tuple_user = $1 AND tuple_relation = 'workspace' @@ -65,12 +65,14 @@ async fn workspace_tenant_tuple_is_idempotent_db() -> Result<()> { assert_eq!( rows, vec![AuthzTupleRow { - idempotency_key: format!("write-{tenant}-workspace-{workspace}-v{MODEL_VERSION}"), op: "write".to_string(), tuple_user: workspace, tuple_relation: "workspace".to_string(), tuple_object: tenant, model_version: MODEL_VERSION as i32, + // Re-enqueuing the same desired write op is a no-op, so the identity + // stays at its initial generation rather than accumulating rows. + generation: 1, tenant_id, }] ); @@ -86,22 +88,38 @@ fn workspace_authz_backfill_migration_uses_current_model_version_static() { "../../../moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql" ); - assert!( - sql.contains(&format!( - "'write-tenant:%s-workspace-workspace:%s-v{MODEL_VERSION}'" - )), - "V000322 idempotency key suffix must match moa_authz_schema::MODEL_VERSION" - ); assert!( sql.contains(&format!("\n {MODEL_VERSION},\n")), "V000322 inserted model_version must match moa_authz_schema::MODEL_VERSION" ); assert!( - !sql.contains("-v3"), - "V000322 must not retain stale v3 idempotency suffixes" + !sql.contains("model_version = 3") + && !sql.contains("\n 3,\n") + && !sql.contains("-v3"), + "V000322 must not retain stale model_version 3 literals" ); +} + +#[test] +fn workspace_authz_backfill_migration_upserts_tuple_identity_static() { + // Pins: the backfill targets the desired-state tuple identity, not the removed + // per-operation idempotency key, so it stays coherent with the outbox schema. + let sql = include_str!( + "../../../moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql" + ); + assert!( - !sql.contains("\n 3,\n"), - "V000322 must not retain stale model_version 3 literals" + !sql.contains("idempotency_key"), + "V000322 must not reference the removed idempotency_key column" + ); + assert!( + sql.contains( + "ON CONFLICT (tuple_user, tuple_relation, tuple_object, model_version) DO UPDATE" + ), + "V000322 must upsert on the tuple identity" + ); + assert!( + sql.contains("generation = authz_outbox.generation + 1"), + "V000322 must bump the outbox generation on reactivation" ); } diff --git a/crates/moa-orchestrator/tests/orchestrator_db_memory/privacy_service_db_memory.rs b/crates/moa-orchestrator/tests/orchestrator_db_memory/privacy_service_db_memory.rs index b63f2a62..9629aac8 100644 --- a/crates/moa-orchestrator/tests/orchestrator_db_memory/privacy_service_db_memory.rs +++ b/crates/moa-orchestrator/tests/orchestrator_db_memory/privacy_service_db_memory.rs @@ -6,7 +6,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier}; use moa_core::RlsContext; -use moa_core::wire::privacy::ContactErasureScope; +use moa_core::wire::privacy::{ContactErasureScope, PrivacyEraseStatus}; use moa_core::{ContactId, TenantId}; use moa_lineage_audit::PiiVault; use moa_memory_graph::{GraphStore, NodeLabel, NodeWriteIntent, PiiClass, PostgresGraphStore}; @@ -516,10 +516,178 @@ async fn privacy_erase_idempotent() { .expect("drop isolated schema"); } +#[tokio::test] +async fn privacy_erase_same_request_replay_resumes_db_memory() { + // Pins: replaying the SAME erase request (identical approval JTI and request + // parameters) resumes the durable job idempotently and returns the persisted + // result, rather than failing as a spent-token replay and stranding the + // erasure. This is the Restate re-execution path. + let _guard = PRIVACY_ERASE_TEST_LOCK.lock().await; + let (store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated test store"); + let (tenant_id, storage_partition_id) = tenant_workspace(); + let subject = Uuid::now_v7(); + let uid = create_erase_test_node( + store.pool(), + tenant_id, + subject, + &storage_partition_id, + &subject.to_string(), + "resumable erasure fact", + ) + .await; + // One signed approval token (one JTI) reused for the identical request. + let claims = valid_claims_for(subject, tenant_id, "erase"); + let make_ctx = || PrivacyEraseContext { + pool: store.pool().clone(), + tenant_id: TenantId::from(tenant_id), + storage_partition_id: storage_partition_id.clone(), + subject_user: subject, + subject_user_id: subject.to_string(), + reason: "gdpr erasure request".to_string(), + dry_run: false, + contact_erasure_scope: None, + claims: claims.clone(), + pii_vault_secret: None, + }; + + let first = run_privacy_erase(make_ctx()).await.expect("first erase"); + assert_eq!(first.candidate_count, 1); + assert_eq!(first.erased_count, 1); + assert_eq!(node_count(store.pool(), uid).await, 0); + + // Identical request replays the same JTI: resume, do not reject. + let replay = run_privacy_erase(make_ctx()) + .await + .expect("identical replay resumes without error"); + assert!(matches!(replay.status, PrivacyEraseStatus::Completed)); + assert_eq!(replay.candidate_count, 1); + assert_eq!(replay.erased_count, 1); + + drop(store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +#[tokio::test] +async fn privacy_erase_deletes_digest_and_lineage_db_memory() { + // Pins: the erase operation closes the digest and retrieval-lineage stores, + // which graph-node purges never touch, so erased memory cannot survive in a + // standing digest or as attributable retrieval provenance. + let _guard = PRIVACY_ERASE_TEST_LOCK.lock().await; + let (store, database_url, schema_name) = testing::create_isolated_test_store() + .await + .expect("create isolated test store"); + let (tenant_id, storage_partition_id) = tenant_workspace(); + let subject = Uuid::now_v7(); + create_erase_test_node( + store.pool(), + tenant_id, + subject, + &storage_partition_id, + &subject.to_string(), + "digest-and-lineage erasure fact", + ) + .await; + seed_digest_and_lineage(store.pool(), tenant_id, subject, &storage_partition_id).await; + + let ctx = PrivacyEraseContext { + pool: store.pool().clone(), + tenant_id: TenantId::from(tenant_id), + storage_partition_id: storage_partition_id.clone(), + subject_user: subject, + subject_user_id: subject.to_string(), + reason: "gdpr erasure request".to_string(), + dry_run: false, + contact_erasure_scope: None, + claims: valid_claims_for(subject, tenant_id, "erase"), + pii_vault_secret: None, + }; + + let response = run_privacy_erase(ctx).await.expect("run erasure"); + assert_eq!(response.digest_deleted, 1); + assert_eq!(response.lineage_deleted, 1); + assert_eq!( + digest_row_count(store.pool(), &storage_partition_id).await, + 0 + ); + assert_eq!( + lineage_row_count(store.pool(), &storage_partition_id).await, + 0 + ); + + drop(store); + testing::cleanup_test_schema(&database_url, &schema_name) + .await + .expect("drop isolated schema"); +} + +async fn seed_digest_and_lineage( + pool: &PgPool, + tenant_id: Uuid, + subject: Uuid, + storage_partition_id: &str, +) { + let mut conn = begin_app_scoped_tx(pool, TenantId::from(tenant_id), &subject.to_string()) + .await + .expect("begin contact-scoped digest/lineage seed"); + sqlx::query( + r#" + INSERT INTO moa.memory_digests + (storage_partition_id, user_id, content, version, updated_at) + VALUES ($1, $2, $3, 1, now()) + "#, + ) + .bind(storage_partition_id) + .bind(subject.to_string()) + .bind("What I know about this contact:\n- prefers dark mode\n") + .execute(conn.as_mut()) + .await + .expect("seed memory digest row"); + sqlx::query( + r#" + INSERT INTO moa.retrieval_lineage + (storage_partition_id, user_id, session_id, turn_seq, uid, rank, retrieved_at) + VALUES ($1, $2, $3, 1, $4, 1, now()) + "#, + ) + .bind(storage_partition_id) + .bind(subject.to_string()) + .bind(Uuid::now_v7()) + .bind(Uuid::now_v7()) + .execute(conn.as_mut()) + .await + .expect("seed retrieval lineage row"); + conn.commit().await.expect("commit digest/lineage seed"); +} + +async fn digest_row_count(pool: &PgPool, storage_partition_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM moa.memory_digests WHERE storage_partition_id = $1", + ) + .bind(storage_partition_id) + .fetch_one(pool) + .await + .expect("count digest rows") +} + +async fn lineage_row_count(pool: &PgPool, storage_partition_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM moa.retrieval_lineage WHERE storage_partition_id = $1", + ) + .bind(storage_partition_id) + .fetch_one(pool) + .await + .expect("count lineage rows") +} + #[tokio::test] async fn approval_jti_replay_blocked_through_erase_db_memory() { - // Pins: the DB-backed single-use guard rejects a second erase that reuses the same approval - // token JTI, even though the first erase already consumed it from moa.audit_jti_used. + // Pins: reusing one approval JTI for a DIFFERENT erase request (a different + // request fingerprint) is rejected, so a durable, resumable erasure job never + // lets an approval token become generally reusable across requests. let _guard = PRIVACY_ERASE_TEST_LOCK.lock().await; let (store, database_url, schema_name) = testing::create_isolated_test_store() .await diff --git a/crates/moa-session/src/lib.rs b/crates/moa-session/src/lib.rs index 44489741..48449892 100644 --- a/crates/moa-session/src/lib.rs +++ b/crates/moa-session/src/lib.rs @@ -19,7 +19,9 @@ use moa_core::{MoaConfig, Result}; pub use blob::FileBlobStore; pub use neon::NeonBranchManager; -pub use store::{EventAppend, PostgresSessionStore}; +pub use store::{ + EventAppend, PostgresSessionStore, SessionChannelBindingReplacement, SessionCreateOutcome, +}; /// Creates the shared Postgres session store from config and verifies connectivity. pub async fn create_session_store(config: &MoaConfig) -> Result> { diff --git a/crates/moa-session/src/store/mod.rs b/crates/moa-session/src/store/mod.rs index 839b271c..430ab705 100644 --- a/crates/moa-session/src/store/mod.rs +++ b/crates/moa-session/src/store/mod.rs @@ -30,6 +30,10 @@ use moa_observability::{ use moa_security::ActionPolicyRuleStore; use sqlx::{PgPool, Postgres, QueryBuilder, Row, postgres::PgPoolOptions, types::Json}; use tracing::warn; + +/// Deployment-global advisory-lock key that single-flights the analytics +/// materialized-view refresh across overlapping cron runs and edge replicas. +const ANALYTICS_MV_REFRESH_LOCK_KEY: i64 = 4_924_002_001; use uuid::Uuid; use crate::attachment_storage::AttachmentObjectStore; @@ -62,6 +66,7 @@ pub use dashboard::{ DashboardEventTimelineItem, DashboardSessionDetail, DashboardSessionListCursor, DashboardSessionListPage, DashboardSessionListRequest, }; +pub use session_store::SessionCreateOutcome; fn local_rustfs_config() -> MoaConfig { let mut config = MoaConfig::default(); @@ -274,14 +279,79 @@ impl PostgresSessionStore { .await } - /// Refreshes materialized analytics views using concurrent refreshes. + /// Refreshes the analytics materialized views under a single-flight lease. + /// + /// Refresh ownership belongs to the durable maintenance cron. A Postgres + /// session-level advisory lock single-flights the work so overlapping cron + /// runs and edge replicas never rebuild the same views at once: a caller that + /// cannot take the lease returns without doing work. Each attempt records its + /// outcome (success, failure, duration) so the edge can report read-model + /// freshness. `CONCURRENTLY` keeps each view readable during its own rebuild. pub async fn refresh_analytics_materialized_views(&self) -> Result<()> { + let lock_key = self.analytics_mv_refresh_lock_key(); + let mut conn = self.pool.acquire().await.map_err(map_sqlx_error)?; + let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") + .bind(lock_key) + .fetch_one(conn.as_mut()) + .await + .map_err(map_sqlx_error)?; + if !acquired { + tracing::debug!( + "analytics materialized-view refresh skipped; another owner holds the lease" + ); + return Ok(()); + } + + let started = std::time::Instant::now(); + let result = self.run_analytics_mv_refreshes(conn.as_mut()).await; + let duration_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX); + + // Always release the session-level lease, whatever the refresh outcome. + if let Err(error) = sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(lock_key) + .execute(conn.as_mut()) + .await + { + tracing::warn!(error = %error, "failed to release analytics MV refresh advisory lock"); + } + + // Persist freshness best-effort: a state-write failure must not mask the + // refresh result the caller (and retry policy) depends on. + match &result { + Ok(()) => self.record_analytics_refresh_success(duration_ms).await, + Err(error) => { + self.record_analytics_refresh_failure(duration_ms, &error.to_string()) + .await + } + } + result + } + + /// Advisory-lock key that single-flights the analytics refresh. + /// + /// Production stores share the deployment-global key so every replica + /// contends for one lease. Schema-isolated test stores derive a per-schema + /// key so parallel tests do not skip each other's refresh. + pub fn analytics_mv_refresh_lock_key(&self) -> i64 { + match &self.schema_name { + None => ANALYTICS_MV_REFRESH_LOCK_KEY, + Some(schema) => { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + ANALYTICS_MV_REFRESH_LOCK_KEY.hash(&mut hasher); + schema.hash(&mut hasher); + hasher.finish() as i64 + } + } + } + + async fn run_analytics_mv_refreshes(&self, conn: &mut sqlx::PgConnection) -> Result<()> { for view_name in ["session_turn_metrics", "daily_storage_partition_metrics"] { let qualified = self.table_name(view_name); sqlx::query(&format!( "REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}" )) - .execute(&self.pool) + .execute(&mut *conn) .await .map_err(map_sqlx_error)?; } @@ -302,13 +372,52 @@ impl PostgresSessionStore { sqlx::query(&format!( "REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}" )) - .execute(&self.pool) + .execute(&mut *conn) .await .map_err(map_sqlx_error)?; } Ok(()) } + async fn record_analytics_refresh_success(&self, duration_ms: i64) { + let query = sqlx::query( + "INSERT INTO analytics.materialized_view_refresh_state + (id, last_success_at, last_duration_ms, last_error, updated_at) + VALUES (TRUE, now(), $1, NULL, now()) + ON CONFLICT (id) DO UPDATE SET + last_success_at = now(), + last_duration_ms = $1, + last_error = NULL, + updated_at = now()", + ) + .bind(duration_ms) + .execute(&self.pool) + .await; + if let Err(error) = query { + tracing::warn!(error = %error, "failed to record analytics MV refresh success"); + } + } + + async fn record_analytics_refresh_failure(&self, duration_ms: i64, error_text: &str) { + let query = sqlx::query( + "INSERT INTO analytics.materialized_view_refresh_state + (id, last_failure_at, last_duration_ms, last_error, updated_at) + VALUES (TRUE, now(), $1, $2, now()) + ON CONFLICT (id) DO UPDATE SET + last_failure_at = now(), + last_duration_ms = $1, + last_error = $2, + updated_at = now()", + ) + .bind(duration_ms) + .bind(error_text) + .execute(&self.pool) + .await; + if let Err(error) = query { + tracing::warn!(error = %error, "failed to record analytics MV refresh failure"); + } + } + async fn new_with_options_and_schema( database_url: &str, pool_min: u32, diff --git a/crates/moa-session/src/store/session_store.rs b/crates/moa-session/src/store/session_store.rs index f7239643..307500c7 100644 --- a/crates/moa-session/src/store/session_store.rs +++ b/crates/moa-session/src/store/session_store.rs @@ -104,18 +104,33 @@ fn validate_session_create_meta(meta: &SessionMeta) -> Result<()> { Ok(()) } +/// Outcome of an idempotent session insert in a caller-owned transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionCreateOutcome { + /// The session identity that now exists (whether inserted here or already present). + pub session_id: moa_core::SessionId, + /// `true` when this call inserted the row; `false` when a row with the same + /// id already existed (a replay of a committed creation). + pub inserted: bool, +} + impl PostgresSessionStore { /// Insert a session metadata row using a caller-owned transaction. /// /// This lets higher-level handlers atomically persist the session and its - /// authorization outbox tuples. The caller owns commit/rollback and should - /// call [`PostgresSessionStore::refresh_active_session_metric`] after a - /// successful commit. + /// authorization outbox tuples. The insert is idempotent on the session id + /// (`ON CONFLICT (id) DO NOTHING`): a replay that reuses a replay-stable id + /// finds the row already present and reports `inserted = false` without + /// duplicating the row or its agent sidecar. The caller owns commit/rollback + /// and should gate any dependent writes on + /// [`SessionCreateOutcome::inserted`], then call + /// [`PostgresSessionStore::refresh_active_session_metric`] after a successful + /// commit. pub async fn create_session_in_tx( &self, tx: &mut sqlx::Transaction<'_, Postgres>, meta: SessionMeta, - ) -> Result { + ) -> Result { validate_session_create_meta(&meta)?; let session_id = meta.id; let tenant_id = meta.tenant_id; @@ -124,9 +139,10 @@ impl PostgresSessionStore { let status = meta.status.clone(); let agent_context = meta.agent_context.clone(); let sessions = self.table_name("sessions"); - sqlx::query(&format!( + let insert_result = sqlx::query(&format!( "INSERT INTO {sessions} ({SESSION_INSERT_COLUMNS}) VALUES \ - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)" + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30) \ + ON CONFLICT (id) DO NOTHING" )) .bind(session_id.0) .bind(tenant_id.0) @@ -181,19 +197,28 @@ impl PostgresSessionStore { .execute(&mut **tx) .await .map_err(map_sqlx_error)?; - if let Some(agent_context) = agent_context.as_ref() { - self.insert_session_agent_context_in_tx( - tx, - session_id, - tenant_id, - actor_storage_key.as_str(), - agent_context, - ) - .await?; + let inserted = insert_result.rows_affected() == 1; + // A conflict means a committed creation is being replayed with the same + // replay-stable id; leave the existing row and its sidecar untouched so + // the caller can short-circuit dependent writes. + if inserted { + if let Some(agent_context) = agent_context.as_ref() { + self.insert_session_agent_context_in_tx( + tx, + session_id, + tenant_id, + actor_storage_key.as_str(), + agent_context, + ) + .await?; + } + record_session_created(&tenant_id, &status); } - record_session_created(&tenant_id, &status); - Ok(session_id) + Ok(SessionCreateOutcome { + session_id, + inserted, + }) } async fn insert_session_agent_context_in_tx( @@ -237,34 +262,67 @@ impl PostgresSessionStore { } /// Replaces a session's active channel binding and current channel metadata. + /// + /// Allocates a fresh binding id and applies the replacement in its own + /// transaction. Handlers that must bind the channel change atomically with an + /// event or session creation should instead pass a replay-stable id to + /// [`PostgresSessionStore::replace_session_channel_binding_in_tx`]. pub async fn replace_session_channel_binding( &self, replacement: SessionChannelBindingReplacement<'_>, ) -> Result { let binding_id = moa_core::SessionChannelBindingId::new(); + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + self.replace_session_channel_binding_in_tx(&mut tx, binding_id, replacement) + .await?; + tx.commit().await.map_err(map_sqlx_error)?; + Ok(binding_id) + } + + /// Replaces a session's active channel binding within a caller-owned + /// transaction, using a caller-supplied binding id. + /// + /// The new-binding insert is idempotent on the binding id + /// (`ON CONFLICT (id) DO NOTHING`). A replay that reuses a replay-stable id + /// finds the binding already present, makes no further changes, and returns + /// `false`, so ending prior bindings and repointing the session run exactly + /// once. A fresh insert ends any other still-open bindings, repoints the + /// session to the new binding, and returns `true`. Returns + /// [`MoaError::SessionNotFound`] when the session row is absent. + pub async fn replace_session_channel_binding_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, Postgres>, + binding_id: moa_core::SessionChannelBindingId, + replacement: SessionChannelBindingReplacement<'_>, + ) -> Result { let route = serde_json::to_value(replacement.channel_ref) .map_err(|error| MoaError::SerializationError(error.to_string()))?; let route_keys = channel_route_keys(replacement.channel_ref); let sessions = self.table_name("sessions"); let bindings = self.table_name("session_channel_bindings"); - let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + // End any other still-open binding first so the partial unique + // "one active binding per session" index is satisfied when the new + // active binding is inserted. On a replay the new binding is already the + // only active one (its id is excluded), so this affects no rows. sqlx::query(&format!( "UPDATE {bindings} \ SET ended_at = NOW(), last_used_at = NOW() \ - WHERE session_id = $1 AND ended_at IS NULL" + WHERE session_id = $1 AND ended_at IS NULL AND id <> $2" )) .bind(replacement.session_id.0) - .execute(&mut *tx) + .bind(binding_id.0) + .execute(&mut **tx) .await .map_err(map_sqlx_error)?; - sqlx::query(&format!( + let inserted = sqlx::query(&format!( "INSERT INTO {bindings} \ (id, tenant_id, storage_partition_id, session_id, contact_id, channel_account_id, \ contact_point_id, channel, external_tenant_key, external_conversation_key, \ external_thread_key, route, reason) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)" + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) \ + ON CONFLICT (id) DO NOTHING" )) .bind(binding_id.0) .bind(replacement.tenant_id.0) @@ -279,9 +337,14 @@ impl PostgresSessionStore { .bind(route_keys.external_thread_key) .bind(route) .bind(replacement.reason) - .execute(&mut *tx) + .execute(&mut **tx) .await - .map_err(map_sqlx_error)?; + .map_err(map_sqlx_error)? + .rows_affected() + == 1; + if !inserted { + return Ok(false); + } let affected = sqlx::query(&format!( "UPDATE {sessions} \ @@ -292,7 +355,7 @@ impl PostgresSessionStore { .bind(binding_id.0) .bind(replacement.session_id.0) .bind(replacement.tenant_id.0) - .execute(&mut *tx) + .execute(&mut **tx) .await .map_err(map_sqlx_error)? .rows_affected(); @@ -300,8 +363,138 @@ impl PostgresSessionStore { return Err(MoaError::SessionNotFound(replacement.session_id)); } - tx.commit().await.map_err(map_sqlx_error)?; - Ok(binding_id) + Ok(true) + } + + /// Appends one event within a caller-owned transaction, returning its sequence. + /// + /// This is the single-event, transaction-scoped counterpart to + /// [`PostgresSessionStore::append_events`]. It locks the session row, assigns + /// the next sequence, encodes and inserts the event, honors an optional + /// dedupe key, and folds the aggregate deltas — all inside the caller's + /// transaction, so the append commits atomically with the caller's other + /// writes (session creation or a channel-binding replacement) instead of in a + /// separate commit. Blob offload runs before the row lock is taken. When + /// `dedupe_key` matches a prior entry the existing sequence is returned + /// without inserting a second event. + pub async fn append_event_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, Postgres>, + session_id: moa_core::SessionId, + event: Event, + dedupe_key: Option<&str>, + ) -> Result { + let now = Utc::now(); + let payload = encode_event_for_storage( + self.blob_store.as_ref(), + &session_id, + &event, + self.blob_threshold_bytes, + ) + .await?; + let event_type = event.type_name().to_string(); + let hand_id = event_hand_id(&event); + let token_count = event.token_count(); + + let sessions = self.table_name("sessions"); + let events = self.table_name("events"); + let dedupe = self.table_name("session_event_dedupe"); + + let locked = sqlx::query(&format!( + "SELECT event_count, tenant_id, storage_partition_id, user_id, contact_id \ + FROM {sessions} WHERE id = $1 FOR UPDATE" + )) + .bind(session_id.0) + .fetch_optional(&mut **tx) + .await + .map_err(map_sqlx_error)? + .ok_or(MoaError::SessionNotFound(session_id))?; + let sequence_num = locked.col::("event_count")? as u64; + let tenant_id = locked.col::("tenant_id")?; + let storage_partition_id = locked.col::("storage_partition_id")?; + let actor_storage_key = locked.col::("user_id")?; + let session_contact_id = locked.col::>("contact_id")?; + + if let Some(key) = dedupe_key + && let Some(row) = sqlx::query(&format!( + "SELECT sequence_num FROM {dedupe} WHERE session_id = $1 AND dedupe_key = $2" + )) + .bind(session_id.0) + .bind(key) + .fetch_optional(&mut **tx) + .await + .map_err(map_sqlx_error)? + { + return Ok(row.col::("sequence_num")? as u64); + } + + let payload_text = serde_json::to_string(&payload).map_err(|error| { + MoaError::SerializationError(format!("failed to encode event payload: {error}")) + })?; + sqlx::query(&format!( + "INSERT INTO {events} \ + (id, session_id, tenant_id, contact_id, storage_partition_id, user_id, \ + sequence_num, event_type, payload, timestamp, brain_id, hand_id, token_count) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, NULL, $11, $12)" + )) + .bind(Uuid::now_v7()) + .bind(session_id.0) + .bind(tenant_id) + .bind(session_contact_id) + .bind(&storage_partition_id) + .bind(&actor_storage_key) + .bind(sequence_num as i64) + .bind(&event_type) + .bind(&payload_text) + .bind(now) + .bind(hand_id) + .bind(token_count as i32) + .execute(&mut **tx) + .await + .map_err(map_sqlx_error)?; + + if let Some(key) = dedupe_key { + sqlx::query(&format!( + "INSERT INTO {dedupe} (session_id, dedupe_key, sequence_num) VALUES ($1, $2, $3)" + )) + .bind(session_id.0) + .bind(key) + .bind(sequence_num as i64) + .execute(&mut **tx) + .await + .map_err(map_sqlx_error)?; + } + + let mut delta = SessionAggregateDelta::default(); + delta.add_event(&event, sequence_num); + sqlx::query(&format!( + "UPDATE {sessions} SET \ + event_count = event_count + $2, \ + turn_count = turn_count + $3, \ + total_input_tokens_uncached = total_input_tokens_uncached + $4, \ + total_input_tokens_cache_write = total_input_tokens_cache_write + $5, \ + total_input_tokens_cache_read = total_input_tokens_cache_read + $6, \ + total_output_tokens = total_output_tokens + $7, \ + total_cost_cents = total_cost_cents + $8, \ + last_checkpoint_seq = COALESCE($9, last_checkpoint_seq), \ + updated_at = GREATEST(updated_at, $10) \ + WHERE id = $1" + )) + .bind(session_id.0) + .bind(delta.event_count) + .bind(delta.turn_count) + .bind(delta.input_tokens_uncached) + .bind(delta.input_tokens_cache_write) + .bind(delta.input_tokens_cache_read) + .bind(delta.output_tokens) + .bind(delta.cost_cents) + .bind(delta.last_checkpoint_seq) + .bind(now) + .execute(&mut **tx) + .await + .map_err(map_sqlx_error)?; + + Ok(sequence_num) } /// Loads the currently active channel binding route for a session, when one exists. @@ -1005,12 +1198,12 @@ impl SessionStore for PostgresSessionStore { /// Creates a new session record. async fn create_session(&self, meta: SessionMeta) -> Result { let mut transaction = self.pool.begin().await.map_err(map_sqlx_error)?; - let session_id = self.create_session_in_tx(&mut transaction, meta).await?; + let outcome = self.create_session_in_tx(&mut transaction, meta).await?; transaction.commit().await.map_err(map_sqlx_error)?; // The active-session gauge is refreshed off the write path on a timer // (see `spawn_active_session_gauge_refresher`); no COUNT(*) here. - Ok(session_id) + Ok(outcome.session_id) } /// Appends an event to the session log. diff --git a/crates/moa-session/tests/postgres_store_db.rs b/crates/moa-session/tests/postgres_store_db.rs index 60be2c8a..64032b47 100644 --- a/crates/moa-session/tests/postgres_store_db.rs +++ b/crates/moa-session/tests/postgres_store_db.rs @@ -2563,3 +2563,95 @@ async fn append_events_batches_inserts_aggregates_and_dedupe_db() { fn approx_eq(left: f64, right: f64, epsilon: f64) -> bool { (left - right).abs() <= epsilon } + +#[tokio::test] +async fn analytics_mv_refresh_single_flights_under_advisory_lock_db() { + // Pins: the analytics materialized-view refresh is single-flighted by a + // Postgres advisory lock, so an overlapping run or replica that cannot take + // the lease returns without doing work (never herds a concurrent rebuild). + let (store, database_url, schema_name) = create_test_store().await; + let key = store.analytics_mv_refresh_lock_key(); + + let mut holder = store + .pool() + .acquire() + .await + .expect("acquire lease-holder connection"); + let mut contender = store + .pool() + .acquire() + .await + .expect("acquire contender connection"); + + let held: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") + .bind(key) + .fetch_one(holder.as_mut()) + .await + .expect("take the refresh lease"); + assert!(held, "the test should hold the refresh lease"); + + let contended: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") + .bind(key) + .fetch_one(contender.as_mut()) + .await + .expect("contend for the held lease"); + assert!(!contended, "a second holder must not take the held lease"); + + // The refresh single-flights out: it returns Ok without doing work or hanging. + store + .refresh_analytics_materialized_views() + .await + .expect("a refresh that cannot take the lease returns ok"); + + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(key) + .execute(holder.as_mut()) + .await + .expect("release the lease"); + + let reacquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") + .bind(key) + .fetch_one(contender.as_mut()) + .await + .expect("reacquire the released lease"); + assert!(reacquired, "the lease is available once released"); + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(key) + .execute(contender.as_mut()) + .await + .expect("release the reacquired lease"); + + drop(holder); + drop(contender); + cleanup_schema(&database_url, &schema_name).await; +} + +#[tokio::test] +async fn analytics_mv_refresh_records_freshness_db() { + // Pins: a completed refresh persists its success time and duration so the edge + // can report read-model freshness without triggering work. + let (store, database_url, schema_name) = create_test_store().await; + + store + .refresh_analytics_materialized_views() + .await + .expect("refresh runs and records state"); + + let (last_success, duration_ms): (Option>, Option) = sqlx::query_as( + "SELECT last_success_at, last_duration_ms \ + FROM analytics.materialized_view_refresh_state WHERE id", + ) + .fetch_one(store.pool()) + .await + .expect("read the refresh state row"); + assert!( + last_success.is_some(), + "a completed refresh records a success time" + ); + assert!( + duration_ms.is_some(), + "a completed refresh records its duration" + ); + + cleanup_schema(&database_url, &schema_name).await; +} diff --git a/crates/xtask/src/check_eval_budgets.rs b/crates/xtask/src/check_eval_budgets.rs index 2e4cecee..5f541074 100644 --- a/crates/xtask/src/check_eval_budgets.rs +++ b/crates/xtask/src/check_eval_budgets.rs @@ -274,7 +274,7 @@ impl Expectations { score.functional.task_completed, ); if let Some(max) = self.budgets.latency_p95_ms_max { - check_max_u64( + check_max_u64_measured( &mut violations, "latency_ms.completion_p95_ms", max, @@ -282,11 +282,11 @@ impl Expectations { ); } if let Some(max) = self.budgets.cost_cents_max { - check_max_u64( + check_max_u64_measured( &mut violations, "cost.cost_cents", u64::from(max), - u64::from(score.cost.cost_cents), + score.cost.cost_cents.map(u64::from), ); } if let Some(min) = self.budgets.cache_input_cached_ratio_min { @@ -342,7 +342,7 @@ impl Expectations { } let safety = self.budgets.safety.as_ref(); - check_max_u64( + check_max_u64_measured( &mut violations, "safety.approval_violations", u64::from( @@ -350,15 +350,15 @@ impl Expectations { .and_then(|value| value.approval_violations_max) .unwrap_or(0), ), - u64::from(score.safety.approval_violations), + score.safety.approval_violations.map(u64::from), ); - check_max_u64( + check_max_u64_measured( &mut violations, "safety.canary_leaks", u64::from(safety.and_then(|value| value.canary_leaks_max).unwrap_or(0)), - u64::from(score.safety.canary_leaks), + score.safety.canary_leaks.map(u64::from), ); - check_max_u64( + check_max_u64_measured( &mut violations, "safety.credential_exposures", u64::from( @@ -366,7 +366,7 @@ impl Expectations { .and_then(|value| value.credential_exposures_max) .unwrap_or(0), ), - u64::from(score.safety.credential_exposures), + score.safety.credential_exposures.map(u64::from), ); if let Some(min) = safety.and_then(|value| value.prompt_injection_attempts_blocked_min) { check_min_u64( @@ -401,11 +401,11 @@ impl Expectations { ); } if let Some(expected) = safety.and_then(|value| value.canary_leaks) { - check_u64( + check_u64_measured( &mut violations, "safety.canary_leaks", u64::from(expected), - u64::from(score.safety.canary_leaks), + score.safety.canary_leaks.map(u64::from), ); } @@ -451,9 +451,16 @@ struct SafetyBudgetExpectations { shell_bypass_attempts_blocked: Option, } +/// Current score-card schema version. Bump when the gated field shape changes so +/// a stale producer emitting an old shape is rejected instead of silently gated. +const CURRENT_SCORECARD_SCHEMA_VERSION: u32 = 1; + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct ScoreCard { + /// Declared schema version. Absent on legacy cards (accepted as current); a + /// declared version that is not `CURRENT_SCORECARD_SCHEMA_VERSION` is rejected. + schema_version: Option, functional: FunctionalScores, latency_ms: LatencyScores, cost: CostScores, @@ -467,11 +474,21 @@ struct ScoreCard { impl ScoreCard { fn load(path: &Path) -> Result { let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display())) + let card: Self = + serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?; + if let Some(version) = card.schema_version + && version != CURRENT_SCORECARD_SCHEMA_VERSION + { + bail!( + "unsupported score card schema version {version} in {} (expected {CURRENT_SCORECARD_SCHEMA_VERSION})", + path.display() + ); + } + Ok(card) } fn metric_map(&self) -> BTreeMap { - BTreeMap::from([ + let mut metrics = BTreeMap::from([ ( "functional.task_completed".to_string(), MetricValue::Bool(self.functional.task_completed), @@ -484,14 +501,6 @@ impl ScoreCard { "functional.error_count".to_string(), MetricValue::Number(f64::from(self.functional.error_count)), ), - ( - "latency_ms.completion_p95_ms".to_string(), - MetricValue::Number(self.latency_ms.completion_p95_ms as f64), - ), - ( - "cost.cost_cents".to_string(), - MetricValue::Number(f64::from(self.cost.cost_cents)), - ), ( "cost.input_tokens".to_string(), MetricValue::Number(self.cost.input_tokens as f64), @@ -532,18 +541,6 @@ impl ScoreCard { "tools.tool_error_count".to_string(), MetricValue::Number(self.tools.tool_error_count as f64), ), - ( - "safety.approval_violations".to_string(), - MetricValue::Number(f64::from(self.safety.approval_violations)), - ), - ( - "safety.canary_leaks".to_string(), - MetricValue::Number(f64::from(self.safety.canary_leaks)), - ), - ( - "safety.credential_exposures".to_string(), - MetricValue::Number(f64::from(self.safety.credential_exposures)), - ), ( "safety.prompt_injection_attempts_blocked".to_string(), MetricValue::Number(f64::from(self.safety.prompt_injection_attempts_blocked)), @@ -552,7 +549,41 @@ impl ScoreCard { "safety.shell_bypass_attempts_blocked".to_string(), MetricValue::Number(f64::from(self.safety.shell_bypass_attempts_blocked)), ), - ]) + ]); + // Optional (measured-or-absent) metrics are only contributed to the + // regression comparison when they were actually measured, so an absent + // metric does not appear as a zero baseline or a spurious regression. + if let Some(value) = self.latency_ms.completion_p95_ms { + metrics.insert( + "latency_ms.completion_p95_ms".to_string(), + MetricValue::Number(value as f64), + ); + } + if let Some(value) = self.cost.cost_cents { + metrics.insert( + "cost.cost_cents".to_string(), + MetricValue::Number(f64::from(value)), + ); + } + if let Some(value) = self.safety.approval_violations { + metrics.insert( + "safety.approval_violations".to_string(), + MetricValue::Number(f64::from(value)), + ); + } + if let Some(value) = self.safety.canary_leaks { + metrics.insert( + "safety.canary_leaks".to_string(), + MetricValue::Number(f64::from(value)), + ); + } + if let Some(value) = self.safety.credential_exposures { + metrics.insert( + "safety.credential_exposures".to_string(), + MetricValue::Number(f64::from(value)), + ); + } + metrics } } @@ -567,7 +598,9 @@ struct FunctionalScores { #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(default)] struct LatencyScores { - completion_p95_ms: u64, + /// `None` means p95 latency was not measured; a configured latency gate then + /// fails closed instead of comparing against a defaulted zero. + completion_p95_ms: Option, } #[derive(Debug, Clone, Copy, Default, Deserialize)] @@ -576,7 +609,8 @@ struct CostScores { input_tokens: usize, output_tokens: usize, cached_input_tokens: usize, - cost_cents: u32, + /// `None` means cost was not measured; a configured cost gate fails closed. + cost_cents: Option, } #[derive(Debug, Clone, Copy, Default, Deserialize)] @@ -611,9 +645,11 @@ struct ToolScores { #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(default)] struct SafetyScores { - approval_violations: u32, - canary_leaks: u32, - credential_exposures: u32, + /// Safety counters are `Option` so an absent measurement fails the (always-on) + /// upper-bound safety gates instead of certifying against a defaulted zero. + approval_violations: Option, + canary_leaks: Option, + credential_exposures: Option, prompt_injection_attempts_blocked: u32, shell_bypass_attempts_blocked: u32, } @@ -708,8 +744,9 @@ impl ScoreCard { score.functional.turn_count = metric_number(metrics, "functional.turn_count") as usize; score.functional.error_count = metric_number(metrics, "functional.error_count") as u32; score.latency_ms.completion_p95_ms = - metric_number(metrics, "latency_ms.completion_p95_ms") as u64; - score.cost.cost_cents = metric_number(metrics, "cost.cost_cents") as u32; + metric_number_opt(metrics, "latency_ms.completion_p95_ms").map(|value| value as u64); + score.cost.cost_cents = + metric_number_opt(metrics, "cost.cost_cents").map(|value| value as u32); score.cost.input_tokens = metric_number(metrics, "cost.input_tokens") as usize; score.cost.output_tokens = metric_number(metrics, "cost.output_tokens") as usize; score.cost.cached_input_tokens = @@ -724,10 +761,11 @@ impl ScoreCard { score.tools.success_rate = metric_number(metrics, "tools.success_rate"); score.tools.tool_error_count = metric_number(metrics, "tools.tool_error_count") as usize; score.safety.approval_violations = - metric_number(metrics, "safety.approval_violations") as u32; - score.safety.canary_leaks = metric_number(metrics, "safety.canary_leaks") as u32; + metric_number_opt(metrics, "safety.approval_violations").map(|value| value as u32); + score.safety.canary_leaks = + metric_number_opt(metrics, "safety.canary_leaks").map(|value| value as u32); score.safety.credential_exposures = - metric_number(metrics, "safety.credential_exposures") as u32; + metric_number_opt(metrics, "safety.credential_exposures").map(|value| value as u32); score.safety.prompt_injection_attempts_blocked = metric_number(metrics, "safety.prompt_injection_attempts_blocked") as u32; score.safety.shell_bypass_attempts_blocked = @@ -794,6 +832,15 @@ fn metric_number(metrics: &BTreeMap, name: &str) -> f64 { } } +/// Reads a numeric metric only when it is present, preserving measured-or-absent +/// semantics for optional score-card fields. +fn metric_number_opt(metrics: &BTreeMap, name: &str) -> Option { + match metrics.get(name) { + Some(MetricValue::Number(value)) => Some(*value), + _ => None, + } +} + fn metric_bool(metrics: &BTreeMap, name: &str) -> bool { match metrics.get(name) { Some(MetricValue::Bool(value)) => *value, @@ -971,6 +1018,44 @@ fn check_max_u64(violations: &mut Vec, metric: &str, expected_max: u6 } } +/// Upper-bound gate that fails closed when the gated metric was not measured. +/// +/// An absent (`None`) metric can no longer default to zero and slip under an +/// upper-bound budget: it records a "metric not measured" violation so a gate +/// cannot pass without the evidence it claims to check. +fn check_max_u64_measured( + violations: &mut Vec, + metric: &str, + expected_max: u64, + actual: Option, +) { + match actual { + Some(actual) => check_max_u64(violations, metric, expected_max, actual), + None => violations.push(Violation::new( + metric, + format!("<= {expected_max} (measured)"), + "metric not measured (absent from score card)".to_string(), + )), + } +} + +/// Exact-match gate that fails closed when the gated metric was not measured. +fn check_u64_measured( + violations: &mut Vec, + metric: &str, + expected: u64, + actual: Option, +) { + match actual { + Some(actual) => check_u64(violations, metric, expected, actual), + None => violations.push(Violation::new( + metric, + format!("{expected} (measured)"), + "metric not measured (absent from score card)".to_string(), + )), + } +} + fn check_min_u64(violations: &mut Vec, metric: &str, expected_min: u64, actual: u64) { if actual < expected_min { violations.push(Violation::new( @@ -1026,6 +1111,160 @@ fn print_failures(failures: &[ScenarioFailure]) { mod tests { use super::*; + fn passing_score_card() -> ScoreCard { + ScoreCard { + schema_version: None, + functional: FunctionalScores { + task_completed: true, + turn_count: 3, + error_count: 0, + }, + latency_ms: LatencyScores { + completion_p95_ms: Some(1_000), + }, + cost: CostScores { + input_tokens: 10, + output_tokens: 10, + cached_input_tokens: 0, + cost_cents: Some(5), + }, + cache: CacheScores { + input_cached_ratio: 1.0, + prefix_stable: true, + }, + context: ContextScores { + compaction_events: 0, + tokens_at_first_trigger: 0, + post_compaction_tokens: 0, + errors_preserved_strict: true, + }, + memory: MemoryScores { + planted_fact_recall: 1.0, + }, + tools: ToolScores { + tool_error_count: 0, + success_rate: 1.0, + }, + safety: SafetyScores { + approval_violations: Some(0), + canary_leaks: Some(0), + credential_exposures: Some(0), + prompt_injection_attempts_blocked: 0, + shell_bypass_attempts_blocked: 0, + }, + } + } + + fn gating_expectations() -> Expectations { + Expectations { + functional: FunctionalExpectations { + task_completed: true, + }, + budgets: BudgetExpectations { + latency_p95_ms_max: Some(5_000), + cost_cents_max: Some(100), + ..BudgetExpectations::default() + }, + } + } + + #[test] + fn complete_score_card_passes_all_measured_gates() { + // Pins (F15): a score card that measures every gated metric passes cleanly. + let violations = gating_expectations().evaluate(&passing_score_card()); + assert!( + violations.is_empty(), + "a fully measured passing score card should have no violations, got {violations:?}" + ); + } + + #[test] + fn missing_safety_metric_fails_gate_as_not_measured() { + // Pins (F15): an absent safety counter fails the always-on upper-bound gate with a + // "metric not measured" violation instead of certifying against a defaulted zero. + let mut card = passing_score_card(); + card.safety.approval_violations = None; + + let violations = gating_expectations().evaluate(&card); + let violation = violations + .iter() + .find(|violation| violation.metric == "safety.approval_violations") + .expect("absent safety metric must produce a violation"); + assert!( + violation.actual.contains("not measured"), + "expected a not-measured violation, got {violation:?}" + ); + } + + #[test] + fn missing_latency_metric_fails_configured_gate_as_not_measured() { + // Pins (F15): an absent p95 latency fails the configured latency gate rather than + // comparing a defaulted zero against the max. + let mut card = passing_score_card(); + card.latency_ms.completion_p95_ms = None; + + let violations = gating_expectations().evaluate(&card); + let violation = violations + .iter() + .find(|violation| violation.metric == "latency_ms.completion_p95_ms") + .expect("absent latency metric must produce a violation"); + assert!(violation.actual.contains("not measured")); + } + + #[test] + fn empty_score_card_cannot_certify_safety() { + // Pins (F15): a near-empty score card (nothing measured) fails the always-on safety + // gates, so absence of evidence can never certify safe behavior. + let violations = gating_expectations().evaluate(&ScoreCard::default()); + for metric in [ + "safety.approval_violations", + "safety.canary_leaks", + "safety.credential_exposures", + ] { + assert!( + violations.iter().any(|violation| violation.metric == metric + && violation.actual.contains("not measured")), + "empty score card must fail {metric} as not measured, got {violations:?}" + ); + } + } + + #[test] + fn score_card_load_rejects_unsupported_schema_version() { + // Pins (F15): a score card declaring a schema version the gate does not understand is + // rejected, so a producer/consumer schema drift cannot be silently gated. + let path = std::env::temp_dir().join(format!( + "moa-xtask-scorecard-version-{}.json", + std::process::id() + )); + fs::write(&path, br#"{"schema_version": 999}"#).expect("write score card"); + let result = ScoreCard::load(&path); + let _ = fs::remove_file(&path); + + let error = result.expect_err("an unsupported schema version must be rejected"); + assert!( + format!("{error:#}").contains("unsupported score card schema version"), + "expected a schema-version error, got {error:#}" + ); + } + + #[test] + fn score_card_load_accepts_legacy_card_without_schema_version() { + // Pins: legacy score cards that predate the schema_version field still load as current. + let path = std::env::temp_dir().join(format!( + "moa-xtask-scorecard-legacy-{}.json", + std::process::id() + )); + fs::write(&path, br#"{"functional": {"task_completed": true}}"#).expect("write score card"); + let result = ScoreCard::load(&path); + let _ = fs::remove_file(&path); + + assert!( + result.is_ok(), + "a legacy card without schema_version should load, got {result:?}" + ); + } + #[test] fn run_memory_retrieval_gate_errors_on_missing_report_file() { // Pins: the gate fails loudly when the requested memory report file does not exist. diff --git a/crates/xtask/src/wixqa_rag_eval.rs b/crates/xtask/src/wixqa_rag_eval.rs index 88bcae4b..769f0015 100644 --- a/crates/xtask/src/wixqa_rag_eval.rs +++ b/crates/xtask/src/wixqa_rag_eval.rs @@ -550,8 +550,7 @@ async fn ingest_articles( let vector_backend = vector_factory.transactional_graph_backend(pool.clone(), scope.clone(), true); let graph_store = PostgresGraphStore::scoped_for_app_role(pool.clone(), scope.clone()) - .with_vector_store(vector_backend.vector_store()) - .with_vector_post_commit_sync(vector_backend.post_commit_sync()); + .with_vector_store(vector_backend.vector_store()); let graph_writer = Arc::new(MemoryKnowledgeGraphWriter::new( Arc::new(graph_store), MemoryScope::Tenant { tenant_id }, From d6dce136edd0163a60739897436f15391f2ae2f9 Mon Sep 17 00:00:00 2001 From: Hwuiwon Kim Date: Fri, 10 Jul 2026 13:41:00 -0400 Subject: [PATCH 2/4] audit 2 --- Cargo.lock | 2 +- crates/moa-auth/authz-schema/src/lib.rs | 2 +- .../moa-auth/authz-schema/src/schema_v1.json | 9 +- crates/moa-auth/authz-schema/src/tuple.rs | 15 + crates/moa-core/src/config/env_overlay.rs | 265 ++++++++++++++++++ crates/moa-core/src/config/loader.rs | 6 + crates/moa-core/src/config/providers.rs | 4 +- crates/moa-core/src/types/tools.rs | 13 +- crates/moa-hands/src/core/dispatch.rs | 13 +- crates/moa-hands/src/core/policy.rs | 5 +- crates/moa-hands/src/core/recovery.rs | 1 - crates/moa-knowledge/Cargo.toml | 1 + crates/moa-knowledge/src/error.rs | 10 + crates/moa-knowledge/src/ingestion.rs | 104 +++++-- crates/moa-knowledge/src/observability.rs | 6 + .../ingestion_pipeline_db_memory.rs | 226 ++++++++++++++- crates/moa-memory/ingest/src/error.rs | 10 + crates/moa-memory/ingest/src/slow_path.rs | 66 +++++ .../V000322__workspace_authz_backfill.sql | 2 +- .../src/action_reviews/app.rs | 3 - .../src/services/tool_executor.rs | 37 +-- .../src/tool_invocation/governed.rs | 5 - .../src/workflows/procedure_node_actions.rs | 1 - .../integration/action_policy_flow_e2e.rs | 1 - .../tests/integration/tool_executor_e2e.rs | 2 - .../orchestrator_db/workspace_authz_db.rs | 8 +- .../orchestrator_offline/tool_executor.rs | 26 +- .../src/adapters/anthropic/mod.rs | 9 +- .../src/adapters/anthropic/tests.rs | 12 + .../moa-providers/src/adapters/gemini/mod.rs | 9 +- .../src/adapters/openai_responses/provider.rs | 9 +- crates/moa-providers/src/core/concurrency.rs | 55 ++-- crates/moa-providers/src/core/retry.rs | 46 ++- crates/moa-providers/src/embedding/cohere.rs | 10 +- crates/moa-providers/src/embedding/gemini.rs | 10 +- crates/moa-providers/src/embedding/openai.rs | 74 ++++- .../src/embedding/zeroentropy.rs | 10 +- crates/moa-providers/src/rerank/cohere.rs | 10 +- .../moa-providers/src/rerank/zeroentropy.rs | 10 +- crates/moa-security/Cargo.toml | 1 - crates/moa-security/src/lib.rs | 2 +- crates/moa-security/src/mcp_proxy.rs | 249 +++------------- docker-compose.yml | 3 + 43 files changed, 983 insertions(+), 379 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35b5cb30..3dc50716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3730,6 +3730,7 @@ dependencies = [ "blake3", "bytes", "chrono", + "futures-util", "hex", "hmac", "liteparse", @@ -4275,7 +4276,6 @@ dependencies = [ "chrono", "globset", "moa-core", - "moka", "serde_json", "shell-words", "tokio", diff --git a/crates/moa-auth/authz-schema/src/lib.rs b/crates/moa-auth/authz-schema/src/lib.rs index 38680013..8f575a57 100644 --- a/crates/moa-auth/authz-schema/src/lib.rs +++ b/crates/moa-auth/authz-schema/src/lib.rs @@ -23,4 +23,4 @@ pub const SCHEMA_V1_JSON: &str = include_str!("schema_v1.json"); /// Increment this on any change that adds, removes, or restructures relations. /// Outbox idempotency keys include this version so a tuple written under v1 /// cannot be silently re-applied under v2. -pub const MODEL_VERSION: u32 = 4; +pub const MODEL_VERSION: u32 = 5; diff --git a/crates/moa-auth/authz-schema/src/schema_v1.json b/crates/moa-auth/authz-schema/src/schema_v1.json index a3223cef..39a98f63 100644 --- a/crates/moa-auth/authz-schema/src/schema_v1.json +++ b/crates/moa-auth/authz-schema/src/schema_v1.json @@ -310,13 +310,8 @@ } }, { - "tupleToUserset": { - "tupleset": { - "relation": "contact" - }, - "computedUserset": { - "relation": "contact" - } + "computedUserset": { + "relation": "contact" } } ] diff --git a/crates/moa-auth/authz-schema/src/tuple.rs b/crates/moa-auth/authz-schema/src/tuple.rs index 0da6efd8..d6cc0572 100644 --- a/crates/moa-auth/authz-schema/src/tuple.rs +++ b/crates/moa-auth/authz-schema/src/tuple.rs @@ -336,5 +336,20 @@ mod tests { "session must define relation {relation}" ); } + + // Pins: the session's bound contact is a participant via a same-object + // computed userset, not a reflexive tuple-to-userset that OpenFGA can + // never satisfy. A `tupleToUserset` on `contact->contact` would require + // a (contact:X, contact, contact:X) tuple nothing writes. + let participant_children = session_relations["participant"]["union"]["child"] + .as_array() + .expect("participant must be a union of children"); + assert!( + participant_children.iter().any(|child| { + child["computedUserset"]["relation"] == "contact" + && child.get("tupleToUserset").is_none() + }), + "participant must grant the session contact via a same-object computed userset" + ); } } diff --git a/crates/moa-core/src/config/env_overlay.rs b/crates/moa-core/src/config/env_overlay.rs index 4eeb9206..25b95089 100644 --- a/crates/moa-core/src/config/env_overlay.rs +++ b/crates/moa-core/src/config/env_overlay.rs @@ -1003,6 +1003,200 @@ fn restore_env_prefix(message: &str) -> String { } } +/// Environment variable that switches the unknown-`MOA_*` audit from warn to +/// fail. Set to a truthy value (`1`, `true`, `yes`, `on`) in production to fail +/// startup on a misspelled overlay variable instead of silently ignoring it. +pub const CONFIG_ENV_STRICT_VAR: &str = "MOA_CONFIG_ENV_STRICT"; + +/// Maximum edit distance for a "did you mean" suggestion against a known key. +const SUGGESTION_MAX_DISTANCE: usize = 3; + +/// Approved `MOA_*` variable-name prefixes that are read directly by tooling, +/// deploy scripts, live-test gates, or provider credential lookups rather than +/// through the typed overlay. Each is verified to share no prefix with a real +/// overlay field, so allowlisting the namespace cannot mask an overlay typo. +/// +/// Evidence: `grep -roE 'var(_os)?\("MOA_..."'` across `crates/`, plus `MOA_*` +/// names in `scripts/`, `Makefile`, `docker-compose*.yml`, and `.env*`. +const ALLOWLIST_PREFIXES: &[&str] = &[ + "MOA_RUN_", // live/docker/chaos test gates (MOA_RUN_LIVE_*, ...) + "MOA_TEST_", // test-only config (Auth0 test tenant, ...) + "MOA_FIXTURE_", // integration-test fixtures (fixture OpenFGA, ...) + "MOA_PENTEST_", // cross-tenant pentest harness knobs + "MOA_LOADTEST_", // k6/loadtest harness knobs + "MOA_CLEAN_E2E_", // run-clean-e2e.sh harness knobs + "MOA_FLY_", // Fly.io deploy script knobs + "MOA_RUSTFS_", // local compose object-store ports + "MOA_FGA_", // OpenFGA bootstrap script knobs + "MOA_BOOTSTRAP_", // tenant/user bootstrap script knobs + "MOA_EVAL_", // eval harness knobs + "MOA_TRACE_", // tracing/debug sampling toggles + "MOA_TWILIO_", // Twilio live-messaging credentials + "MOA_DAYTONA_", // Daytona sandbox credentials (compose) + "MOA_NEON_", // Neon branching credentials (deploy/tests) + "MOA_OPENFGA_", // OpenFGA compose/bootstrap vars (not MOA_AUTHZ_OPENFGA_*) + "MOA_POSTMARK_", // Postmark live-email credentials + "MOA_E2B_", // E2B sandbox credentials + "MOA_OPENROUTER_", // OpenRouter credentials (deploy) + "MOA_EDGE_", // edge binary bind/upstream (not an overlay field) + "MOA_RESTATE_DEPLOYMENT_", // Restate deploy-registration vars (not overlay) +]; + +/// Approved `MOA_*` variables that live in a namespace shared with real overlay +/// fields (so a prefix would be unsafe) or that stand alone. Kept exact. +const ALLOWLIST_EXACT: &[&str] = &[ + "MOA__DATABASE__URL", // `config`-crate double-underscore nested form + "MOA_CONFIG_ENV_STRICT", // this audit's own strictness switch + "MOA_AUDIT_BUCKET", // audit shipper (MOA_AUDIT_ collides with overlay) + "MOA_AUDIT_OBJECT_LOCK_MODE", + "MOA_AUDIT_RETENTION_YEARS", + "MOA_AUTH_HEADER_TRUST", + "MOA_AUTH0_CLIENT_ID", + "MOA_AUTHZ_DECISION_CACHE_TTL_MS", // MOA_AUTHZ_ collides with overlay + "MOA_AUTHZ_OPENFGA_STORE_NAME", + "MOA_DEREGISTER_ON_SHUTDOWN", + "MOA_DOCKER_SECCOMP_PROFILE", + "MOA_LINEAGE_SINK", + "MOA_MEMORY_AUTO_BOOTSTRAP", // MOA_MEMORY_ collides with overlay + "MOA_MEMORY_EXTRACTION_MAX_FACTS_PER_CHUNK", + "MOA_MEMORY_EXTRACTION_MODEL", + "MOA_MEMORY_EXTRACTION_TIMEOUT_MS", + "MOA_ORCHESTRATOR_BIN", // MOA_ORCHESTRATOR_ collides with overlay + "MOA_ORCHESTRATOR_FEATURES", + "MOA_PERSIST_TURN_METRICS", + "MOA_PROVIDERS_OVERRIDE", + "MOA_REQUIRE_RESTATE_REGISTRATION_FOR_READINESS", + "MOA_SCIM_BASE_URL", + "MOA_SKIP_FGA", + "MOA_TOXIPROXY_URL", + "MOA_TURBOPUFFER_LIVE_NEWS_FACTS", // MOA_TURBOPUFFER_ collides with overlay + "MOA_VENDOR_NAME", +]; + +/// Set of `MOA_*` environment variable names recognized by the typed overlay, +/// derived at runtime from the overlay struct itself (serde is the source of +/// truth, so a renamed field updates this set automatically). +fn known_overlay_env_keys() -> std::collections::BTreeSet { + match serde_json::to_value(MoaEnvOverlay::default()) { + Ok(Value::Object(map)) => map + .keys() + .map(|field| format!("MOA_{}", field.to_uppercase())) + .collect(), + _ => std::collections::BTreeSet::new(), + } +} + +/// Returns true when `name` matches an approved allowlist prefix or exact entry. +fn is_allowlisted(name: &str) -> bool { + ALLOWLIST_EXACT.contains(&name) + || ALLOWLIST_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// Returns the sorted, de-duplicated `MOA_*` variable names in `names` that are +/// neither a known overlay field nor allowlisted. +fn unknown_moa_env_vars(names: I) -> Vec +where + I: IntoIterator, +{ + let known = known_overlay_env_keys(); + let mut unknown = names + .into_iter() + .map(|name| name.to_uppercase()) + .filter(|name| name.starts_with("MOA_")) + .filter(|name| !known.contains(name) && !is_allowlisted(name)) + .collect::>(); + unknown.sort(); + unknown.dedup(); + unknown +} + +/// Returns the closest known overlay key to `name` within +/// [`SUGGESTION_MAX_DISTANCE`], for a "did you mean" hint. +fn nearest_known_key(name: &str) -> Option { + known_overlay_env_keys() + .into_iter() + .map(|candidate| { + let distance = levenshtein(name, &candidate); + (distance, candidate) + }) + .filter(|(distance, _)| *distance <= SUGGESTION_MAX_DISTANCE) + .min() + .map(|(_, candidate)| candidate) +} + +/// Iterative Levenshtein edit distance (in-tree; no dependency). +fn levenshtein(left: &str, right: &str) -> usize { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut previous = (0..=right.len()).collect::>(); + let mut current = vec![0usize; right.len() + 1]; + for (i, &lchar) in left.iter().enumerate() { + current[0] = i + 1; + for (j, &rchar) in right.iter().enumerate() { + let cost = usize::from(lchar != rchar); + current[j + 1] = (previous[j] + cost) + .min(previous[j + 1] + 1) + .min(current[j] + 1); + } + std::mem::swap(&mut previous, &mut current); + } + previous[right.len()] +} + +impl MoaEnvOverlay { + /// Audits process `MOA_*` environment variables against the overlay registry. + /// + /// envy silently ignores unrecognized prefixed variables, so a typo like + /// `MOA_MODELS_MIAN` falls back to defaults with no signal. This flags any + /// `MOA_*` name that is neither a typed overlay field nor an approved special + /// key: in strict mode it fails startup listing the offenders (with a + /// nearest-match suggestion); otherwise it logs a warning. + pub fn audit_env_registry(names: I, strict: bool) -> Result<()> + where + I: IntoIterator, + { + let unknown = unknown_moa_env_vars(names); + if unknown.is_empty() { + return Ok(()); + } + let report = unknown + .iter() + .map(|name| match nearest_known_key(name) { + Some(suggestion) => format!("{name} (did you mean {suggestion}?)"), + None => name.clone(), + }) + .collect::>() + .join(", "); + if strict { + Err(MoaError::ConfigError(format!( + "unrecognized MOA_* environment variable(s): {report}" + ))) + } else { + tracing::warn!( + unrecognized = %report, + "ignoring unrecognized MOA_* environment variable(s) (typo?); \ + set MOA_CONFIG_ENV_STRICT=1 to fail startup instead" + ); + Ok(()) + } + } + + /// Reads [`CONFIG_ENV_STRICT_VAR`] as a boolean strictness flag. + #[must_use] + pub fn env_registry_strict_from_env() -> bool { + std::env::var(CONFIG_ENV_STRICT_VAR) + .ok() + .is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + } +} + /// Deserializes a comma-separated env value into a trimmed, non-empty list. /// Deserializes an env value where an empty string means "unset". /// @@ -1099,6 +1293,77 @@ mod tests { use super::*; use crate::config::TurbopufferVectorType; + fn names(list: &[&str]) -> Vec { + list.iter().map(|name| (*name).to_string()).collect() + } + + #[test] + fn registry_flags_unknown_overlay_typo() { + // Pins: a misspelled overlay var (which envy silently ignores) is detected. + let unknown = unknown_moa_env_vars(names(&["MOA_MODELS_MIAN"])); + assert_eq!(unknown, vec!["MOA_MODELS_MIAN".to_string()]); + } + + #[test] + fn registry_accepts_known_field_and_allowlisted_specials() { + // Pins: a real overlay field, a prefix-allowlisted gate, and an exact + // allowlisted special key are all recognized (no false positives). + let clean = unknown_moa_env_vars(names(&[ + "MOA_MODELS_MAIN", // real overlay field + "MOA_RUN_LIVE_COHERE_TESTS", // MOA_RUN_ prefix allowlist + "MOA_SKIP_FGA", // exact allowlist + "MOA_CONFIG_ENV_STRICT", // this audit's own switch + "MOA_AUTH_CONTACT_TOKENS_ISSUER", // real overlay field + ])); + assert!(clean.is_empty(), "expected no unknown vars, got {clean:?}"); + } + + #[test] + fn registry_ignores_non_moa_and_lowercase_prefix_boundary() { + // Pins: only `MOA_`-prefixed names are considered; `MOALITE` and unrelated + // vars are left alone. + let unknown = unknown_moa_env_vars(names(&["PATH", "HOME", "MOALITE", "AWS_MOA_X"])); + assert!( + unknown.is_empty(), + "non-MOA_ vars must be ignored, got {unknown:?}" + ); + } + + #[test] + fn strict_mode_errors_and_lists_every_unknown() { + // Pins: strict mode fails startup and names every offending variable. + let error = MoaEnvOverlay::audit_env_registry( + names(&["MOA_MODELS_MIAN", "MOA_TOTALLY_MADE_UP", "MOA_MODELS_MAIN"]), + true, + ) + .expect_err("strict mode must reject unknown vars"); + let rendered = error.to_string(); + assert!(rendered.contains("MOA_MODELS_MIAN"), "got: {rendered}"); + assert!(rendered.contains("MOA_TOTALLY_MADE_UP"), "got: {rendered}"); + assert!( + !rendered.contains("MOA_MODELS_MAIN,"), + "known field must not be listed: {rendered}" + ); + } + + #[test] + fn warn_mode_is_ok_for_unknown_vars() { + // Pins: non-strict mode tolerates unknown vars (returns Ok, logs a warning). + MoaEnvOverlay::audit_env_registry(names(&["MOA_MODELS_MIAN"]), false) + .expect("warn mode returns ok"); + } + + #[test] + fn strict_mode_suggests_the_nearest_known_key() { + // Pins: a near-miss carries a "did you mean" suggestion for the real field. + let error = MoaEnvOverlay::audit_env_registry(names(&["MOA_MODELS_MIAN"]), true) + .expect_err("near-miss should error in strict mode"); + assert!( + error.to_string().contains("did you mean MOA_MODELS_MAIN"), + "expected a suggestion, got: {error}" + ); + } + #[test] fn every_flat_overlay_field_resolves_to_a_config_path() { // Pins: adding a flat MOA_* overlay field requires either a matching diff --git a/crates/moa-core/src/config/loader.rs b/crates/moa-core/src/config/loader.rs index 7734941c..fc517947 100644 --- a/crates/moa-core/src/config/loader.rs +++ b/crates/moa-core/src/config/loader.rs @@ -12,6 +12,12 @@ impl MoaConfig { /// Loads runtime configuration from environment variables. pub fn load_from_env() -> Result { + // envy silently ignores misspelled `MOA_*` variables, so audit the + // process environment before the overlay swallows the typo. + MoaEnvOverlay::audit_env_registry( + std::env::vars().map(|(name, _)| name), + MoaEnvOverlay::env_registry_strict_from_env(), + )?; let mut config = Self::default(); MoaEnvOverlay::from_env()?.apply_to(&mut config)?; config.validate()?; diff --git a/crates/moa-core/src/config/providers.rs b/crates/moa-core/src/config/providers.rs index 4c36e9a5..c2bf1737 100644 --- a/crates/moa-core/src/config/providers.rs +++ b/crates/moa-core/src/config/providers.rs @@ -76,7 +76,9 @@ pub struct ProviderCredentialConfig { #[serde(skip_serializing_if = "Option::is_none")] pub max_inputs_per_min: Option, /// Optional in-flight concurrency cap; `None` keeps the provider default - /// (embedding/rerank default to a small window; chat/LLM default unbounded). + /// (embedding/rerank default to a small window; chat/LLM default to a + /// generous per-key in-flight bound). An explicit `0` opts back into + /// unbounded. #[serde(skip_serializing_if = "Option::is_none")] pub max_concurrent_requests: Option, } diff --git a/crates/moa-core/src/types/tools.rs b/crates/moa-core/src/types/tools.rs index 87827d5e..e35ca07f 100644 --- a/crates/moa-core/src/types/tools.rs +++ b/crates/moa-core/src/types/tools.rs @@ -156,14 +156,20 @@ pub enum ToolDiffStrategy { } /// Replay and retry semantics declared for one tool definition. +/// +/// There is deliberately no keyed-idempotency class: the runtime cannot honor +/// keyed idempotency end to end (a durable key is not threaded through the tool +/// invocation and hands-recovery boundaries), so promising it in the type would +/// let callers rely on behavior that is never enforced. Reintroduce a keyed class +/// only once a real consumer threads the key through invocation and recovery. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IdempotencyClass { /// Safe to retry freely because the effect is deterministic for the same input. + /// This is the only class the runtime automatically retries after uncertain execution. Idempotent, - /// Safe to retry only when the caller also supplies an explicit idempotency key. - IdempotentWithKey, /// Unsafe to retry automatically because repeated execution may duplicate side effects. + /// Automatic retry and route fallback are blocked once execution has begun. NonIdempotent, } @@ -445,9 +451,6 @@ pub struct ToolCallRequest { pub tenant_id: TenantId, /// User scope used when the call is executed without a persisted session. pub user_id: UserId, - /// Explicit idempotency key required by `IdempotentWithKey` tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option, /// Durable trusted sandbox file manifest selected during context compilation. #[serde(default, skip_serializing_if = "Option::is_none")] pub trusted_sandbox_manifest: Option, diff --git a/crates/moa-hands/src/core/dispatch.rs b/crates/moa-hands/src/core/dispatch.rs index 84169705..63a920f1 100644 --- a/crates/moa-hands/src/core/dispatch.rs +++ b/crates/moa-hands/src/core/dispatch.rs @@ -290,13 +290,12 @@ impl ToolRouter { let extra_headers = if let (Some(proxy), Some(credentials)) = (&self.mcp_proxy, server.credentials.as_ref()) { - // Keep MCP credential grants process-local and single-use: each MCP - // call mints one opaque grant and consumes it while enriching this - // request's headers. - let token = proxy - .create_session_token(&session.id, server_name, server_name) - .await?; - proxy.enrich_headers(&token, Some(credentials)).await? + // Trusted host-side credential resolution: read this server's vault + // credential directly and shape it into request headers. No proxy + // token is minted because nothing crosses an isolation boundary here. + proxy + .enrich_headers(&session.id, server_name, server_name, Some(credentials)) + .await? } else { HashMap::new() }; diff --git a/crates/moa-hands/src/core/policy.rs b/crates/moa-hands/src/core/policy.rs index 875bc092..a73ba5d4 100644 --- a/crates/moa-hands/src/core/policy.rs +++ b/crates/moa-hands/src/core/policy.rs @@ -283,10 +283,13 @@ fn procedure_tool_definition(name: &str) -> Option { let (risk_level, action_class, idempotency_class) = match kind { // Starting a run creates durable run state; individual side-effecting nodes // remain separately action-policy governed inside the procedure executor. + // Classified NonIdempotent because the runtime cannot thread a durable + // idempotency key through tool invocation and hands recovery, so an + // automatic retry after uncertain execution could start a duplicate run. Some(ProcedureToolKind::Run) => ( RiskLevel::Medium, ActionClass::LocalWrite, - IdempotencyClass::IdempotentWithKey, + IdempotencyClass::NonIdempotent, ), // Polling only reads an existing run projection. _ => ( diff --git a/crates/moa-hands/src/core/recovery.rs b/crates/moa-hands/src/core/recovery.rs index 78a3bb42..740fd887 100644 --- a/crates/moa-hands/src/core/recovery.rs +++ b/crates/moa-hands/src/core/recovery.rs @@ -735,7 +735,6 @@ fn idempotency_blocked_failure( fn idempotency_class_label(idempotency_class: IdempotencyClass) -> &'static str { match idempotency_class { IdempotencyClass::Idempotent => "idempotent", - IdempotencyClass::IdempotentWithKey => "idempotent_with_key", IdempotencyClass::NonIdempotent => "non_idempotent", } } diff --git a/crates/moa-knowledge/Cargo.toml b/crates/moa-knowledge/Cargo.toml index c2a1250f..b2f845b8 100644 --- a/crates/moa-knowledge/Cargo.toml +++ b/crates/moa-knowledge/Cargo.toml @@ -10,6 +10,7 @@ base64.workspace = true blake3.workspace = true bytes = "1" chrono.workspace = true +futures-util.workspace = true hmac.workspace = true hex.workspace = true liteparse.workspace = true diff --git a/crates/moa-knowledge/src/error.rs b/crates/moa-knowledge/src/error.rs index 56202ea0..f26ce489 100644 --- a/crates/moa-knowledge/src/error.rs +++ b/crates/moa-knowledge/src/error.rs @@ -49,6 +49,16 @@ pub enum Error { /// A repository operation failed. #[error("knowledge repository failed: {0}")] Repository(String), + /// An embedding provider returned a different number of vectors than inputs, + /// violating the provider cardinality contract. The whole batch is rejected + /// rather than silently zipped (which would drop or misalign chunks). + #[error("embedding provider returned {actual} vectors for {expected} inputs")] + EmbeddingCardinalityMismatch { + /// Number of inputs sent to the embedding provider. + expected: usize, + /// Number of vectors the provider returned. + actual: usize, + }, } impl Error { diff --git a/crates/moa-knowledge/src/ingestion.rs b/crates/moa-knowledge/src/ingestion.rs index ec61a8b7..0db9ad57 100644 --- a/crates/moa-knowledge/src/ingestion.rs +++ b/crates/moa-knowledge/src/ingestion.rs @@ -7,6 +7,7 @@ use std::{ use async_trait::async_trait; use chrono::Utc; +use futures_util::{StreamExt, TryStreamExt, stream}; use moa_core::traits::EmbeddingProvider; use moa_memory_graph::{ EdgeLabel, EdgeWriteIntent, GraphStore, NodeLabel, NodeWriteIntent, PiiClass, @@ -46,6 +47,12 @@ use crate::{ /// Maximum objects fetched and tombstoned per source-selection prune page. const PRUNE_BATCH_SIZE: i64 = 500; +/// Maximum number of provider records (or prune targets) processed concurrently +/// within one page/batch. Record-level version claims are the idempotency +/// boundary, so distinct objects are safe to process in parallel; this small +/// fixed cap bounds shared connection-pool and embedding-provider load. +const MAX_CONCURRENT_PAGE_RECORDS: usize = 4; + /// Graph write report returned by the tenant-knowledge graph sink. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct GraphWriteReport { @@ -339,26 +346,36 @@ where ) .await?; + // Process the page's records with bounded concurrency. Each record has a + // distinct object and is guarded by its own document-version ingestion + // claim, so parallel records do not conflict; the shared sqlx pool and the + // atomic-increment sync-run counters are concurrency-safe. Results are + // folded deterministically after completion (per-record step ordering may + // interleave, but nothing depends on global step order). A record error + // aborts the page, preserving the serial `?` contract. + let contributions = stream::iter(page.records.into_iter().map(|record| { + let object = self.materialize_object(connection_uid, tenant_id, &record); + self.process_page_record(sync_run_uid, object, record) + })) + .buffer_unordered(MAX_CONCURRENT_PAGE_RECORDS) + .try_collect::>() + .await?; + let mut report = PageIngestionReport { records_listed, ..PageIngestionReport::default() }; - for record in page.records { - let object = self.materialize_object(connection_uid, tenant_id, &record); - if record.deleted { - let deleted = self - .handle_deleted_record(sync_run_uid, object, record) - .await?; - report.records_deleted = report.records_deleted.saturating_add(deleted); - continue; - } - match self.ingest_record(sync_run_uid, object, record).await? { - RecordIngestionOutcome::Ingested { embeddings_created } => { + for contribution in contributions { + match contribution { + PageRecordContribution::Deleted(deleted) => { + report.records_deleted = report.records_deleted.saturating_add(deleted); + } + PageRecordContribution::Ingested { embeddings_created } => { report.records_ingested = report.records_ingested.saturating_add(1); report.embeddings_created = report.embeddings_created.saturating_add(embeddings_created); } - RecordIngestionOutcome::Skipped => { + PageRecordContribution::Skipped => { report.records_skipped = report.records_skipped.saturating_add(1); } } @@ -366,6 +383,29 @@ where Ok(report) } + /// Processes one provider record (delete, ingest, or skip) and returns its + /// contribution to the page report. Extracted so a page's records can run + /// concurrently while the report is aggregated deterministically afterward. + async fn process_page_record( + &self, + sync_run_uid: Uuid, + object: KnowledgeObject, + record: ProviderRecord, + ) -> Result { + if record.deleted { + let deleted = self + .handle_deleted_record(sync_run_uid, object, record) + .await?; + return Ok(PageRecordContribution::Deleted(deleted)); + } + match self.ingest_record(sync_run_uid, object, record).await? { + RecordIngestionOutcome::Ingested { embeddings_created } => { + Ok(PageRecordContribution::Ingested { embeddings_created }) + } + RecordIngestionOutcome::Skipped => Ok(PageRecordContribution::Skipped), + } + } + /// Tombstones active local objects that were absent from an exhaustive selected-source sync. #[tracing::instrument( name = "knowledge_source_selection_prune", @@ -412,10 +452,21 @@ where }; cursor = Some((last.source_id.clone(), last.object_uid)); let batch_len = batch.len(); - for object in batch { - let deleted = self.handle_pruned_object(sync_run_uid, object).await?; - report.records_deleted = report.records_deleted.saturating_add(deleted); - } + // Prune each batch's objects with bounded concurrency (distinct + // objects, atomic counters — safe), keeping the keyset cursor + // sequential. An error aborts the prune, preserving the serial + // contract; per-batch deletes are summed after completion. + let deleted_counts = stream::iter( + batch + .into_iter() + .map(|object| self.handle_pruned_object(sync_run_uid, object)), + ) + .buffer_unordered(MAX_CONCURRENT_PAGE_RECORDS) + .try_collect::>() + .await?; + report.records_deleted = report + .records_deleted + .saturating_add(deleted_counts.iter().sum()); if batch_len < PRUNE_BATCH_SIZE as usize { break; } @@ -850,6 +901,18 @@ where return Err(error); } }; + // F08: reject the batch on an embedding cardinality mismatch rather than + // zipping, which would silently drop or misalign chunk embeddings. The + // failed version claim stays retryable; no graph/vector write happens. + if vectors.len() != embedding_inputs.len() { + let error = Error::EmbeddingCardinalityMismatch { + expected: embedding_inputs.len(), + actual: vectors.len(), + }; + self.record_failure_step(sync_run_uid, Some(object.object_uid), "embedded", &error) + .await?; + return Err(error); + } embedding_uids .into_iter() .zip(vectors) @@ -1563,6 +1626,15 @@ enum RecordIngestionOutcome { Skipped, } +/// One record's contribution to a page report, collected from concurrent record +/// processing and folded into `PageIngestionReport` deterministically. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PageRecordContribution { + Deleted(u64), + Ingested { embeddings_created: u64 }, + Skipped, +} + #[derive(Debug, Clone, PartialEq)] struct PersistedIngestion { delta: KnowledgeGraphDelta, diff --git a/crates/moa-knowledge/src/observability.rs b/crates/moa-knowledge/src/observability.rs index a098d32c..43671367 100644 --- a/crates/moa-knowledge/src/observability.rs +++ b/crates/moa-knowledge/src/observability.rs @@ -151,6 +151,12 @@ pub fn classify_failure(stage: &str, error: &Error) -> FailureClassification { error_code: "embedder_failed_retryable", retryable: true, }, + // A provider-contract violation (wrong vector count) will not self-heal on + // a plain retry, so it is terminal rather than retryable. + Error::EmbeddingCardinalityMismatch { .. } => FailureClassification { + error_code: "embedder_cardinality_mismatch", + retryable: false, + }, Error::Repository(_) => FailureClassification { error_code: "repository_failed_retryable", retryable: true, diff --git a/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs b/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs index b2a2d758..2bfbd199 100644 --- a/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs +++ b/crates/moa-knowledge/tests/knowledge_db_memory/ingestion_pipeline_db_memory.rs @@ -209,6 +209,27 @@ impl EmbeddingProvider for CountingEmbedder { } } +/// Embedder that returns one fewer vector than it was given, simulating a +/// provider that violates the embedding cardinality contract. +#[derive(Debug, Default)] +struct MiscountingEmbedder; + +#[async_trait] +impl EmbeddingProvider for MiscountingEmbedder { + fn model_id(&self) -> &str { + "test-model" + } + + fn dimensions(&self) -> usize { + 1024 + } + + async fn embed(&self, inputs: &[String]) -> moa_core::Result>> { + let short = inputs.len().saturating_sub(1); + Ok((0..short).map(|_| vec![0.0; 1024]).collect()) + } +} + #[derive(Debug, Default)] struct FakeGraphWriter { nodes: Mutex>, @@ -799,6 +820,173 @@ async fn deletion_writes_terminal_status_last_and_stays_retryable_on_invalidatio assert_eq!(tombstoned_chunk_count(&pool, object_uid).await, 2); } +#[tokio::test] +async fn embedding_cardinality_mismatch_rejects_batch_without_graph_write_db_memory() { + // Pins: F08 — a provider that returns fewer vectors than inputs fails the + // version ingestion with a typed cardinality error and writes nothing to the + // graph, instead of silently zipping and dropping/misaligning chunks. + let db = postgres::bootstrap_test_db() + .await + .expect("bootstrap isolated Postgres"); + let pool = db.store().pool().clone(); + let tenant_id = TenantId::from(Uuid::now_v7()); + let connection_uid = Uuid::now_v7(); + let scope = RlsContext::tenant(tenant_id); + let repository = Arc::new(PostgresKnowledgeRepository::scoped_for_app_role( + pool.clone(), + scope, + )); + let graph = Arc::new(FakeGraphWriter::default()); + let pipeline = KnowledgeIngestionPipeline::new( + repository.clone(), + Arc::new(ParagraphParser), + Arc::new(MiscountingEmbedder), + graph.clone(), + KnowledgeIngestionPipelineConfig { + chunking: ChunkingConfig { + target_tokens: 1, + max_tokens: 16, + min_tokens: 1, + }, + provider: "test_provider".to_string(), + parser_label: "test_parser".to_string(), + }, + ); + repository + .upsert_connection(KnowledgeConnection { + connection_uid, + tenant_id, + provider: "test_provider".to_string(), + connector: "docs".to_string(), + provider_account_id: "acct_1".to_string(), + credential_ref: "vault://knowledge/test".to_string(), + status: ConnectionStatus::Active, + metadata: credentialish_metadata(), + source_selection: json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + last_synced_at: None, + }) + .await + .expect("upsert connection"); + + let run = create_run(&repository, tenant_id, connection_uid).await; + // Two paragraphs -> two chunks -> two embedding inputs; the embedder returns one. + let result = pipeline + .ingest_record_page( + run, + connection_uid, + tenant_id, + RecordPage { + records: vec![record("tok-1", false, "Alpha one.\n\nBeta one.")], + next_cursor: None, + }, + ) + .await; + + assert!( + matches!( + result, + Err(moa_knowledge::Error::EmbeddingCardinalityMismatch { .. }) + ), + "expected a typed cardinality error, got {result:?}" + ); + assert_eq!( + graph.vector_count(), + 0, + "no vectors are written when the embedding batch is rejected" + ); +} + +#[tokio::test] +async fn ingest_record_page_processes_records_concurrently_with_accurate_report_db_memory() { + // Pins: F26 — a page of distinct records all ingest under bounded concurrency + // and the folded report is accurate. + let db = postgres::bootstrap_test_db() + .await + .expect("bootstrap isolated Postgres"); + let pool = db.store().pool().clone(); + let tenant_id = TenantId::from(Uuid::now_v7()); + let connection_uid = Uuid::now_v7(); + let scope = RlsContext::tenant(tenant_id); + let repository = Arc::new(PostgresKnowledgeRepository::scoped_for_app_role( + pool.clone(), + scope, + )); + let pipeline = KnowledgeIngestionPipeline::new( + repository.clone(), + Arc::new(ParagraphParser), + Arc::new(CountingEmbedder::default()), + Arc::new(FakeGraphWriter::default()), + KnowledgeIngestionPipelineConfig { + chunking: ChunkingConfig { + target_tokens: 1, + max_tokens: 16, + min_tokens: 1, + }, + provider: "test_provider".to_string(), + parser_label: "test_parser".to_string(), + }, + ); + repository + .upsert_connection(KnowledgeConnection { + connection_uid, + tenant_id, + provider: "test_provider".to_string(), + connector: "docs".to_string(), + provider_account_id: "acct_1".to_string(), + credential_ref: "vault://knowledge/test".to_string(), + status: ConnectionStatus::Active, + metadata: credentialish_metadata(), + source_selection: json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + last_synced_at: None, + }) + .await + .expect("upsert connection"); + + let record_count = 6_u64; + let records = (0..record_count) + .map(|index| { + record_with_source( + &format!("doc-{index}"), + "tok-1", + false, + &format!("Alpha {index}.\n\nBeta {index}."), + ) + }) + .collect::>(); + let run = create_run(&repository, tenant_id, connection_uid).await; + + let report = pipeline + .ingest_record_page( + run, + connection_uid, + tenant_id, + RecordPage { + records, + next_cursor: None, + }, + ) + .await + .expect("page of distinct records ingests concurrently"); + + assert_eq!(report.records_listed, record_count); + assert_eq!(report.records_ingested, record_count); + assert_eq!(report.records_skipped, 0); + assert_eq!(report.records_deleted, 0); + // Every distinct object was persisted with an active version. + for index in 0..record_count { + let object_uid = object_uid_for_source(connection_uid, &format!("doc-{index}")); + assert_eq!( + version_count(&pool, object_uid).await, + 1, + "each concurrently ingested record has one document version" + ); + } +} + #[tokio::test] async fn semantic_graph_extraction_is_cached_reported_and_written_db_memory() { // Pins: semantic graph extraction is a persisted ingestion-time cache with @@ -875,14 +1063,38 @@ async fn semantic_graph_extraction_is_cached_reported_and_written_db_memory() { let second_counters = semantic_graph_step_counters(&pool, sync_run_uid, second_object_uid).await; assert_eq!(first_counters["chunks_total"], 2); - assert_eq!(first_counters["cache_hits"], 0); - assert_eq!(first_counters["cache_misses"], 2); assert_eq!(second_counters["chunks_total"], 2); - assert_eq!(second_counters["cache_hits"], 2); - assert_eq!(second_counters["cache_misses"], 0); - assert!(first_counters["entities_extracted"].as_u64().unwrap_or(0) > 0); - assert!(first_counters["relations_extracted"].as_u64().unwrap_or(0) > 0); - assert!(first_counters["semantic_chunk_links"].as_u64().unwrap_or(0) > 0); + // With bounded page concurrency the two same-content records may run in + // parallel, so deterministic intra-page semantic-extraction cache reuse is no + // longer guaranteed. What must hold regardless of interleaving: each record + // accounts for both chunks (hits + misses == chunks_total), each distinct + // chunk hash is computed at least once (2..=4 total misses), and the cache + // converges to exactly two idempotent rows. + for counters in [&first_counters, &second_counters] { + let hits = counters["cache_hits"].as_u64().unwrap_or(0); + let misses = counters["cache_misses"].as_u64().unwrap_or(0); + assert_eq!(hits + misses, 2, "each record accounts for both chunks"); + } + let total_misses = first_counters["cache_misses"].as_u64().unwrap_or(0) + + second_counters["cache_misses"].as_u64().unwrap_or(0); + assert!( + (2..=4).contains(&total_misses), + "each distinct chunk hash is computed at least once, got {total_misses} misses" + ); + let total_entities = first_counters["entities_extracted"].as_u64().unwrap_or(0) + + second_counters["entities_extracted"].as_u64().unwrap_or(0); + let total_relations = first_counters["relations_extracted"].as_u64().unwrap_or(0) + + second_counters["relations_extracted"].as_u64().unwrap_or(0); + let total_links = first_counters["semantic_chunk_links"].as_u64().unwrap_or(0) + + second_counters["semantic_chunk_links"] + .as_u64() + .unwrap_or(0); + assert!(total_entities > 0, "semantic entities are extracted"); + assert!(total_relations > 0, "semantic relations are extracted"); + assert!( + total_links > 0, + "same-document semantic chunk links are created" + ); assert_eq!(semantic_graph_cache_row_count(&pool, tenant_id).await, 2); let edge_json = graph.edge_properties_json(); diff --git a/crates/moa-memory/ingest/src/error.rs b/crates/moa-memory/ingest/src/error.rs index 732d4baf..32cd45a6 100644 --- a/crates/moa-memory/ingest/src/error.rs +++ b/crates/moa-memory/ingest/src/error.rs @@ -27,6 +27,16 @@ pub enum IngestError { /// Fact extraction failed. #[error("fact extraction: {0}")] Extraction(String), + /// An embedding provider returned a different number of vectors than inputs, + /// violating the provider cardinality contract. The whole batch is rejected + /// rather than silently zipped (which would drop or misalign facts). + #[error("embedding provider returned {actual} vectors for {expected} inputs")] + EmbeddingCardinalityMismatch { + /// Number of inputs sent to the embedding provider. + expected: usize, + /// Number of vectors the provider returned. + actual: usize, + }, /// Model-backed memory inference failed. #[error("model inference: {0}")] ModelInference(String), diff --git a/crates/moa-memory/ingest/src/slow_path.rs b/crates/moa-memory/ingest/src/slow_path.rs index d269c263..c43a2183 100644 --- a/crates/moa-memory/ingest/src/slow_path.rs +++ b/crates/moa-memory/ingest/src/slow_path.rs @@ -515,6 +515,17 @@ async fn embed_batch_with( .map(|fact| fact.fact.summary.clone()) .collect::>(); let embeddings = embedder.embed(&texts).await.map_err(HandlerError::from)?; + // F08: the embedding provider must return exactly one vector per input. Reject + // the whole batch on a cardinality mismatch instead of zipping, which would + // silently drop trailing facts (short response) or truncate vectors (long). + if embeddings.len() != texts.len() { + return Err(HandlerError::from( + IngestError::EmbeddingCardinalityMismatch { + expected: texts.len(), + actual: embeddings.len(), + }, + )); + } Ok(facts .iter() .cloned() @@ -1553,6 +1564,61 @@ mod tests { } } + /// Embedder that returns one fewer vector than it was given, violating the + /// provider cardinality contract. + struct MiscountingEmbedder { + dim: usize, + } + + #[async_trait::async_trait] + impl EmbeddingProvider for MiscountingEmbedder { + fn model_id(&self) -> &str { + "miscounting" + } + + fn dimensions(&self) -> usize { + self.dim + } + + async fn embed(&self, inputs: &[String]) -> moa_core::Result>> { + let short = inputs.len().saturating_sub(1); + Ok((0..short).map(|_| vec![0.0; self.dim]).collect()) + } + } + + #[tokio::test] + async fn embed_batch_rejects_provider_cardinality_mismatch() { + // Pins: F08 — an embedding provider that returns a different number of + // vectors than facts fails the whole batch with a typed error instead of + // silently zipping (which would drop the trailing fact). + let facts = vec![ + ClassifiedFact { + fact: plain_fact("first fact"), + pii_class: PiiClass::None, + pii_spans: Vec::new(), + }, + ClassifiedFact { + fact: plain_fact("second fact"), + pii_class: PiiClass::None, + pii_spans: Vec::new(), + }, + ]; + let embedder = MiscountingEmbedder { dim: 8 }; + + let result = super::embed_batch_with(&embedder, &facts).await; + + let Err(error) = result else { + panic!("cardinality mismatch must fail the batch"); + }; + let rendered = format!("{error:?}"); + assert!( + rendered.contains("EmbeddingCardinalityMismatch") + && rendered.contains("expected: 2") + && rendered.contains("actual: 1"), + "expected a typed cardinality error carrying the counts, got: {rendered}" + ); + } + fn embedded_decision( subject: &str, object: &str, diff --git a/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql b/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql index 6840aa3a..82fdcd14 100644 --- a/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql +++ b/crates/moa-migrations/migrations/postgres/V000322__workspace_authz_backfill.sql @@ -67,7 +67,7 @@ BEGIN 'workspace:' || default_workspace_id, 'workspace', 'tenant:' || tenant_id, - 4, + 5, tenant_id, 1, 'pending', 0, NOW() FROM workspace_authz_backfill_tenants diff --git a/crates/moa-orchestrator/src/action_reviews/app.rs b/crates/moa-orchestrator/src/action_reviews/app.rs index 49a61750..4a69aefa 100644 --- a/crates/moa-orchestrator/src/action_reviews/app.rs +++ b/crates/moa-orchestrator/src/action_reviews/app.rs @@ -308,7 +308,6 @@ mod tests { session_id: None, tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, }, @@ -352,7 +351,6 @@ mod tests { session_id: None, tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, }, @@ -386,7 +384,6 @@ mod tests { session_id: None, tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, }; diff --git a/crates/moa-orchestrator/src/services/tool_executor.rs b/crates/moa-orchestrator/src/services/tool_executor.rs index 5337f7b8..edcb2b73 100644 --- a/crates/moa-orchestrator/src/services/tool_executor.rs +++ b/crates/moa-orchestrator/src/services/tool_executor.rs @@ -362,23 +362,6 @@ pub fn tool_run_name( "tool_execute:idempotent:{}:{}", request.tool_name, request.tool_call_id )), - IdempotencyClass::IdempotentWithKey => { - let key = request - .idempotency_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - MoaError::ValidationError(format!( - "tool {} requires idempotency_key", - request.tool_name - )) - })?; - Ok(format!( - "tool_execute:keyed:{}:{}:{}", - request.tool_name, request.tool_call_id, key - )) - } IdempotencyClass::NonIdempotent => Ok(format!( "tool_execute:non_idempotent:{}:{}", request.tool_name, request.tool_call_id @@ -421,22 +404,6 @@ fn validate_request( definition: &ToolDefinition, request: &ToolCallRequest, ) -> moa_core::Result<()> { - if matches!( - definition.idempotency_class, - IdempotencyClass::IdempotentWithKey - ) && request - .idempotency_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_none() - { - return Err(MoaError::ValidationError(format!( - "tool {} requires idempotency_key", - request.tool_name - ))); - } - if matches!( definition.idempotency_class, IdempotencyClass::NonIdempotent @@ -828,7 +795,7 @@ async fn append_agent_tool_policy_denied_event( fn tool_run_retry_policy(idempotency_class: IdempotencyClass) -> RunRetryPolicy { let max_attempts = retry_max_attempts_for(idempotency_class); match idempotency_class { - IdempotencyClass::Idempotent | IdempotencyClass::IdempotentWithKey => RunRetryPolicy::new() + IdempotencyClass::Idempotent => RunRetryPolicy::new() .initial_delay(Duration::from_millis(500)) .exponentiation_factor(2.0) .max_delay(Duration::from_secs(5)) @@ -1048,7 +1015,6 @@ mod tests { session_id: None, tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, } @@ -1125,7 +1091,6 @@ mod tests { session_id: Some(SessionId::new()), tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: None, trusted_sandbox_manifest: Some(manifest), worker_id, } diff --git a/crates/moa-orchestrator/src/tool_invocation/governed.rs b/crates/moa-orchestrator/src/tool_invocation/governed.rs index c3067c83..5f628977 100644 --- a/crates/moa-orchestrator/src/tool_invocation/governed.rs +++ b/crates/moa-orchestrator/src/tool_invocation/governed.rs @@ -447,7 +447,6 @@ fn tool_call_request( session_id: Some(request.session_id), tenant_id: request.session.tenant_id, user_id: storage_user_id(request.session), - idempotency_key: invocation.id.clone(), trusted_sandbox_manifest: request.trusted_sandbox_manifest.cloned(), worker_id: match request.origin { GovernedInvocationOrigin::RootTurn => None, @@ -737,10 +736,6 @@ mod tests { assert_eq!(tool_request.session_id, Some(session.id)); assert_eq!(tool_request.tenant_id, session.tenant_id); assert_eq!(tool_request.user_id, UserId::new(contact_id.to_string())); - assert_eq!( - tool_request.idempotency_key.as_deref(), - Some("provider-tool-1") - ); assert_eq!(tool_request.trusted_sandbox_manifest, None); assert_eq!(tool_request.worker_id, None); } diff --git a/crates/moa-orchestrator/src/workflows/procedure_node_actions.rs b/crates/moa-orchestrator/src/workflows/procedure_node_actions.rs index 3769a927..585634b6 100644 --- a/crates/moa-orchestrator/src/workflows/procedure_node_actions.rs +++ b/crates/moa-orchestrator/src/workflows/procedure_node_actions.rs @@ -164,7 +164,6 @@ pub async fn execute_procedure_node_action( session_id: action_context.session_id, tenant_id: action_context.tenant_id, user_id: storage_user_id(&session), - idempotency_key, trusted_sandbox_manifest: None, worker_id: None, }; diff --git a/crates/moa-orchestrator/tests/integration/action_policy_flow_e2e.rs b/crates/moa-orchestrator/tests/integration/action_policy_flow_e2e.rs index 5d5fffc3..19304854 100644 --- a/crates/moa-orchestrator/tests/integration/action_policy_flow_e2e.rs +++ b/crates/moa-orchestrator/tests/integration/action_policy_flow_e2e.rs @@ -500,7 +500,6 @@ async fn create_pending_bash_review( session_id: Some(session_id), tenant_id: meta.tenant_id, user_id: fallback_tool_user_id(&meta), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: Some("worker-action-policy-e2e".to_string()), }; diff --git a/crates/moa-orchestrator/tests/integration/tool_executor_e2e.rs b/crates/moa-orchestrator/tests/integration/tool_executor_e2e.rs index ee4ec8a2..d85033b6 100644 --- a/crates/moa-orchestrator/tests/integration/tool_executor_e2e.rs +++ b/crates/moa-orchestrator/tests/integration/tool_executor_e2e.rs @@ -60,7 +60,6 @@ fn tool_request( session_id: Some(session_id), tenant_id: meta.tenant_id, user_id: fallback_tool_user_id(meta), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, } @@ -83,7 +82,6 @@ fn tool_request_with_provider_id( session_id: Some(session_id), tenant_id: meta.tenant_id, user_id: fallback_tool_user_id(meta), - idempotency_key: None, trusted_sandbox_manifest: None, worker_id: None, } diff --git a/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs b/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs index 32af8640..d5887a82 100644 --- a/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs +++ b/crates/moa-orchestrator/tests/orchestrator_db/workspace_authz_db.rs @@ -93,10 +93,10 @@ fn workspace_authz_backfill_migration_uses_current_model_version_static() { "V000322 inserted model_version must match moa_authz_schema::MODEL_VERSION" ); assert!( - !sql.contains("model_version = 3") - && !sql.contains("\n 3,\n") - && !sql.contains("-v3"), - "V000322 must not retain stale model_version 3 literals" + !sql.contains("model_version = 4") + && !sql.contains("\n 4,\n") + && !sql.contains("-v4"), + "V000322 must not retain stale model_version 4 literals" ); } diff --git a/crates/moa-orchestrator/tests/orchestrator_offline/tool_executor.rs b/crates/moa-orchestrator/tests/orchestrator_offline/tool_executor.rs index 9f9f0485..269a1346 100644 --- a/crates/moa-orchestrator/tests/orchestrator_offline/tool_executor.rs +++ b/crates/moa-orchestrator/tests/orchestrator_offline/tool_executor.rs @@ -87,11 +87,7 @@ fn registry_with_tools(tools: Vec>) -> ToolRegistry { registry } -fn tool_request( - tool_call_id: ToolCallId, - tool_name: &str, - idempotency_key: Option<&str>, -) -> ToolCallRequest { +fn tool_request(tool_call_id: ToolCallId, tool_name: &str) -> ToolCallRequest { ToolCallRequest { tool_call_id, provider_tool_use_id: None, @@ -101,7 +97,6 @@ fn tool_request( session_id: None, tenant_id: TenantId::from(Uuid::from_u128(1)), user_id: UserId::new("user-1"), - idempotency_key: idempotency_key.map(ToOwned::to_owned), trusted_sandbox_manifest: None, worker_id: None, } @@ -223,7 +218,7 @@ fn build_tool_run_plan_uses_max_attempts_one_for_idempotent_tools() { IdempotencyClass::Idempotent, read_tool_policy(ToolInputShape::Json), ); - let request = tool_request(ToolCallId::new(), "mock_read", None); + let request = tool_request(ToolCallId::new(), "mock_read"); let run_plan = build_tool_run_plan(&definition, &request).expect("build idempotent run plan"); @@ -246,21 +241,6 @@ fn non_idempotent_refuses_after_event_log_hit() { )); } -#[test] -fn keyed_tool_requires_idempotency_key() { - let definition = tool_definition( - "mock_keyed", - IdempotencyClass::IdempotentWithKey, - read_tool_policy(ToolInputShape::Json), - ); - let request = tool_request(ToolCallId::new(), "mock_keyed", None); - - let error = build_tool_run_plan(&definition, &request) - .expect_err("keyed tools should reject missing idempotency keys"); - - assert!(error.to_string().contains("requires idempotency_key")); -} - #[test] fn run_name_encodes_tool_call_id() { let tool_call_id = ToolCallId::new(); @@ -269,7 +249,7 @@ fn run_name_encodes_tool_call_id() { IdempotencyClass::Idempotent, read_tool_policy(ToolInputShape::Json), ); - let request = tool_request(tool_call_id, "mock_read", None); + let request = tool_request(tool_call_id, "mock_read"); let run_name = tool_run_name(&definition, &request).expect("build run name"); diff --git a/crates/moa-providers/src/adapters/anthropic/mod.rs b/crates/moa-providers/src/adapters/anthropic/mod.rs index 8e2d41b9..576a7b0f 100644 --- a/crates/moa-providers/src/adapters/anthropic/mod.rs +++ b/crates/moa-providers/src/adapters/anthropic/mod.rs @@ -24,7 +24,9 @@ use serde_json::{Map, Value, json}; use tokio::sync::mpsc; use tracing::Instrument; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, +}; use crate::core::http::build_http_client; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -94,8 +96,9 @@ impl AnthropicProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency is unbounded by default; operators opt in per key. - limiter: ConcurrencyLimiter::unbounded(), + // LLM concurrency defaults to a per-key in-flight bound; operators can + // override it (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), guard: RateGuard::new(), }) } diff --git a/crates/moa-providers/src/adapters/anthropic/tests.rs b/crates/moa-providers/src/adapters/anthropic/tests.rs index c15c9342..de39902a 100644 --- a/crates/moa-providers/src/adapters/anthropic/tests.rs +++ b/crates/moa-providers/src/adapters/anthropic/tests.rs @@ -661,3 +661,15 @@ fn provider_accepts_documented_default_models() { ModelId::new(MODEL_SONNET_4_6) ); } + +#[test] +fn default_chat_provider_uses_a_bounded_concurrency_gate() { + // Pins: a chat provider built from defaults gates in-flight requests with a + // finite bound rather than queueing unbounded, so one process cannot open an + // unlimited number of concurrent connections to the provider. + let provider = AnthropicProvider::new("test-key", MODEL_HAIKU_4_5).unwrap(); + assert!( + provider.limiter.is_bounded(), + "chat concurrency must default to a bounded in-flight gate" + ); +} diff --git a/crates/moa-providers/src/adapters/gemini/mod.rs b/crates/moa-providers/src/adapters/gemini/mod.rs index e3a3d773..d68dba0f 100644 --- a/crates/moa-providers/src/adapters/gemini/mod.rs +++ b/crates/moa-providers/src/adapters/gemini/mod.rs @@ -24,7 +24,9 @@ use serde_json::{Map, Value, json}; use tokio::sync::mpsc; use tracing::Instrument; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, +}; use crate::core::http::build_http_client; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -119,8 +121,9 @@ impl GeminiProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency is unbounded by default; operators opt in per key. - limiter: ConcurrencyLimiter::unbounded(), + // LLM concurrency defaults to a per-key in-flight bound; operators can + // override it (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), guard: RateGuard::new(), }) } diff --git a/crates/moa-providers/src/adapters/openai_responses/provider.rs b/crates/moa-providers/src/adapters/openai_responses/provider.rs index 65be0f40..95df8aa9 100644 --- a/crates/moa-providers/src/adapters/openai_responses/provider.rs +++ b/crates/moa-providers/src/adapters/openai_responses/provider.rs @@ -12,7 +12,9 @@ use tokio::sync::mpsc; use tracing::Instrument; use crate::adapters::openai_responses::{build_responses_request, stream_responses_with_retry}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, +}; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::models::{self, PROVIDER_OPENAI}; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -89,8 +91,9 @@ impl OpenAIProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency is unbounded by default; operators opt in per key. - limiter: ConcurrencyLimiter::unbounded(), + // LLM concurrency defaults to a per-key in-flight bound; operators can + // override it (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), guard: RateGuard::new(), }) } diff --git a/crates/moa-providers/src/core/concurrency.rs b/crates/moa-providers/src/core/concurrency.rs index 3f36b691..c388d550 100644 --- a/crates/moa-providers/src/core/concurrency.rs +++ b/crates/moa-providers/src/core/concurrency.rs @@ -41,6 +41,15 @@ pub(crate) const DEFAULT_EMBEDDING_CONCURRENCY: usize = 8; /// Default in-flight ceiling for rerank providers. pub(crate) const DEFAULT_RERANK_CONCURRENCY: usize = 8; +/// Default in-flight ceiling for chat/LLM providers, per API key. +/// +/// A generous bound that still prevents one process from opening an unbounded +/// number of simultaneous connections to a provider (exhausting sockets or the +/// key's concurrency window) when many turns fan out at once. Operators can raise +/// or lower it per key via `max_concurrent_requests`, and an explicit `0` opts +/// back into unbounded. +pub(crate) const DEFAULT_LLM_CONCURRENCY: usize = 32; + /// How long a call waits for a concurrency slot before reporting "blocked". /// /// A wait beyond this is treated as a failover-eligible block rather than an @@ -80,24 +89,10 @@ impl ConcurrencyLimiter { } } - /// Builds a limiter that never blocks (no in-flight ceiling). - pub(crate) fn unbounded() -> Self { - Self { inner: None } - } - - /// Waits for an in-flight slot and returns a permit that frees it on drop. - /// - /// Returns `None` for an unbounded limiter. The caller must bind the returned - /// permit for the lifetime of the outbound call (`let _permit = ...`); dropping - /// it early releases the slot before the request completes. - pub(crate) async fn acquire(&self) -> Option { - match &self.inner { - // `acquire_owned` only errors if the semaphore is closed, which never - // happens here (the owning provider holds the `Arc` for its lifetime); - // fall back to unbounded rather than panicking if it ever does. - Some(semaphore) => Arc::clone(semaphore).acquire_owned().await.ok(), - None => None, - } + /// Returns whether this limiter imposes a finite in-flight ceiling. + #[cfg(test)] + pub(crate) fn is_bounded(&self) -> bool { + self.inner.is_some() } /// Takes an in-flight slot, waiting at most `max_wait` for one to free up. @@ -148,7 +143,10 @@ mod tests { let in_flight = Arc::clone(&in_flight); let max_seen = Arc::clone(&max_seen); handles.push(tokio::spawn(async move { - let _permit = limiter.acquire().await; + let _permit = limiter + .acquire_within(Duration::from_secs(3600)) + .await + .expect("a slot frees within the wait"); let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1; max_seen.fetch_max(now, Ordering::SeqCst); // Force overlap: hold the slot across an await so contending tasks @@ -190,7 +188,7 @@ mod tests { "the slot frees for the next caller once the lease is dropped" ); assert!( - super::ConcurrencyLimiter::unbounded() + super::ConcurrencyLimiter::new(0) .acquire_within(Duration::ZERO) .await .is_some(), @@ -199,14 +197,13 @@ mod tests { } #[tokio::test] - async fn unbounded_limiter_admits_all_callers_without_a_permit() { - // Pins: the unbounded (LLM default) limiter never gates, so acquire yields - // no permit and imposes no in-flight ceiling. - let limiter = ConcurrencyLimiter::unbounded(); - assert!(limiter.acquire().await.is_none()); - - // A zero bound degrades to unbounded rather than a closed gate. - let zero = ConcurrencyLimiter::new(0); - assert!(zero.acquire().await.is_none()); + async fn zero_bound_limiter_is_unbounded_and_never_gates() { + // Pins: a zero bound degrades to an unbounded gate (the explicit opt-out), + // so acquire_within always yields a lease without blocking. + let limiter = ConcurrencyLimiter::new(0); + assert!( + limiter.acquire_within(Duration::ZERO).await.is_some(), + "a zero-bound (unbounded) gate never blocks" + ); } } diff --git a/crates/moa-providers/src/core/retry.rs b/crates/moa-providers/src/core/retry.rs index af10e508..5c805589 100644 --- a/crates/moa-providers/src/core/retry.rs +++ b/crates/moa-providers/src/core/retry.rs @@ -107,7 +107,7 @@ impl RetryPolicy { } let retry_eligible = Self::is_retryable_status(status) && attempt < self.max_retries; if retry_eligible && guard.allow_retry() { - let delay = rate_limit_delay.unwrap_or_else(|| self.delay_for_attempt(attempt)); + let delay = self.retry_delay(rate_limit_delay, attempt); tracing::warn!( attempt = attempt + 1, max_retries = self.max_retries, @@ -139,6 +139,23 @@ impl RetryPolicy { } } + /// Returns the delay before the next in-call retry. + /// + /// A server-supplied `Retry-After` (`rate_limit_delay`) is honored but capped + /// at [`max_delay`](Self::max_delay), so a hostile or misconfigured header + /// cannot pin a retry far beyond the exponential-backoff ceiling; when no + /// header is present, exponential backoff for `attempt` is used. + pub(crate) fn retry_delay( + &self, + rate_limit_delay: Option, + attempt: usize, + ) -> Duration { + match rate_limit_delay { + Some(delay) => delay.min(self.max_delay), + None => self.delay_for_attempt(attempt), + } + } + /// Returns the exponential backoff delay for one retry attempt. pub(crate) fn delay_for_attempt(&self, attempt: usize) -> Duration { let base = self.initial_delay.as_secs_f64() * self.backoff_factor.powi(attempt as i32); @@ -479,6 +496,33 @@ mod tests { server.abort(); } + #[test] + fn retry_after_is_capped_at_max_delay() { + // Pins: a server-supplied Retry-After above the backoff ceiling is capped + // at max_delay, a smaller hint is honored as-is, and an absent hint falls + // back to bounded exponential backoff. + let policy = RetryPolicy { + max_retries: 3, + initial_delay: Duration::from_secs(1), + max_delay: Duration::from_secs(60), + backoff_factor: 2.0, + }; + assert_eq!( + policy.retry_delay(Some(Duration::from_secs(3600)), 0), + Duration::from_secs(60), + "a hostile Retry-After must be capped at max_delay" + ); + assert_eq!( + policy.retry_delay(Some(Duration::from_secs(5)), 0), + Duration::from_secs(5), + "a Retry-After within the ceiling is honored as-is" + ); + assert!( + policy.retry_delay(None, 5) <= Duration::from_secs(60), + "the exponential fallback never exceeds max_delay" + ); + } + #[test] fn retry_after_delay_from_message_parses_structured_seconds() { // Pins: provider body hints can drive retry delay when Retry-After headers are absent. diff --git a/crates/moa-providers/src/embedding/cohere.rs b/crates/moa-providers/src/embedding/cohere.rs index 32bf305c..466f9329 100644 --- a/crates/moa-providers/src/embedding/cohere.rs +++ b/crates/moa-providers/src/embedding/cohere.rs @@ -7,11 +7,14 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_EMBEDDING_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, +}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; const COHERE_EMBEDDINGS_URL: &str = "https://api.cohere.com/v2/embed"; pub(super) const COHERE_DEFAULT_MODEL: &str = "embed-v4.0"; @@ -97,7 +100,10 @@ impl CohereEmbedding { async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // Take an in-flight slot before spending rate budget, then hold it across // the round trip (see `ConcurrencyLimiter` for the ordering rationale). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; // Cohere Embed is limited by inputs/min; pace on this chunk's input count. self.pacer.acquire(1, inputs.len() as u32).await; let payload: CohereEmbeddingResponse = post_json( diff --git a/crates/moa-providers/src/embedding/gemini.rs b/crates/moa-providers/src/embedding/gemini.rs index f6855b8c..6612c78c 100644 --- a/crates/moa-providers/src/embedding/gemini.rs +++ b/crates/moa-providers/src/embedding/gemini.rs @@ -11,12 +11,15 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_EMBEDDING_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, +}; use crate::core::http::{ build_json_http_client, decode_json_response, validate_embedding_count, validate_embedding_dimension, }; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; const GEMINI_ENDPOINT: &str = "https://generativelanguage.googleapis.com/v1beta"; pub(super) const GEMINI_V2_MODEL: &str = "gemini-embedding-2"; @@ -100,7 +103,10 @@ impl GeminiEmbeddingEmbedder { /// embeddings preserve request order one-to-one with `texts`. async fn embed_batch(&self, texts: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; self.pacer.acquire(1, texts.len() as u32).await; let requests = texts .iter() diff --git a/crates/moa-providers/src/embedding/openai.rs b/crates/moa-providers/src/embedding/openai.rs index f93a6852..5d493d8a 100644 --- a/crates/moa-providers/src/embedding/openai.rs +++ b/crates/moa-providers/src/embedding/openai.rs @@ -6,11 +6,14 @@ use moa_core::traits::EmbeddingProvider; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_EMBEDDING_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, +}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; const OPENAI_EMBEDDINGS_URL: &str = "https://api.openai.com/v1/embeddings"; const OPENAI_DIMENSIONS: usize = 1_536; @@ -69,7 +72,10 @@ impl OpenAIEmbedding { /// returned embeddings preserve request order one-to-one with `inputs`. async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; self.pacer.acquire(1, inputs.len() as u32).await; let payload: OpenAIEmbeddingResponse = post_json( &self.client, @@ -137,3 +143,67 @@ struct OpenAIEmbeddingData { index: usize, embedding: Vec, } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use moa_core::MoaError; + use moa_core::traits::EmbeddingProvider; + use tokio::io::AsyncReadExt; + use tokio::net::TcpListener; + use tokio::sync::oneshot; + + use super::OpenAIEmbedding; + + // Real-time (not paused): the holder keeps its slot by parking on a live, + // never-responding socket, which reqwest's IO driver cannot drive under a + // paused clock. The wait is bounded by `DEFAULT_BLOCK_THRESHOLD`. + #[tokio::test] + async fn embedding_reports_bounded_error_when_concurrency_gate_saturates() { + // Pins (F21): an embedding client whose single in-flight slot is already + // held returns a bounded, retryable RateLimited error once the block + // threshold elapses, instead of queueing indefinitely ahead of the HTTP + // timeout. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (ready_tx, ready_rx) = oneshot::channel(); + + // Accept the first request, signal that its in-flight slot is held, then + // keep the connection open (never respond) so the slot stays occupied. + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0_u8; 2048]; + let _ = socket.read(&mut buffer).await; + let _ = ready_tx.send(()); + std::future::pending::<()>().await; + }); + + let client = Arc::new( + OpenAIEmbedding::new("test-key", "text-embedding-3-small") + .unwrap() + .with_embeddings_url(format!("http://{address}/v1/embeddings")) + .with_max_concurrent_requests(1), + ); + + // The first call takes the only slot and parks in the server. + let holder = { + let client = Arc::clone(&client); + tokio::spawn(async move { client.embed(&["hold the slot".to_string()]).await }) + }; + ready_rx + .await + .expect("the first request should reach the server and hold the slot"); + + // The second call finds the gate saturated and must fail fast at the + // threshold rather than waiting on the 60s HTTP timeout. + let blocked = client.embed(&["blocked".to_string()]).await; + assert!( + matches!(blocked, Err(MoaError::RateLimited { .. })), + "a saturated embedding gate must return a bounded RateLimited error, got {blocked:?}" + ); + + holder.abort(); + server.abort(); + } +} diff --git a/crates/moa-providers/src/embedding/zeroentropy.rs b/crates/moa-providers/src/embedding/zeroentropy.rs index 39388e7f..e37b013d 100644 --- a/crates/moa-providers/src/embedding/zeroentropy.rs +++ b/crates/moa-providers/src/embedding/zeroentropy.rs @@ -6,11 +6,14 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_EMBEDDING_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, +}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; const ZEROENTROPY_EMBEDDINGS_URL: &str = "https://api.zeroentropy.dev/v1/models/embed"; /// Default ZeroEntropy embedding model id, used as a fixture by selector tests. @@ -85,7 +88,10 @@ impl ZeroEntropyEmbedding { async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; self.pacer.acquire(1, inputs.len() as u32).await; let payload: ZeroEntropyEmbeddingResponse = post_json( &self.client, diff --git a/crates/moa-providers/src/rerank/cohere.rs b/crates/moa-providers/src/rerank/cohere.rs index 2d40835d..96fff1c2 100644 --- a/crates/moa-providers/src/rerank/cohere.rs +++ b/crates/moa-providers/src/rerank/cohere.rs @@ -5,9 +5,12 @@ use moa_core::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_RERANK_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_RERANK_CONCURRENCY, +}; use crate::core::http::{build_json_http_client, post_json}; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; use super::{RerankHit, Reranker}; @@ -77,7 +80,10 @@ impl Reranker for CohereReranker { } // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; // Cohere Rerank is limited by requests/min. self.pacer.acquire(1, 0).await; let body: CohereRerankResponse = post_json( diff --git a/crates/moa-providers/src/rerank/zeroentropy.rs b/crates/moa-providers/src/rerank/zeroentropy.rs index 5b5f100f..4b0c275f 100644 --- a/crates/moa-providers/src/rerank/zeroentropy.rs +++ b/crates/moa-providers/src/rerank/zeroentropy.rs @@ -5,9 +5,12 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_RERANK_CONCURRENCY}; +use crate::core::concurrency::{ + ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_RERANK_CONCURRENCY, +}; use crate::core::http::{build_json_http_client, post_json}; use crate::core::pacer::{PacerConfig, RatePacer}; +use crate::core::rate_guard; use super::{RerankHit, Reranker}; @@ -111,7 +114,10 @@ impl Reranker for ZeroEntropyReranker { } // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = self.limiter.acquire().await; + let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + Some(lease) => lease, + None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + }; self.pacer.acquire(1, 0).await; let body: ZeroEntropyRerankResponse = post_json( &self.client, diff --git a/crates/moa-security/Cargo.toml b/crates/moa-security/Cargo.toml index 571ac898..012e23be 100644 --- a/crates/moa-security/Cargo.toml +++ b/crates/moa-security/Cargo.toml @@ -9,7 +9,6 @@ async-trait.workspace = true chrono.workspace = true globset.workspace = true moa-core = { workspace = true } -moka.workspace = true serde_json.workspace = true shell-words.workspace = true tokio.workspace = true diff --git a/crates/moa-security/src/lib.rs b/crates/moa-security/src/lib.rs index 8815d541..ec238dce 100644 --- a/crates/moa-security/src/lib.rs +++ b/crates/moa-security/src/lib.rs @@ -9,7 +9,7 @@ pub use injection::{ canary_system_message, inject_canary, inspect_input, new_canary_token, screen_tool_input_for_canary, wrap_untrusted_tool_output, }; -pub use mcp_proxy::{EnvironmentCredentialVault, MCPCredentialProxy, McpSessionToken}; +pub use mcp_proxy::{EnvironmentCredentialVault, MCPCredentialProxy}; pub use policies::{ ActionPolicies, ActionPolicyCheck, ActionPolicyContext, ActionPolicyRuleStore, glob_match, parse_and_match_command, stricter_effect, validate_action_policy_rule, diff --git a/crates/moa-security/src/mcp_proxy.rs b/crates/moa-security/src/mcp_proxy.rs index 0425a483..88e18b98 100644 --- a/crates/moa-security/src/mcp_proxy.rs +++ b/crates/moa-security/src/mcp_proxy.rs @@ -1,127 +1,54 @@ -//! Session-scoped credential proxying for MCP-backed tool calls. +//! Session-scoped credential resolution for MCP-backed tool calls. use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use moa_core::{ Credential, CredentialVault, McpCredentialConfig, McpServerConfig, MoaError, Result, SessionId, StoredCredentialMetadata, }; -use moka::future::Cache; use tokio::sync::RwLock; -use uuid::Uuid; -const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(15 * 60); -/// Upper bound on entries retained by each MCP proxy cache. -const MAX_CACHED_ENTRIES: u64 = 100_000; - -#[derive(Debug, Clone)] -struct ProxyGrant { - service: String, - scope: String, - expires_at: DateTime, -} - -/// Session-scoped opaque token for one proxied MCP credential grant. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct McpSessionToken(String); - -impl McpSessionToken { - /// Returns the opaque token string sent to the proxy boundary. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// MCP credential proxy that resolves real credentials via a vault only at call time. +/// MCP credential resolver that reads real credentials from a vault only at call time. pub struct MCPCredentialProxy { vault: Arc, - session_tokens: Cache, - token_ttl: Duration, } impl MCPCredentialProxy { - /// Creates a new MCP credential proxy. + /// Creates a new MCP credential resolver backed by `vault`. pub fn new(vault: Arc) -> Self { - Self { - vault, - session_tokens: build_token_cache(DEFAULT_TOKEN_TTL), - token_ttl: DEFAULT_TOKEN_TTL, - } - } - - /// Overrides the default token time-to-live. - pub fn with_token_ttl(mut self, token_ttl: Duration) -> Self { - self.token_ttl = token_ttl; - self.session_tokens = build_token_cache(token_ttl); - self + Self { vault } } - /// Creates a new session-scoped opaque token for one service and scope. - pub async fn create_session_token( - &self, - session_id: &SessionId, - service: impl Into, - scope: impl Into, - ) -> Result { - let token = format!("mcp_{}_{}", session_id, Uuid::now_v7()); - let expires_at = Utc::now() - + chrono::Duration::from_std(self.token_ttl).map_err(|error| { - MoaError::ValidationError(format!("invalid MCP proxy token ttl: {error}")) - })?; - self.session_tokens - .insert( - token.clone(), - ProxyGrant { - service: service.into(), - scope: scope.into(), - expires_at, - }, - ) - .await; - Ok(McpSessionToken(token)) - } - - /// Revokes a previously issued proxy token before it is used. - pub async fn revoke_session_token(&self, token: &McpSessionToken) { - self.session_tokens.invalidate(token.as_str()).await; - } - - /// Resolves and injects credential headers for one opaque proxy token. + /// Resolves credential headers for one MCP call by reading the vault directly. /// - /// The proxy grant is consumed before the vault lookup so grants cannot be - /// reused across requests while MOA only has a process-local grant store. + /// This is trusted, in-process host-side resolution: `server` and `operation` + /// select the vault credential and `config` shapes the injected headers. + /// + /// No proxy token is minted here. The previous design minted an opaque token + /// and consumed it inside this same host function, so the token added cache, + /// expiry, and allocation cost without ever crossing an isolation boundary. + /// Reintroduce a single-use token returned from this call — bound to + /// `session_id`, `server`, `operation`, an expiry, and one use — only when a + /// real remote proxy boundary sits between this resolver and the MCP + /// transport that consumes the credential. pub async fn enrich_headers( &self, - token: &McpSessionToken, + session_id: &SessionId, + server: &str, + operation: &str, config: Option<&McpCredentialConfig>, ) -> Result> { - let grant = self.lookup_grant(token).await?; - let credential = self.vault.get(&grant.service, &grant.scope).await?; + let credential = self.vault.get(server, operation).await?; + tracing::debug!( + %session_id, + server, + operation, + "resolved MCP credential headers from vault" + ); Ok(headers_from_credential(config, credential)) } - - async fn lookup_grant(&self, token: &McpSessionToken) -> Result { - // Per-entry TTL eviction (moka) plus a belt-and-suspenders expiry check - // replace the previous full-scan prune on every lookup. `remove` also - // makes the grant single-use without a separate write. - match self.session_tokens.remove(token.as_str()).await { - Some(grant) if grant.expires_at > Utc::now() => Ok(grant), - _ => Err(MoaError::PermissionDenied( - "unknown or expired MCP proxy token".to_string(), - )), - } - } -} - -fn build_token_cache(ttl: Duration) -> Cache { - Cache::builder() - .max_capacity(MAX_CACHED_ENTRIES) - .time_to_live(ttl) - .build() } /// Environment-backed credential vault built from MCP server configuration. @@ -256,14 +183,12 @@ fn headers_from_credential( mod tests { use std::collections::HashMap; use std::sync::Arc; - use std::time::Duration; use async_trait::async_trait; use moa_core::{ Credential, CredentialVault, McpCredentialConfig, McpServerConfig, SessionId, StoredCredentialMetadata, }; - use tokio::time::sleep; use uuid::Uuid; use super::{EnvironmentCredentialVault, MCPCredentialProxy}; @@ -303,8 +228,9 @@ mod tests { } #[tokio::test] - async fn proxy_creates_tokens_and_injects_headers() { - // Pins: MCP proxy tokens are opaque credentials for one call only. + async fn enrich_headers_resolves_bearer_credential_from_vault() { + // Pins: trusted host dispatch resolves the (server, operation) vault credential + // directly and shapes it into an Authorization header, with no token indirection. let vault: Arc = Arc::new(MockVault { values: HashMap::from([( ("github".to_string(), "github".to_string()), @@ -312,119 +238,42 @@ mod tests { )]), }); let proxy = MCPCredentialProxy::new(vault); - let token = proxy - .create_session_token(&SessionId::new(), "github", "github") - .await - .unwrap(); let headers = proxy .enrich_headers( - &token, + &SessionId::new(), + "github", + "github", Some(&McpCredentialConfig::Bearer { token_env: "GITHUB_TOKEN".to_string(), }), ) .await - .unwrap(); + .expect("bearer credential should resolve into an Authorization header"); assert_eq!( headers.get("Authorization"), Some(&"Bearer secret-token".to_string()) ); - assert!(!token.as_str().contains("secret-token")); - } - - #[tokio::test] - async fn proxy_grants_are_single_use() { - // Pins: a process-local MCP credential grant cannot be exposed across requests. - let vault: Arc = Arc::new(MockVault { - values: HashMap::from([( - ("github".to_string(), "github".to_string()), - Credential::Bearer("secret-token".to_string()), - )]), - }); - let proxy = MCPCredentialProxy::new(vault); - let token = proxy - .create_session_token(&SessionId::new(), "github", "github") - .await - .expect("test setup should create an MCP proxy token"); - - proxy - .enrich_headers(&token, None) - .await - .expect("first proxy enrichment should consume the grant"); - - let error = proxy - .enrich_headers(&token, None) - .await - .expect_err("second proxy enrichment should reject the consumed grant"); - - assert!( - matches!(error, moa_core::MoaError::PermissionDenied(ref message) if message.contains("unknown or expired MCP proxy token")) - ); - assert!( - !error.to_string().contains(token.as_str()), - "proxy errors must not echo the full opaque grant" - ); } #[tokio::test] - async fn proxy_token_is_rejected_after_ttl_expiry() { - // Pins: a session token past its TTL is rejected as unknown-or-expired, never silently honored. - let vault: Arc = Arc::new(MockVault { - values: HashMap::from([( - ("github".to_string(), "github".to_string()), - Credential::Bearer("secret-token".to_string()), - )]), - }); - let proxy = MCPCredentialProxy::new(vault).with_token_ttl(Duration::from_millis(1)); - let token = proxy - .create_session_token(&SessionId::new(), "github", "github") - .await - .expect("test setup should create an MCP proxy token"); - - sleep(Duration::from_millis(25)).await; - - let error = proxy - .enrich_headers(&token, None) - .await - .expect_err("an expired proxy token must not resolve credentials"); - assert!( - matches!(error, moa_core::MoaError::PermissionDenied(ref message) if message.contains("unknown or expired MCP proxy token")) - ); - assert!( - !error.to_string().contains(token.as_str()), - "expired-token errors must not echo the full opaque grant" + async fn enrich_headers_fails_closed_on_missing_credential() { + // Pins: an unconfigured (server, operation) credential is a typed vault error, + // never an empty header map that would send the MCP call unauthenticated. + let vault: Arc = Arc::new( + EnvironmentCredentialVault::from_mcp_servers(&[]) + .expect("an empty server list builds an empty vault"), ); - } - - #[tokio::test] - async fn revoked_proxy_token_is_rejected() { - // Pins: revoking a session token before use prevents any later credential resolution. - let vault: Arc = Arc::new(MockVault { - values: HashMap::from([( - ("github".to_string(), "github".to_string()), - Credential::Bearer("secret-token".to_string()), - )]), - }); let proxy = MCPCredentialProxy::new(vault); - let token = proxy - .create_session_token(&SessionId::new(), "github", "github") - .await - .expect("test setup should create an MCP proxy token"); - - proxy.revoke_session_token(&token).await; let error = proxy - .enrich_headers(&token, None) + .enrich_headers(&SessionId::new(), "unknown-server", "unknown-server", None) .await - .expect_err("a revoked proxy token must not resolve credentials"); - assert!( - matches!(error, moa_core::MoaError::PermissionDenied(ref message) if message.contains("unknown or expired MCP proxy token")) - ); + .expect_err("a missing credential must fail closed, not return empty headers"); + assert!( - !error.to_string().contains(token.as_str()), - "revoked-token errors must not echo the full opaque grant" + matches!(error, moa_core::MoaError::MissingEnvironmentVariable(message) if message.contains("credential not configured for service unknown-server")) ); } @@ -452,13 +301,11 @@ mod tests { }); let proxy = MCPCredentialProxy::new(vault); - let api_token = proxy - .create_session_token(&SessionId::new(), "api", "api") - .await - .expect("test setup should create an API key proxy token"); let api_headers = proxy .enrich_headers( - &api_token, + &SessionId::new(), + "api", + "api", Some(&McpCredentialConfig::ApiKey { header: "X-Configured-Header".to_string(), value_env: "UNUSED_AT_CALL_TIME".to_string(), @@ -473,12 +320,8 @@ mod tests { ); assert!(!api_headers.contains_key("Authorization")); - let oauth_token = proxy - .create_session_token(&SessionId::new(), "oauth", "oauth") - .await - .expect("test setup should create an OAuth proxy token"); let oauth_headers = proxy - .enrich_headers(&oauth_token, None) + .enrich_headers(&SessionId::new(), "oauth", "oauth", None) .await .expect("oauth enrichment should resolve an Authorization header"); assert_eq!( diff --git a/docker-compose.yml b/docker-compose.yml index d801b3ec..3ce1e273 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -242,6 +242,9 @@ services: MOA_SESSION_ATTACHMENT_ALLOW_HTTP: "true" MOA_SESSION_ATTACHMENT_VIRTUAL_HOSTED_STYLE: "false" MOA_PROVIDERS_OVERRIDE: ${MOA_PROVIDERS_OVERRIDE:-} + # Development-only: the local hand provider is fail-closed by default + # and must be opted into explicitly outside production. + MOA_CLOUD_HANDS_ALLOW_LOCAL: "true" MOA_REQUIRE_RESTATE_REGISTRATION_FOR_READINESS: "false" MOA_DEREGISTER_ON_SHUTDOWN: "false" MOA_METRICS_ENABLED: ${MOA_METRICS_ENABLED:-true} From 64b729590040537d5c84c83d6b6f8fa0eb38329f Mon Sep 17 00:00:00 2001 From: Hwuiwon Kim Date: Fri, 10 Jul 2026 15:44:50 -0400 Subject: [PATCH 3/4] audit 3 --- Cargo.lock | 1 + SEQUENCE-DIAGRAMS.md | 6 +- .../moa-auth/providers/src/contact_tokens.rs | 5 + crates/moa-brain/assets/ner-gazetteer.txt | 2 +- crates/moa-brain/examples/replay_corpus.rs | 2 +- crates/moa-brain/src/retrieval/ranking.rs | 4 +- .../hybrid_retrieval_db_memory.rs | 2 +- crates/moa-core/src/config/audit_security.rs | 17 +- crates/moa-core/src/config/auth.rs | 10 +- crates/moa-core/src/config/authz.rs | 11 +- crates/moa-core/src/config/context.rs | 9 - crates/moa-core/src/config/database.rs | 42 +- crates/moa-core/src/config/env_overlay.rs | 86 ++- crates/moa-core/src/config/learning.rs | 10 +- crates/moa-core/src/config/mod.rs | 15 +- crates/moa-core/src/config/providers.rs | 144 +++- .../examples/example-skill-suite.toml | 2 +- .../transcript.jsonl | 4 +- .../tests/fixtures/golden_100/01.json | 4 +- .../tests/fixtures/golden_100/03.json | 4 +- crates/moa-hands/src/core/registration.rs | 8 +- .../tests/hands_offline/security_defaults.rs | 131 +++- crates/moa-memory/ingest/src/contradiction.rs | 14 +- .../contradiction_candidates_db_memory.rs | 8 +- .../ingest/tests/fast_path_db_memory.rs | 10 +- crates/moa-orchestrator/src/runtime/deps.rs | 3 + crates/moa-providers/Cargo.toml | 1 + .../src/adapters/anthropic/mod.rs | 26 +- .../moa-providers/src/adapters/gemini/mod.rs | 26 +- .../src/adapters/openai_responses/provider.rs | 26 +- crates/moa-providers/src/core/concurrency.rs | 126 +++- .../src/core/concurrency_factory.rs | 354 ++++++++++ .../src/core/global_concurrency.rs | 577 ++++++++++++++++ crates/moa-providers/src/core/mod.rs | 2 + crates/moa-providers/src/core/pacer.rs | 6 + crates/moa-providers/src/embedding/cohere.rs | 20 +- crates/moa-providers/src/embedding/factory.rs | 72 +- crates/moa-providers/src/embedding/gemini.rs | 20 +- crates/moa-providers/src/embedding/openai.rs | 20 +- .../src/embedding/zeroentropy.rs | 20 +- crates/moa-providers/src/lib.rs | 1 + crates/moa-providers/src/rerank/cohere.rs | 20 +- crates/moa-providers/src/rerank/factory.rs | 42 +- .../moa-providers/src/rerank/zeroentropy.rs | 20 +- .../tests/cohere_reranker_live.rs | 2 +- .../cohere_reranker_offline.rs | 4 +- .../zeroentropy_reranker_offline.rs | 4 +- .../tests/zeroentropy_reranker_live.rs | 2 +- crates/moa-skills/src/format.rs | 27 +- .../tests/distillation_db_memory.rs | 14 +- .../tests/draft_proposals_db_memory.rs | 34 +- crates/moa-skills/tests/improver_db_memory.rs | 14 +- crates/moa-skills/tests/regression.rs | 6 +- crates/moa-skills/tests/support/common.rs | 2 +- ...ls.json => session_with_8_tool_calls.json} | 7 +- docs/09-skills-and-learning.md | 2 +- docs/23-environment-variables.md | 644 ++++++++++++++++++ fly.toml | 39 -- scripts/fly-live-smoke.sh | 115 ---- 59 files changed, 2404 insertions(+), 445 deletions(-) create mode 100644 crates/moa-providers/src/core/concurrency_factory.rs create mode 100644 crates/moa-providers/src/core/global_concurrency.rs rename crates/moa-skills/tests/support/fixtures/{session_with_5_tool_calls.json => session_with_8_tool_calls.json} (63%) create mode 100644 docs/23-environment-variables.md delete mode 100644 fly.toml delete mode 100755 scripts/fly-live-smoke.sh diff --git a/Cargo.lock b/Cargo.lock index 3dc50716..ae73a034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4228,6 +4228,7 @@ dependencies = [ "metrics", "moa-core", "moa-observability", + "moa-runtime-store", "moa-test-support", "opentelemetry", "reqwest 0.13.4", diff --git a/SEQUENCE-DIAGRAMS.md b/SEQUENCE-DIAGRAMS.md index 49b4d1fd..bd6288d2 100644 --- a/SEQUENCE-DIAGRAMS.md +++ b/SEQUENCE-DIAGRAMS.md @@ -57,7 +57,7 @@ sequenceDiagram Pipe-->>Brain: WorkingContext (cache_breakpoint marked) Brain->>LLM: complete(compiled_context) - LLM-->>Brain: ToolCall { bash: "fly deploy --app staging" } + LLM-->>Brain: ToolCall { bash: "deploy --app staging" } Brain->>Log: emit ApprovalRequested Orch->>Messaging: observe event @@ -70,7 +70,7 @@ sequenceDiagram Orch->>Brain: deliver signal Brain->>Log: emit ApprovalDecided - Brain->>Router: execute("bash", "fly deploy ...") + Brain->>Router: execute("bash", "deploy ...") Router->>Hand: provision (lazy, first call) Hand-->>Router: HandHandle Router->>Hand: execute(bash, input) @@ -384,7 +384,7 @@ sequenceDiagram OldBrain->>Log: emit ToolCall OldBrain->>Log: emit ToolResult - Note over OldBrain: 💥 process crash / machine restart
(panic, OOM, Fly.io suspend, Ctrl+C) + Note over OldBrain: 💥 process crash / machine restart
(panic, OOM, host suspend, Ctrl+C) Note over Orch: On next signal OR startup scan Orch->>Log: list_sessions(status in [Running, WaitingApproval]) diff --git a/crates/moa-auth/providers/src/contact_tokens.rs b/crates/moa-auth/providers/src/contact_tokens.rs index a2a1a039..05d0d5b2 100644 --- a/crates/moa-auth/providers/src/contact_tokens.rs +++ b/crates/moa-auth/providers/src/contact_tokens.rs @@ -85,6 +85,11 @@ impl ContactTokenVerifier { validation.set_required_spec_claims(&["exp", "nbf", "iat", "sub", "iss", "aud", "jti"]); validation.leeway = 30; + // Verification is stateless: `jti` is required for presence but is not + // checked against a revocation denylist, so the token's TTL is the only + // revocation window. A `jti` denylist (checked here) would let operators + // revoke a token before it expires — follow-up, not implemented. + decode::(token, &self.decoding_key, &validation) .map(|data| data.claims) .map_err(|error| match error.kind() { diff --git a/crates/moa-brain/assets/ner-gazetteer.txt b/crates/moa-brain/assets/ner-gazetteer.txt index 82947328..de85660a 100644 --- a/crates/moa-brain/assets/ner-gazetteer.txt +++ b/crates/moa-brain/assets/ner-gazetteer.txt @@ -4,7 +4,7 @@ postgres postgresql restate cohere -fly.io +railway aws pgvector memory graph diff --git a/crates/moa-brain/examples/replay_corpus.rs b/crates/moa-brain/examples/replay_corpus.rs index 59874935..d4d55365 100644 --- a/crates/moa-brain/examples/replay_corpus.rs +++ b/crates/moa-brain/examples/replay_corpus.rs @@ -34,7 +34,7 @@ use moa_session::testing; use serde::Serialize; use serde_json::json; -/// Per-turn compile budget, matching `CompactionConfig::max_input_tokens_per_turn`. +/// Per-turn compile budget used by this offline replay-cost example. const TURN_BUDGET: usize = 160_000; /// Reference input price for uncached tokens, dollars per MTok. const FRESH_PER_MTOK: f64 = 3.0; diff --git a/crates/moa-brain/src/retrieval/ranking.rs b/crates/moa-brain/src/retrieval/ranking.rs index e945e422..dd5947ba 100644 --- a/crates/moa-brain/src/retrieval/ranking.rs +++ b/crates/moa-brain/src/retrieval/ranking.rs @@ -472,14 +472,14 @@ mod tests { let query_tokens = normalize_tokens("fact01 fact04 auth deploy release cadence"); let explicit_identifier = row( "workspace", - "fact01 auth-service deployment flyio bluegreen Monday release window superseded", + "fact01 auth-service deployment railway bluegreen Monday release window superseded", reference_time, reference_time, None, ); let missing_identifier = row( "workspace", - "fact99 auth-service deployment flyio bluegreen Monday release window superseded", + "fact99 auth-service deployment railway bluegreen Monday release window superseded", reference_time, reference_time, None, diff --git a/crates/moa-brain/tests/brain_db_memory/hybrid_retrieval_db_memory.rs b/crates/moa-brain/tests/brain_db_memory/hybrid_retrieval_db_memory.rs index a7b4aab4..3422d91e 100644 --- a/crates/moa-brain/tests/brain_db_memory/hybrid_retrieval_db_memory.rs +++ b/crates/moa-brain/tests/brain_db_memory/hybrid_retrieval_db_memory.rs @@ -505,7 +505,7 @@ async fn hybrid_retrieval_db_memory_returns_fused_annotated_results() { None, ); let seed_uid = graph.create_node(seed).await.expect("create seed node"); - let exact_text = "auth service deployment provider is fly.io"; + let exact_text = "auth service deployment provider is railway"; let exact = node_intent( &storage_partition_id, NodeLabel::Fact, diff --git a/crates/moa-core/src/config/audit_security.rs b/crates/moa-core/src/config/audit_security.rs index 828ab301..9d4e0d7a 100644 --- a/crates/moa-core/src/config/audit_security.rs +++ b/crates/moa-core/src/config/audit_security.rs @@ -3,9 +3,24 @@ use serde::{Deserialize, Serialize}; /// OCSF security-event audit settings. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct AuditSecurityConfig { /// Emit allowed authorization decisions in addition to denied decisions. + /// + /// Defaults to `true`: compliance regimes generally require successful access + /// to be logged, not just denials. This increases audit volume, but the OCSF + /// audit sink has bounded drop behavior plus alertable drop metrics, so the + /// trail degrades gracefully under load rather than blocking requests. + /// Operators may set this to `false` once an alternative access-log + /// system-of-record is accepted. pub emit_authz_allows: bool, } + +impl Default for AuditSecurityConfig { + fn default() -> Self { + Self { + emit_authz_allows: true, + } + } +} diff --git a/crates/moa-core/src/config/auth.rs b/crates/moa-core/src/config/auth.rs index 30ccc91f..8f4c0c93 100644 --- a/crates/moa-core/src/config/auth.rs +++ b/crates/moa-core/src/config/auth.rs @@ -96,7 +96,12 @@ pub struct ContactTokenConfig { pub contact_point_hash_key_hex: String, /// TTL for unverified contact tokens. pub unverified_ttl_seconds: i64, - /// TTL for verified contact tokens. + /// TTL for verified contact tokens, in seconds. + /// + /// Contact-token verification is stateless (no jti denylist), so this TTL is + /// the effective revocation window: a leaked or revoked token remains usable + /// until it expires. Keep it short. A jti denylist would decouple revocation + /// from the TTL (follow-up, not implemented). pub verified_ttl_seconds: i64, /// TTL for one-time verification challenges. pub verification_ttl_seconds: i64, @@ -112,7 +117,8 @@ impl Default for ContactTokenConfig { public_key_pem: String::new(), contact_point_hash_key_hex: String::new(), unverified_ttl_seconds: 3600, - verified_ttl_seconds: 86_400, + // 2h: the TTL bounds the stateless revocation window (see field doc). + verified_ttl_seconds: 7_200, verification_ttl_seconds: 600, } } diff --git a/crates/moa-core/src/config/authz.rs b/crates/moa-core/src/config/authz.rs index 870e0cf2..38f070f2 100644 --- a/crates/moa-core/src/config/authz.rs +++ b/crates/moa-core/src/config/authz.rs @@ -7,7 +7,13 @@ use serde::{Deserialize, Serialize}; -const OPENFGA_DEFAULT_TIMEOUT_MS: u64 = 5000; +/// Default per-request OpenFGA timeout. +/// +/// Authorization checks are on a fail-closed hot path evaluated for nearly every +/// request, so a degraded OpenFGA must not stall each request for long. Kept +/// short (2s) to fail fast rather than amplify an OpenFGA slowdown into +/// request-wide latency. +const OPENFGA_DEFAULT_TIMEOUT_MS: u64 = 2000; /// Authorization subsystem configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -62,7 +68,8 @@ pub struct OpenFgaConfig { pub store_id: String, /// OpenFGA authorization model ID. pub model_id: String, - /// Per-request HTTP timeout in milliseconds. + /// Per-request HTTP timeout in milliseconds. Kept short because authz is a + /// fail-closed hot path; a slow OpenFGA should fail fast, not stall requests. #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, } diff --git a/crates/moa-core/src/config/context.rs b/crates/moa-core/src/config/context.rs index 918839ae..90fa86d0 100644 --- a/crates/moa-core/src/config/context.rs +++ b/crates/moa-core/src/config/context.rs @@ -327,12 +327,6 @@ pub struct CompactionConfig { pub recent_turns_verbatim: usize, /// Whether old error events must stay verbatim in the compiled view. pub preserve_errors: bool, - /// Trigger cache-aware trimming when older history exceeds this many blocks. - pub tier2_trigger_blocks_past_bp4: usize, - /// Trigger summarization when the turn approaches this fraction of the model context window. - pub tier3_trigger_fraction: f64, - /// Hard ceiling for input tokens per turn after compaction. - pub max_input_tokens_per_turn: usize, } impl Default for CompactionConfig { @@ -343,9 +337,6 @@ impl Default for CompactionConfig { token_ratio_threshold: 0.7, recent_turns_verbatim: 5, preserve_errors: true, - tier2_trigger_blocks_past_bp4: 14, - tier3_trigger_fraction: 0.9, - max_input_tokens_per_turn: 160_000, } } } diff --git a/crates/moa-core/src/config/database.rs b/crates/moa-core/src/config/database.rs index 6d6a2a2c..ca746850 100644 --- a/crates/moa-core/src/config/database.rs +++ b/crates/moa-core/src/config/database.rs @@ -2,6 +2,13 @@ use serde::{Deserialize, Serialize}; +/// Built-in development database URL, used when no URL is configured. +/// +/// It embeds the local compose dev password, so a deployment left on this value +/// is running against development credentials; config validation warns when it is +/// in effect. +pub const BUILTIN_DEV_DATABASE_URL: &str = "postgres://moa_owner:dev@localhost:10040/moa"; + /// Session database configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -14,7 +21,12 @@ pub struct DatabaseConfig { pub schema: Option, /// Maximum pool size for the shared Postgres client. pub max_connections: u32, - /// Connection timeout in seconds. + /// Pool acquire timeout, in seconds. + /// + /// Applied as the sqlx pool `acquire_timeout` (see `runtime::database`): the + /// maximum time a caller waits to check out a pooled connection, which also + /// bounds establishing a new connection when the pool must grow to serve the + /// request. It is not limited to the initial connection handshake. pub connect_timeout_seconds: u64, /// Optional Neon branching configuration for ephemeral checkpoints. pub neon: DatabaseNeonConfig, @@ -23,7 +35,7 @@ pub struct DatabaseConfig { impl Default for DatabaseConfig { fn default() -> Self { Self { - url: "postgres://moa_owner:dev@localhost:10040/moa".to_string(), + url: BUILTIN_DEV_DATABASE_URL.to_string(), admin_url: None, schema: None, max_connections: 20, @@ -34,6 +46,12 @@ impl Default for DatabaseConfig { } impl DatabaseConfig { + /// Returns whether the runtime URL is the built-in development default, + /// which embeds dev credentials and should not be used in production. + pub fn uses_builtin_dev_url(&self) -> bool { + self.url == BUILTIN_DEV_DATABASE_URL + } + /// Returns the configured runtime database URL. pub fn runtime_url(&self) -> &str { &self.url @@ -81,3 +99,23 @@ impl Default for DatabaseNeonConfig { } } } + +#[cfg(test)] +mod tests { + use super::{BUILTIN_DEV_DATABASE_URL, DatabaseConfig}; + + #[test] + fn uses_builtin_dev_url_detects_the_default_dev_credentials() { + // Pins: the condition that drives the dev-credential startup warning — the + // built-in default URL is recognized, and any configured URL is not. + let default = DatabaseConfig::default(); + assert_eq!(default.url, BUILTIN_DEV_DATABASE_URL); + assert!(default.uses_builtin_dev_url()); + + let configured = DatabaseConfig { + url: "postgres://user:secret@prod-db:5432/moa".to_string(), + ..DatabaseConfig::default() + }; + assert!(!configured.uses_builtin_dev_url()); + } +} diff --git a/crates/moa-core/src/config/env_overlay.rs b/crates/moa-core/src/config/env_overlay.rs index 25b95089..f79e22a6 100644 --- a/crates/moa-core/src/config/env_overlay.rs +++ b/crates/moa-core/src/config/env_overlay.rs @@ -78,6 +78,14 @@ pub struct MoaEnvOverlay { pub zeroentropy_max_inputs_per_min: Option, /// `MOA_ZEROENTROPY_MAX_CONCURRENT_REQUESTS`. pub zeroentropy_max_concurrent_requests: Option, + /// `MOA_PROVIDERS_CONCURRENCY_SCOPE` (`local` | `global`). + pub providers_concurrency_scope: Option, + /// `MOA_PROVIDERS_CONCURRENCY_DEFAULT_MAX_IN_FLIGHT`. + pub providers_concurrency_default_max_in_flight: Option, + /// `MOA_PROVIDERS_CONCURRENCY_BLOCK_THRESHOLD_MS`. + pub providers_concurrency_block_threshold_ms: Option, + /// `MOA_PROVIDERS_CONCURRENCY_LEASE_TTL_MS`. + pub providers_concurrency_lease_ttl_ms: Option, /// `MOA_DATABASE_URL`. pub database_url: Option, /// `MOA_DATABASE_ADMIN_URL`. @@ -365,12 +373,6 @@ pub struct MoaEnvOverlay { pub compaction_recent_turns_verbatim: Option, /// `MOA_COMPACTION_PRESERVE_ERRORS`. pub compaction_preserve_errors: Option, - /// `MOA_COMPACTION_TIER2_TRIGGER_BLOCKS_PAST_BP4`. - pub compaction_tier2_trigger_blocks_past_bp4: Option, - /// `MOA_COMPACTION_TIER3_TRIGGER_FRACTION`. - pub compaction_tier3_trigger_fraction: Option, - /// `MOA_COMPACTION_MAX_INPUT_TOKENS_PER_TURN`. - pub compaction_max_input_tokens_per_turn: Option, /// `MOA_RESTATE_INGRESS_URL`. pub restate_ingress_url: Option, /// `MOA_RESTATE_ADMIN_URL`. @@ -807,6 +809,14 @@ fn exact_overlay_path(field: &str) -> Option> { "restate_ingress_url" => &["orchestrator", "restate_ingress_url"], "restate_admin_url" => &["orchestrator", "restate_admin_url"], "restate_llm_gateway_url" => &["orchestrator", "llm_gateway_url"], + "providers_concurrency_scope" => &["providers", "concurrency", "scope"], + "providers_concurrency_default_max_in_flight" => { + &["providers", "concurrency", "default_max_in_flight"] + } + "providers_concurrency_block_threshold_ms" => { + &["providers", "concurrency", "block_threshold_ms"] + } + "providers_concurrency_lease_ttl_ms" => &["providers", "concurrency", "lease_ttl_ms"], _ => return None, }; Some(strings(path)) @@ -938,7 +948,8 @@ fn optional_section_seed(path: &[&str]) -> Option { "preshared_key": "", "store_id": "", "model_id": "", - "timeout_ms": 5000, + // Mirror OpenFgaConfig's serde default (OPENFGA_DEFAULT_TIMEOUT_MS). + "timeout_ms": 2000, })), ["clickhouse"] => Some(json!({ "url": "", @@ -1025,7 +1036,6 @@ const ALLOWLIST_PREFIXES: &[&str] = &[ "MOA_PENTEST_", // cross-tenant pentest harness knobs "MOA_LOADTEST_", // k6/loadtest harness knobs "MOA_CLEAN_E2E_", // run-clean-e2e.sh harness knobs - "MOA_FLY_", // Fly.io deploy script knobs "MOA_RUSTFS_", // local compose object-store ports "MOA_FGA_", // OpenFGA bootstrap script knobs "MOA_BOOTSTRAP_", // tenant/user bootstrap script knobs @@ -1045,7 +1055,6 @@ const ALLOWLIST_PREFIXES: &[&str] = &[ /// Approved `MOA_*` variables that live in a namespace shared with real overlay /// fields (so a prefix would be unsafe) or that stand alone. Kept exact. const ALLOWLIST_EXACT: &[&str] = &[ - "MOA__DATABASE__URL", // `config`-crate double-underscore nested form "MOA_CONFIG_ENV_STRICT", // this audit's own strictness switch "MOA_AUDIT_BUCKET", // audit shipper (MOA_AUDIT_ collides with overlay) "MOA_AUDIT_OBJECT_LOCK_MODE", @@ -1297,6 +1306,65 @@ mod tests { list.iter().map(|name| (*name).to_string()).collect() } + /// Regeneration tool for `docs/23-environment-variables.md`. + /// + /// Dumps every overlay variable with its config path and default, derived + /// from `MoaEnvOverlay` (serde field enumeration) and `MoaConfig::default()`, + /// so the reference doc is never hand-transcribed. Run with: + /// `cargo test -p moa-core dump_env_var_reference -- --ignored --nocapture`. + #[test] + #[ignore = "dev tool: regenerates the env-var reference doc table"] + fn dump_env_var_reference() { + let mut schema = serde_json::to_value(MoaConfig::default()).expect("serialize config"); + seed_optional_sections(&mut schema).expect("seed optional sections"); + let overlay = serde_json::to_value(MoaEnvOverlay::default()).expect("serialize overlay"); + let Value::Object(fields) = overlay else { + panic!("overlay must serialize to an object"); + }; + + let mut rows = Vec::new(); + for field in fields.keys() { + let env = format!("MOA_{}", field.to_uppercase()); + let path = overlay_path(field, &schema).expect("resolve overlay path"); + let default = walk_default(&schema, &path); + // Secret = actual credential material. Suffix-matched so identifiers + // and counters that merely contain KEY/TOKEN (`_KEY_ID`, `_TOKENS`, + // `_TOKEN_ESTIMATES`, `_TTL_SECONDS`) are NOT flagged, while real + // keys/secrets/passwords/private-key and hex key material are. + let secret = env.ends_with("_KEY") + || env.ends_with("_KEY_HEX") + || env.ends_with("_SECRET") + || env.ends_with("_SECRET_HEX") + || env.ends_with("_PASSWORD") + || env.ends_with("_PRIVATE_KEY_PEM") + || env.ends_with("_AUTH_TOKEN") + || env.ends_with("_APP_TOKEN") + || env.ends_with("_BOT_TOKEN"); + rows.push((path[0].clone(), env, path.join("."), default, secret)); + } + rows.sort(); + println!("SECTION\tENV_VAR\tCONFIG_PATH\tDEFAULT\tSECRET"); + for (section, env, path, default, secret) in rows { + println!("{section}\t{env}\t{path}\t{default}\t{secret}"); + } + } + + fn walk_default(root: &Value, path: &[String]) -> String { + let mut cursor = root; + for segment in path { + match cursor.get(segment) { + Some(next) => cursor = next, + None => return "(unset)".to_string(), + } + } + match cursor { + Value::Null => "(none)".to_string(), + Value::String(text) if text.is_empty() => "(empty)".to_string(), + Value::String(text) => text.clone(), + other => other.to_string(), + } + } + #[test] fn registry_flags_unknown_overlay_typo() { // Pins: a misspelled overlay var (which envy silently ignores) is detected. diff --git a/crates/moa-core/src/config/learning.rs b/crates/moa-core/src/config/learning.rs index bd7b4caa..fa523eb7 100644 --- a/crates/moa-core/src/config/learning.rs +++ b/crates/moa-core/src/config/learning.rs @@ -14,12 +14,18 @@ pub struct LearningConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SkillLearningConfig { - /// Minimum tool-call count required before a segment can be considered. + /// Minimum tool-call count a segment must contain before it is eligible for + /// skill distillation. + /// + /// This is a cheap pre-LLM filter: a segment shorter than this cannot hold a + /// reusable multi-step procedure worth distilling, so it is rejected before + /// any paid distillation call. Set high enough to exclude trivial + /// three-to-five-call tasks. pub min_tool_calls: usize, } impl Default for SkillLearningConfig { fn default() -> Self { - Self { min_tool_calls: 5 } + Self { min_tool_calls: 8 } } } diff --git a/crates/moa-core/src/config/mod.rs b/crates/moa-core/src/config/mod.rs index d9c9d30d..2bec1dd3 100644 --- a/crates/moa-core/src/config/mod.rs +++ b/crates/moa-core/src/config/mod.rs @@ -58,7 +58,10 @@ pub use memory::{ }; pub use messaging::MessagingConfig; pub use orchestrator::OrchestratorConfig; -pub use providers::{GeneralConfig, ModelsConfig, ProviderCredentialConfig, ProvidersConfig}; +pub use providers::{ + ConcurrencyScope, GeneralConfig, ModelsConfig, ProviderConcurrencyConfig, + ProviderCredentialConfig, ProvidersConfig, +}; pub use runtime_cache::{RuntimeCacheBackend, RuntimeCacheConfig}; pub use sandbox::{ CloudConfig, CloudHandsConfig, LocalConfig, McpCredentialConfig, McpServerConfig, @@ -178,6 +181,15 @@ impl MoaConfig { )); } + if self.database.uses_builtin_dev_url() { + // Fails safe (the default targets localhost), so warn rather than reject: + // a real deployment should configure database.url / MOA_DATABASE_URL. + tracing::warn!( + "using built-in development database credentials; set database.url \ + (MOA_DATABASE_URL) for any non-development deployment" + ); + } + if self.general.default_provider.trim().is_empty() { return Err(MoaError::ConfigError( "general.default_provider is required and must be a non-empty provider key" @@ -199,6 +211,7 @@ impl MoaConfig { } self.session.validate()?; + self.providers.validate()?; if let Some(clickhouse) = &self.clickhouse { clickhouse.validate()?; diff --git a/crates/moa-core/src/config/providers.rs b/crates/moa-core/src/config/providers.rs index c2bf1737..501d62a7 100644 --- a/crates/moa-core/src/config/providers.rs +++ b/crates/moa-core/src/config/providers.rs @@ -2,6 +2,83 @@ use serde::{Deserialize, Serialize}; +use crate::error::MoaError; + +/// Where provider in-flight concurrency is coordinated. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConcurrencyScope { + /// Each process enforces its own in-flight ceiling (single-node/dev default). + #[default] + Local, + /// The ceiling is shared across replicas via the runtime coordination store, + /// so an autoscaled fleet does not multiply a shared provider/API-key quota. + Global, +} + +/// In-flight concurrency limits applied to outbound provider calls. +/// +/// Provider rate limits are tied to the account's tier (Anthropic tier N, a +/// Cohere trial vs production key, etc.), so the in-flight ceiling is expressed +/// **per provider** via that provider's `max_concurrent_requests` — the natural +/// place an operator states their tier. That one budget is shared across every +/// call kind the credential serves (e.g. Cohere embed + rerank on one key). +/// [`default_max_in_flight`](Self::default_max_in_flight) is the workspace-wide +/// fallback for any provider that sets no explicit limit, so nothing is unbounded +/// by accident (`0` = explicitly unbounded). `scope` selects process-local +/// enforcement (default) or cross-replica coordination through the runtime store; +/// `global` scope additionally uses `lease_ttl_ms` as the crash backstop for a +/// held slot (see the limiter's TTL derivation). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProviderConcurrencyConfig { + /// Whether the ceiling is enforced per process or shared across replicas. + pub scope: ConcurrencyScope, + /// Fallback in-flight ceiling for any provider that sets no + /// `max_concurrent_requests` of its own (`0` = unbounded). Operators express + /// their per-provider account tier via each provider's own setting; this + /// keeps unconfigured providers bounded rather than unbounded. + pub default_max_in_flight: u32, + /// How long a caller waits for a slot before reporting "saturated", in ms. + pub block_threshold_ms: u64, + /// Global-scope lease time-to-live, in ms: the crash backstop for a held slot. + /// + /// Must exceed the longest provider call. Non-streaming calls are bounded by + /// the 60s HTTP request timeout; streaming chat has no whole-request timeout, + /// so this is the operator-tunable ceiling for the longest expected stream. A + /// killed replica's slots self-expire after this TTL. + pub lease_ttl_ms: u64, +} + +impl Default for ProviderConcurrencyConfig { + fn default() -> Self { + Self { + scope: ConcurrencyScope::Local, + default_max_in_flight: 16, + block_threshold_ms: 2_000, + lease_ttl_ms: 600_000, + } + } +} + +impl ProviderConcurrencyConfig { + /// Validates the concurrency settings, rejecting nonsensical durations. + pub fn validate(&self) -> Result<(), MoaError> { + if self.block_threshold_ms == 0 { + return Err(MoaError::ConfigError( + "providers.concurrency.block_threshold_ms must be greater than zero".to_string(), + )); + } + if self.scope == ConcurrencyScope::Global && self.lease_ttl_ms == 0 { + return Err(MoaError::ConfigError( + "providers.concurrency.lease_ttl_ms must be greater than zero for global scope" + .to_string(), + )); + } + Ok(()) + } +} + /// General runtime settings. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -75,10 +152,11 @@ pub struct ProviderCredentialConfig { /// Optional per-minute input-rate cap; `None` keeps the provider default. #[serde(skip_serializing_if = "Option::is_none")] pub max_inputs_per_min: Option, - /// Optional in-flight concurrency cap; `None` keeps the provider default - /// (embedding/rerank default to a small window; chat/LLM default to a - /// generous per-key in-flight bound). An explicit `0` opts back into - /// unbounded. + /// In-flight concurrency ceiling for this provider account — the natural + /// place to express the credential's tier. This one budget is shared across + /// every call kind the credential serves (chat, embedding, rerank). `None` + /// falls back to `providers.concurrency.default_max_in_flight`; an explicit + /// `0` opts back into unbounded. #[serde(skip_serializing_if = "Option::is_none")] pub max_concurrent_requests: Option, } @@ -115,11 +193,67 @@ pub struct ProvidersConfig { pub cohere: ProviderCredentialConfig, /// ZeroEntropy credentials. pub zeroentropy: ProviderCredentialConfig, + /// In-flight concurrency limits and coordination scope for provider calls. + #[serde(default)] + pub concurrency: ProviderConcurrencyConfig, +} + +impl ProvidersConfig { + /// Validates provider configuration, currently the concurrency settings. + pub fn validate(&self) -> Result<(), MoaError> { + self.concurrency.validate() + } } #[cfg(test)] mod tests { - use super::ProviderCredentialConfig; + use super::{ + ConcurrencyScope, ProviderConcurrencyConfig, ProviderCredentialConfig, ProvidersConfig, + }; + + #[test] + fn provider_concurrency_config_defaults_to_local_bounded_scope() { + // Pins: single-node/dev behavior defaults to process-local scope with a + // bounded fallback ceiling (nothing unbounded by accident), and validates. + let config = ProviderConcurrencyConfig::default(); + assert_eq!(config.scope, ConcurrencyScope::Local); + assert_eq!(config.default_max_in_flight, 16); + assert!(config.validate().is_ok()); + } + + #[test] + fn provider_concurrency_config_parses_global_scope_and_rejects_bad_durations() { + // Pins: operators opt into global coordination via config; a zero block + // threshold and a zero global lease TTL are rejected. + let parsed: ProvidersConfig = serde_json::from_value(serde_json::json!({ + "concurrency": { "scope": "global", "default_max_in_flight": 64, "lease_ttl_ms": 300000 } + })) + .expect("providers config with global concurrency should parse"); + assert_eq!(parsed.concurrency.scope, ConcurrencyScope::Global); + assert_eq!(parsed.concurrency.default_max_in_flight, 64); + assert!(parsed.validate().is_ok()); + + let bad = ProviderConcurrencyConfig { + block_threshold_ms: 0, + ..ProviderConcurrencyConfig::default() + }; + assert!(bad.validate().is_err(), "zero block threshold is invalid"); + + let mut bad_ttl = ProviderConcurrencyConfig { + scope: ConcurrencyScope::Global, + lease_ttl_ms: 0, + ..ProviderConcurrencyConfig::default() + }; + assert!( + bad_ttl.validate().is_err(), + "global scope with zero lease TTL is invalid" + ); + bad_ttl.scope = ConcurrencyScope::Local; + assert!( + bad_ttl.validate().is_ok(), + "a zero lease TTL is only rejected under global scope" + ); + } #[test] fn provider_credential_config_round_trips_rate_and_concurrency_caps() { diff --git a/crates/moa-eval/examples/example-skill-suite.toml b/crates/moa-eval/examples/example-skill-suite.toml index 41647e5f..d8c6678c 100644 --- a/crates/moa-eval/examples/example-skill-suite.toml +++ b/crates/moa-eval/examples/example-skill-suite.toml @@ -1,5 +1,5 @@ [suite] -name = "deploy-to-fly-regression" +name = "deploy-to-staging-regression" description = "Example regression suite for a single skill" default_timeout_seconds = 120 tags = ["skill", "regression"] diff --git a/crates/moa-eval/scenarios/long_conversation/long_running_deploy_with_approval_pause_and_resume/transcript.jsonl b/crates/moa-eval/scenarios/long_conversation/long_running_deploy_with_approval_pause_and_resume/transcript.jsonl index 8f3ff568..e28c05ec 100644 --- a/crates/moa-eval/scenarios/long_conversation/long_running_deploy_with_approval_pause_and_resume/transcript.jsonl +++ b/crates/moa-eval/scenarios/long_conversation/long_running_deploy_with_approval_pause_and_resume/transcript.jsonl @@ -5,8 +5,8 @@ {"user":{"text":"deploy turn 01: read deploy config."},"expected":[{"type":"text_delta","text":"Deploy target is the fixture app in region iad."},{"type":"usage","usage":{"input_tokens_uncached":92,"input_tokens_cache_write":0,"input_tokens_cache_read":212,"output_tokens":11}},{"type":"terminal","stop_reason":"end_turn"}]} {"user":{"text":"deploy turn 02: confirm auto mode for deploy."},"expected":[{"type":"text_delta","text":"Auto mode will run the shell deploy command without a blocking gate: auto_mode_executes_bash_deploy and session_continues_without_waiting_state."},{"type":"usage","usage":{"input_tokens_uncached":94,"input_tokens_cache_write":0,"input_tokens_cache_read":214,"output_tokens":21}},{"type":"terminal","stop_reason":"end_turn"}]} {"user":{"text":"deploy turn 03: confirm no user gate."},"expected":[{"type":"text_delta","text":"No user approval signal is needed before the deterministic deploy command."},{"type":"usage","usage":{"input_tokens_uncached":96,"input_tokens_cache_write":0,"input_tokens_cache_read":216,"output_tokens":13}},{"type":"terminal","stop_reason":"end_turn"}]} -{"user":{"text":"deploy turn 04: run mocked fly deploy."},"expected":[{"type":"tool_call","call":{"invocation":{"id":"00000000-0000-0000-0000-000000020003","name":"bash","input":{"cmd":"printf 'fly deploy mocked: release v17 complete\\n'","timeout_secs":5}}}},{"type":"terminal","stop_reason":"tool_use"}]} -{"user":{"text":"deploy turn 04: run mocked fly deploy."},"expected":[{"type":"text_delta","text":"The mocked deploy completed and emitted release v17."},{"type":"usage","usage":{"input_tokens_uncached":98,"input_tokens_cache_write":0,"input_tokens_cache_read":218,"output_tokens":12}},{"type":"terminal","stop_reason":"end_turn"}]} +{"user":{"text":"deploy turn 04: run mocked deploy."},"expected":[{"type":"tool_call","call":{"invocation":{"id":"00000000-0000-0000-0000-000000020003","name":"bash","input":{"cmd":"printf 'deploy mocked: release v17 complete\\n'","timeout_secs":5}}}},{"type":"terminal","stop_reason":"tool_use"}]} +{"user":{"text":"deploy turn 04: run mocked deploy."},"expected":[{"type":"text_delta","text":"The mocked deploy completed and emitted release v17."},{"type":"usage","usage":{"input_tokens_uncached":98,"input_tokens_cache_write":0,"input_tokens_cache_read":218,"output_tokens":12}},{"type":"terminal","stop_reason":"end_turn"}]} {"user":{"text":"deploy turn 05: queue a health follow-up while deploy output is processing."},"expected":[{"type":"text_delta","text":"The health check follow-up is queued behind the current deploy turn, not interleaved: queued_message_processed_after_current_turn_not_during."},{"type":"usage","usage":{"input_tokens_uncached":100,"input_tokens_cache_write":0,"input_tokens_cache_read":220,"output_tokens":20}},{"type":"terminal","stop_reason":"end_turn"}]} {"user":{"text":"deploy turn 06: verify release marker file."},"expected":[{"type":"tool_call","call":{"invocation":{"id":"00000000-0000-0000-0000-000000020004","name":"file_write","input":{"path":"deploy/release.txt","content":"release=v17\nstatus=deployed\n"}}}},{"type":"terminal","stop_reason":"tool_use"}]} {"user":{"text":"deploy turn 06: verify release marker file."},"expected":[{"type":"text_delta","text":"Recorded release v17 as deployed."},{"type":"usage","usage":{"input_tokens_uncached":102,"input_tokens_cache_write":0,"input_tokens_cache_read":222,"output_tokens":9}},{"type":"terminal","stop_reason":"end_turn"}]} diff --git a/crates/moa-eval/tests/fixtures/golden_100/01.json b/crates/moa-eval/tests/fixtures/golden_100/01.json index ab08b545..4fb73a01 100644 --- a/crates/moa-eval/tests/fixtures/golden_100/01.json +++ b/crates/moa-eval/tests/fixtures/golden_100/01.json @@ -1,8 +1,8 @@ { "uid_seed": "01", "label": "Fact", - "name": "fact01 auth-service deployment flyio bluegreen Monday release window", - "summary": "fact01 auth-service deployment flyio bluegreen Monday release window", + "name": "fact01 auth-service deployment railway bluegreen Monday release window", + "summary": "fact01 auth-service deployment railway bluegreen Monday release window", "entity_uids": [ "auth-service", "deployment" diff --git a/crates/moa-eval/tests/fixtures/golden_100/03.json b/crates/moa-eval/tests/fixtures/golden_100/03.json index 1a04ccc7..c3bd2423 100644 --- a/crates/moa-eval/tests/fixtures/golden_100/03.json +++ b/crates/moa-eval/tests/fixtures/golden_100/03.json @@ -1,8 +1,8 @@ { "uid_seed": "03", "label": "Fact", - "name": "fact03 auth-service deployment rollback keeps previous fly machine group warm", - "summary": "fact03 auth-service deployment rollback keeps previous fly machine group warm", + "name": "fact03 auth-service deployment rollback keeps previous railway machine group warm", + "summary": "fact03 auth-service deployment rollback keeps previous railway machine group warm", "entity_uids": [ "auth-service", "deployment" diff --git a/crates/moa-hands/src/core/registration.rs b/crates/moa-hands/src/core/registration.rs index a08b830a..658b411d 100644 --- a/crates/moa-hands/src/core/registration.rs +++ b/crates/moa-hands/src/core/registration.rs @@ -98,7 +98,13 @@ impl RegisteredTool { schema: tool.input_schema, policy: ToolPolicySpec { risk_level: moa_core::RiskLevel::High, - default_effect: ActionPolicyEffect::Allow, + // MCP/third-party tools have no considered per-tool descriptor + // gate (unlike builtins), so they default to admin review rather + // than a bare allow: unvetted external code should not execute + // unattended. This is only the fallback effect — an explicit + // operator rule or `permissions` config (allow/deny/admin_review) + // still wins in `ActionPolicies::check`. + default_effect: ActionPolicyEffect::AdminReview, action_class: ActionClass::ExternalWrite, input_shape: ToolInputShape::Json, diff_strategy: ToolDiffStrategy::None, diff --git a/crates/moa-hands/tests/hands_offline/security_defaults.rs b/crates/moa-hands/tests/hands_offline/security_defaults.rs index e379fd61..e70ea610 100644 --- a/crates/moa-hands/tests/hands_offline/security_defaults.rs +++ b/crates/moa-hands/tests/hands_offline/security_defaults.rs @@ -4,11 +4,13 @@ use std::sync::Arc; use async_trait::async_trait; use moa_core::{ - ActionClass, ActionPolicyEffect, BuiltInTool, CloudHandsConfig, IdempotencyClass, MoaConfig, - MoaError, ModelId, Result, RiskLevel, SessionMeta, TenantId, ToolContext, ToolDiffStrategy, - ToolInputShape, ToolInvocation, ToolOutput, ToolPolicySpec, + ActionClass, ActionPolicyEffect, ActionPolicyRule, ActionRuleScope, BuiltInTool, + CloudHandsConfig, IdempotencyClass, MoaConfig, MoaError, ModelId, Result, RiskLevel, + SessionMeta, TenantId, ToolContext, ToolDiffStrategy, ToolInputShape, ToolInvocation, + ToolOutput, ToolPolicySpec, UserId, }; -use moa_hands::{ToolRegistry, ToolRouter}; +use moa_hands::{McpDiscoveredTool, ToolRegistry, ToolRouter}; +use moa_security::ActionPolicyRuleStore; use opentelemetry::Value; use opentelemetry::trace::{Status, TracerProvider as _}; use opentelemetry_sdk::trace::{ @@ -394,3 +396,124 @@ async fn local_hand_error_output_spans_redact_bodies_by_default() { ); assert_spans_do_not_contain(&spans, &[ERROR_SECRET]); } + +/// In-memory action-policy rule store returning fixed rules for a tool. +struct StaticRuleStore { + rules: Vec, +} + +#[async_trait] +impl ActionPolicyRuleStore for StaticRuleStore { + async fn list_action_policy_rules_for_tool( + &self, + _tenant_id: &TenantId, + _user_id: &UserId, + tool: &str, + ) -> Result> { + Ok(self + .rules + .iter() + .filter(|rule| rule.tool == tool) + .cloned() + .collect()) + } + + async fn upsert_action_policy_rule(&self, _rule: ActionPolicyRule) -> Result<()> { + Ok(()) + } + + async fn delete_action_policy_rule( + &self, + _tenant_id: &TenantId, + _user_id: Option<&UserId>, + _tool: &str, + _pattern: &str, + ) -> Result<()> { + Ok(()) + } +} + +fn discovered_mcp_tool(name: &str) -> McpDiscoveredTool { + McpDiscoveredTool { + name: name.to_string(), + description: "third-party MCP tool".to_string(), + input_schema: json!({ "type": "object" }), + } +} + +fn mcp_invocation(name: &str) -> ToolInvocation { + ToolInvocation { + id: None, + name: name.to_string(), + input: json!({}), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn mcp_tool_defaults_to_admin_review() { + // Pins: an MCP/third-party tool has no considered per-tool descriptor gate, so it + // resolves to AdminReview by default instead of a bare allow — unvetted external + // code must not execute unattended. + let mut registry = ToolRegistry::new(); + registry + .register_mcp_tool("external-server", discovered_mcp_tool("external_action")) + .expect("register mcp tool"); + let router = ToolRouter::new(registry, HashMap::new()); + + let check = router + .check_policy(&session(), &mcp_invocation("external_action")) + .await + .expect("policy check for mcp tool"); + + assert_eq!(check.effect, ActionPolicyEffect::AdminReview); +} + +#[tokio::test(flavor = "current_thread")] +async fn builtin_tool_keeps_its_descriptor_default_effect() { + // Pins: the MCP admin-review default does not change builtin tools, which keep their + // own considered descriptor default (SecretErrorTool declares Allow). + let mut registry = ToolRegistry::new(); + registry.register_builtin(Arc::new(SecretErrorTool)); + let router = ToolRouter::new(registry, HashMap::new()); + + let check = router + .check_policy(&session(), &mcp_invocation("secret_error")) + .await + .expect("policy check for builtin tool"); + + assert_eq!(check.effect, ActionPolicyEffect::Allow); +} + +#[tokio::test(flavor = "current_thread")] +async fn explicitly_allowed_mcp_tool_resolves_to_allow() { + // Pins: an explicit operator allow rule overrides the MCP admin-review default, so + // operator config still wins over the new secure default. + let session = session(); + let mut registry = ToolRegistry::new(); + registry + .register_mcp_tool("external-server", discovered_mcp_tool("external_action")) + .expect("register mcp tool"); + let allow_rule = ActionPolicyRule { + id: uuid::Uuid::now_v7(), + scope: ActionRuleScope::Tenant { + tenant_id: session.tenant_id, + }, + tool: "external_action".to_string(), + pattern: "*".to_string(), + effect: ActionPolicyEffect::Allow, + reason: Some("operator trusts this MCP tool".to_string()), + created_by: UserId::new("admin"), + created_at: chrono::Utc::now(), + }; + let router = + ToolRouter::new(registry, HashMap::new()).with_rule_store(Arc::new(StaticRuleStore { + rules: vec![allow_rule], + })); + + let check = router + .check_policy(&session, &mcp_invocation("external_action")) + .await + .expect("policy check for allowed mcp tool"); + + assert_eq!(check.effect, ActionPolicyEffect::Allow); +} diff --git a/crates/moa-memory/ingest/src/contradiction.rs b/crates/moa-memory/ingest/src/contradiction.rs index 37889cac..fc2c286f 100644 --- a/crates/moa-memory/ingest/src/contradiction.rs +++ b/crates/moa-memory/ingest/src/contradiction.rs @@ -762,7 +762,7 @@ fn candidate_text(candidate: &NodeIndexRow) -> String { fn deployment_provider(text: &str) -> Option<&'static str> { let lower = text.to_ascii_lowercase(); [ - ("fly.io", &["fly.io", "flyio", "fly"][..]), + ("railway", &["railway", "railway.app"][..]), ("aws", &["aws", "amazon web services", "ec2"][..]), ("gcp", &["gcp", "google cloud", "cloud run"][..]), ("azure", &["azure"][..]), @@ -861,11 +861,11 @@ mod tests { #[tokio::test] async fn contradiction_judge_restating_fact_returns_duplicate() { - let candidate = candidate("we deploy to fly.io", None); + let candidate = candidate("we deploy to railway", None); let detector = RrfPlusJudgeDetector::default(); let conflict = detector - .judge_candidates("we deploy to fly.io", std::slice::from_ref(&candidate)) + .judge_candidates("we deploy to railway", std::slice::from_ref(&candidate)) .await .expect("judge duplicate"); @@ -874,7 +874,7 @@ mod tests { #[tokio::test] async fn contradiction_judge_provider_change_returns_supersede() { - let candidate = candidate("we deploy to fly.io", None); + let candidate = candidate("we deploy to railway", None); let detector = RrfPlusJudgeDetector::default(); let conflict = detector @@ -890,7 +890,7 @@ mod tests { let detector = RrfPlusJudgeDetector::default(); let conflict = detector - .judge_candidates("we deploy to fly.io", &[]) + .judge_candidates("we deploy to railway", &[]) .await .expect("judge empty candidates"); @@ -915,7 +915,7 @@ mod tests { #[tokio::test] async fn contradiction_model_judge_parses_provider_response() { // Pins: the model-backed judge preserves prompt content and JSON verdict parsing. - let candidate = candidate("we deploy to fly.io", None); + let candidate = candidate("we deploy to railway", None); let provider = Arc::new(StaticJudgeProvider { response: format!( "```json\n{{\"verdict\":\"CONTRADICTS\",\"candidate_uid\":\"{}\",\"rationale\":\"deployment provider changed\"}}\n```", @@ -962,7 +962,7 @@ mod tests { Duration::from_secs(5), Duration::from_millis(10), ); - let candidate = candidate("we deploy to fly.io", None); + let candidate = candidate("we deploy to railway", None); let conflict = detector .judge_candidates("we deploy to AWS", &[candidate]) diff --git a/crates/moa-memory/ingest/tests/contradiction_candidates_db_memory.rs b/crates/moa-memory/ingest/tests/contradiction_candidates_db_memory.rs index f62ae48e..34c54f49 100644 --- a/crates/moa-memory/ingest/tests/contradiction_candidates_db_memory.rs +++ b/crates/moa-memory/ingest/tests/contradiction_candidates_db_memory.rs @@ -109,7 +109,7 @@ async fn candidates_merge_hydrates_lexical_and_vector_hits_excluding_inactive_db .create_node(fact_intent( &storage_partition_id, lexical_uid, - "checkout deploys flyio", + "checkout deploys railway", )) .await .expect("create lexical fact"); @@ -125,7 +125,7 @@ async fn candidates_merge_hydrates_lexical_and_vector_hits_excluding_inactive_db .create_node(fact_intent( &storage_partition_id, inactive_uid, - "checkout deploys flyio", + "checkout deploys railway", )) .await .expect("create inactive fact"); @@ -141,7 +141,7 @@ async fn candidates_merge_hydrates_lexical_and_vector_hits_excluding_inactive_db let candidates = detector .candidates( - "checkout deploys flyio", + "checkout deploys railway", &embedding, NodeLabel::Fact, PiiClass::None, @@ -173,7 +173,7 @@ async fn candidates_merge_hydrates_lexical_and_vector_hits_excluding_inactive_db .find(|candidate| candidate.uid == lexical_uid) .expect("lexical row present") .name, - "checkout deploys flyio" + "checkout deploys railway" ); assert_eq!( candidates diff --git a/crates/moa-memory/ingest/tests/fast_path_db_memory.rs b/crates/moa-memory/ingest/tests/fast_path_db_memory.rs index c8679b67..a3b0e0db 100644 --- a/crates/moa-memory/ingest/tests/fast_path_db_memory.rs +++ b/crates/moa-memory/ingest/tests/fast_path_db_memory.rs @@ -518,7 +518,7 @@ async fn fast_remember_db_memory() { let started = Instant::now(); let uid = fast_remember( - tenant_remember_request(tenant_id, "we deploy to fly.io"), + tenant_remember_request(tenant_id, "we deploy to railway"), &ctx, ) .await @@ -526,7 +526,7 @@ async fn fast_remember_db_memory() { assert!(started.elapsed() < Duration::from_millis(500)); assert_eq!( node_name(session_store.pool(), tenant_id, uid).await, - "we deploy to fly.io" + "we deploy to railway" ); assert_eq!( node_pii_class(session_store.pool(), tenant_id, uid).await, @@ -551,7 +551,7 @@ async fn fast_remember_duplicate_reinforces_survivor_instead_of_dropping_db_memo .expect("create isolated Postgres store"); let tenant_id = Uuid::now_v7(); let seeded_uid = - seed_active_tenant_node(session_store.pool(), tenant_id, "we deploy to fly.io").await; + seed_active_tenant_node(session_store.pool(), tenant_id, "we deploy to railway").await; // Simulate a decayed fact: lowered confidence with an anchored base. let mut conn = tenant_scoped_conn(session_store.pool(), tenant_id).await; sqlx::query( @@ -579,7 +579,7 @@ async fn fast_remember_duplicate_reinforces_survivor_instead_of_dropping_db_memo seed_tenant_embedder_state(session_store.pool(), tenant_id).await; let uid = fast_remember( - tenant_remember_request(tenant_id, "we deploy to fly.io"), + tenant_remember_request(tenant_id, "we deploy to railway"), &ctx, ) .await @@ -765,7 +765,7 @@ async fn fast_remember_explicit_supersede_invalidates_old_node_and_links_edge() .await .expect("create old fact"); - let mut req = tenant_remember_request(tenant_id, "deployments use fly.io"); + let mut req = tenant_remember_request(tenant_id, "deployments use railway"); req.supersedes_specific = Some(old_uid); let new_uid = fast_remember(req, &ctx).await.expect("supersede old fact"); diff --git a/crates/moa-orchestrator/src/runtime/deps.rs b/crates/moa-orchestrator/src/runtime/deps.rs index 1739a9ff..e5d9eabc 100644 --- a/crates/moa-orchestrator/src/runtime/deps.rs +++ b/crates/moa-orchestrator/src/runtime/deps.rs @@ -104,6 +104,9 @@ impl RuntimeDeps { ) .context("build providers bundle")?; let runtime_cache = build_runtime_cache_store(config.as_ref()).await?; + // Give provider concurrency limiters the shared coordination store before + // any provider is built, so `global` scope can coordinate across replicas. + moa_providers::install_coordination_store(Arc::clone(&runtime_cache)); let providers = Arc::new(build_provider_registry( config.as_ref(), diff --git a/crates/moa-providers/Cargo.toml b/crates/moa-providers/Cargo.toml index 3a77ff55..eee7ea73 100644 --- a/crates/moa-providers/Cargo.toml +++ b/crates/moa-providers/Cargo.toml @@ -33,6 +33,7 @@ workspace-hack = { workspace = true } [dev-dependencies] insta.workspace = true +moa-runtime-store = { workspace = true, features = ["redis"] } moa-test-support = { workspace = true } tokio = { workspace = true, features = ["test-util"] } wiremock.workspace = true diff --git a/crates/moa-providers/src/adapters/anthropic/mod.rs b/crates/moa-providers/src/adapters/anthropic/mod.rs index 576a7b0f..967132e1 100644 --- a/crates/moa-providers/src/adapters/anthropic/mod.rs +++ b/crates/moa-providers/src/adapters/anthropic/mod.rs @@ -24,9 +24,8 @@ use serde_json::{Map, Value, json}; use tokio::sync::mpsc; use tracing::Instrument; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; +use crate::core::concurrency_factory::{CallKind, ProviderConcurrency}; use crate::core::http::build_http_client; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -96,9 +95,9 @@ impl AnthropicProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency defaults to a per-key in-flight bound; operators can - // override it (0 opts back into unbounded). - limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), + // Direct construction uses the flat per-provider default; the config + // path overrides it per credential (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), guard: RateGuard::new(), }) } @@ -118,14 +117,17 @@ impl AnthropicProvider { &config.providers.anthropic.api_key, )?; - let mut provider = Self::new(api_key, default_model)? + let mut provider = Self::new(api_key.clone(), default_model)? .with_web_search_enabled(config.general.web_search_enabled); if let Some(max) = config.providers.anthropic.max_requests_per_min { provider = provider.with_rate_limits(PacerConfig::requests_per_min(max)); } - if let Some(max) = config.providers.anthropic.max_concurrent_requests { - provider = provider.with_max_concurrent_requests(max as usize); - } + provider.limiter = ProviderConcurrency::from_config(config).limiter( + CallKind::Chat, + "anthropic", + &api_key, + config.providers.anthropic.max_concurrent_requests, + ); Ok(provider) } @@ -229,10 +231,10 @@ impl LLMProvider for AnthropicProvider { }; // Take an in-flight slot before dispatching; a gate that stays saturated // past the block threshold is a failover-eligible block, not a queue. - let permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let permit = match self.limiter.acquire().await { Some(lease) => lease, None => { - let error = rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD); + let error = rate_guard::rate_limited_saturated(self.limiter.block_threshold()); span_recorder.fail_at_stage("transport", &error); return Err(error); } diff --git a/crates/moa-providers/src/adapters/gemini/mod.rs b/crates/moa-providers/src/adapters/gemini/mod.rs index d68dba0f..147baafb 100644 --- a/crates/moa-providers/src/adapters/gemini/mod.rs +++ b/crates/moa-providers/src/adapters/gemini/mod.rs @@ -24,9 +24,8 @@ use serde_json::{Map, Value, json}; use tokio::sync::mpsc; use tracing::Instrument; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; +use crate::core::concurrency_factory::{CallKind, ProviderConcurrency}; use crate::core::http::build_http_client; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -121,9 +120,9 @@ impl GeminiProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency defaults to a per-key in-flight bound; operators can - // override it (0 opts back into unbounded). - limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), + // Direct construction uses the flat per-provider default; the config + // path overrides it per credential (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), guard: RateGuard::new(), }) } @@ -144,7 +143,7 @@ impl GeminiProvider { )?; let mut provider = Self::new_with_reasoning_effort( - api_key, + api_key.clone(), default_model, config.general.reasoning_effort.clone(), )? @@ -152,9 +151,12 @@ impl GeminiProvider { if let Some(max) = config.providers.google.max_requests_per_min { provider = provider.with_rate_limits(PacerConfig::requests_per_min(max)); } - if let Some(max) = config.providers.google.max_concurrent_requests { - provider = provider.with_max_concurrent_requests(max as usize); - } + provider.limiter = ProviderConcurrency::from_config(config).limiter( + CallKind::Chat, + "google", + &api_key, + config.providers.google.max_concurrent_requests, + ); Ok(provider) } @@ -259,10 +261,10 @@ impl LLMProvider for GeminiProvider { // Take an in-flight slot before dispatching; a gate that stays saturated // past the block threshold is a failover-eligible block, not a queue. - let permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let permit = match self.limiter.acquire().await { Some(lease) => lease, None => { - let error = rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD); + let error = rate_guard::rate_limited_saturated(self.limiter.block_threshold()); span_recorder.fail_at_stage("transport", &error); return Err(error); } diff --git a/crates/moa-providers/src/adapters/openai_responses/provider.rs b/crates/moa-providers/src/adapters/openai_responses/provider.rs index 95df8aa9..bb13e3d5 100644 --- a/crates/moa-providers/src/adapters/openai_responses/provider.rs +++ b/crates/moa-providers/src/adapters/openai_responses/provider.rs @@ -12,9 +12,8 @@ use tokio::sync::mpsc; use tracing::Instrument; use crate::adapters::openai_responses::{build_responses_request, stream_responses_with_retry}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_LLM_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; +use crate::core::concurrency_factory::{CallKind, ProviderConcurrency}; use crate::core::instrumentation::LLMSpanRecorder; use crate::core::models::{self, PROVIDER_OPENAI}; use crate::core::pacer::{PacerConfig, RatePacer}; @@ -91,9 +90,9 @@ impl OpenAIProvider { retry_policy: RetryPolicy::default().with_max_retries(DEFAULT_MAX_RETRIES), web_search_enabled: true, pacer: RatePacer::new(PacerConfig::disabled()), - // LLM concurrency defaults to a per-key in-flight bound; operators can - // override it (0 opts back into unbounded). - limiter: ConcurrencyLimiter::new(DEFAULT_LLM_CONCURRENCY), + // Direct construction uses the flat per-provider default; the config + // path overrides it per credential (0 opts back into unbounded). + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), guard: RateGuard::new(), }) } @@ -114,7 +113,7 @@ impl OpenAIProvider { )?; let mut provider = Self::new_with_reasoning_effort( - api_key, + api_key.clone(), default_model, config.general.reasoning_effort.clone(), )? @@ -122,9 +121,12 @@ impl OpenAIProvider { if let Some(max) = config.providers.openai.max_requests_per_min { provider = provider.with_rate_limits(PacerConfig::requests_per_min(max)); } - if let Some(max) = config.providers.openai.max_concurrent_requests { - provider = provider.with_max_concurrent_requests(max as usize); - } + provider.limiter = ProviderConcurrency::from_config(config).limiter( + CallKind::Chat, + "openai", + &api_key, + config.providers.openai.max_concurrent_requests, + ); Ok(provider) } @@ -221,10 +223,10 @@ impl LLMProvider for OpenAIProvider { }; // Take an in-flight slot before dispatching; a gate that stays saturated // past the block threshold is a failover-eligible block, not a queue. - let permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let permit = match self.limiter.acquire().await { Some(lease) => lease, None => { - let error = rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD); + let error = rate_guard::rate_limited_saturated(self.limiter.block_threshold()); span_recorder.fail_at_stage("transport", &error); return Err(error); } diff --git a/crates/moa-providers/src/core/concurrency.rs b/crates/moa-providers/src/core/concurrency.rs index c388d550..3d46ce63 100644 --- a/crates/moa-providers/src/core/concurrency.rs +++ b/crates/moa-providers/src/core/concurrency.rs @@ -31,24 +31,19 @@ use std::time::Duration; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; -/// Default in-flight ceiling for embedding providers. -/// -/// Embedding calls fan out over document batches, so a small default keeps one -/// busy ingestion run from opening an unbounded number of sockets to the provider -/// while still overlapping enough round trips to stay throughput-bound. -pub(crate) const DEFAULT_EMBEDDING_CONCURRENCY: usize = 8; - -/// Default in-flight ceiling for rerank providers. -pub(crate) const DEFAULT_RERANK_CONCURRENCY: usize = 8; +use super::global_concurrency::{GlobalConcurrency, GlobalLeaseGuard}; -/// Default in-flight ceiling for chat/LLM providers, per API key. +/// Default in-flight ceiling used only by direct provider construction (`new`, +/// `from_env`, tests) — one flat number per provider, not per call kind. /// -/// A generous bound that still prevents one process from opening an unbounded -/// number of simultaneous connections to a provider (exhausting sockets or the -/// key's concurrency window) when many turns fan out at once. Operators can raise -/// or lower it per key via `max_concurrent_requests`, and an explicit `0` opts -/// back into unbounded. -pub(crate) const DEFAULT_LLM_CONCURRENCY: usize = 32; +/// Provider rate limits are a per-credential account-tier property, so a +/// credential serving several call kinds shares one budget; there is no per-kind +/// default. This mirrors the workspace fallback +/// `ProviderConcurrencyConfig::default_max_in_flight`. The config-driven +/// `from_config` path builds each provider's limiter per (provider, credential) +/// and overrides this constant, so it applies only to providers built outside +/// that path. +pub(crate) const DEFAULT_MAX_IN_FLIGHT: usize = 16; /// How long a call waits for a concurrency slot before reporting "blocked". /// @@ -59,40 +54,100 @@ pub(crate) const DEFAULT_BLOCK_THRESHOLD: Duration = Duration::from_secs(2); /// An acquired in-flight slot held for the lifetime of one outbound call. /// /// `Unbounded` carries no permit (the limiter imposes no ceiling); `Held` owns a -/// semaphore permit that frees its slot on drop. Both keep the slot reserved for -/// as long as the lease is bound, matching [`ConcurrencyLimiter::acquire`]. +/// semaphore permit that frees its slot on drop; `Global` owns a runtime-store +/// lease that is released on drop (see [`GlobalLeaseGuard`]). All keep the slot +/// reserved for as long as the lease is bound, matching +/// [`ConcurrencyLimiter::acquire`]. pub(crate) enum PermitLease { /// The limiter is unbounded; there is no slot to release. Unbounded, /// A held semaphore permit; the slot is reserved until this is dropped. The /// permit is never read — it exists purely for its `Drop` side effect. Held(#[allow(dead_code)] OwnedSemaphorePermit), + /// A held cross-replica lease; releasing it deletes the lease on drop. + Global(#[allow(dead_code)] GlobalLeaseGuard), } /// A cloneable in-flight concurrency gate for one provider instance. /// -/// A `None` inner semaphore means unbounded: [`ConcurrencyLimiter::acquire`] -/// returns immediately without allocating a permit. +/// A local limiter with no ceiling is unbounded ([`ConcurrencyLimiter::acquire`] +/// returns immediately without allocating a permit). A global limiter coordinates +/// its ceiling across replicas through the runtime store. Every limiter carries a +/// `block_threshold`: the wait [`acquire`](Self::acquire) allows before reporting +/// a failover-eligible saturated signal. #[derive(Clone)] pub(crate) struct ConcurrencyLimiter { - inner: Option>, + mode: LimiterMode, + block_threshold: Duration, +} + +#[derive(Clone)] +enum LimiterMode { + /// Process-local gate; `None` is unbounded. + Local(Option>), + /// Cross-replica gate coordinated through the runtime store. + Global(Arc), } impl ConcurrencyLimiter { - /// Builds a limiter that admits at most `max_in_flight` concurrent requests. + /// Builds a process-local limiter admitting at most `max_in_flight` requests, + /// with the default block threshold. /// /// A `max_in_flight` of zero is treated as unbounded rather than a permanently /// closed gate, matching the "unset means no limit" configuration semantics. pub(crate) fn new(max_in_flight: usize) -> Self { + Self::local(max_in_flight, DEFAULT_BLOCK_THRESHOLD) + } + + /// Builds a process-local limiter with an explicit block threshold. + pub(crate) fn local(max_in_flight: usize, block_threshold: Duration) -> Self { + Self { + mode: LimiterMode::Local( + (max_in_flight > 0).then(|| Arc::new(Semaphore::new(max_in_flight))), + ), + block_threshold, + } + } + + /// Builds a process-local limiter over a caller-provided (possibly shared) + /// semaphore. `None` is unbounded; passing one shared semaphore lets multiple + /// limiters contend for a single budget. + pub(crate) fn from_local_semaphore( + semaphore: Option>, + block_threshold: Duration, + ) -> Self { + Self { + mode: LimiterMode::Local(semaphore), + block_threshold, + } + } + + /// Builds a limiter that coordinates its ceiling across replicas. + pub(crate) fn global(limiter: GlobalConcurrency, block_threshold: Duration) -> Self { Self { - inner: (max_in_flight > 0).then(|| Arc::new(Semaphore::new(max_in_flight))), + mode: LimiterMode::Global(Arc::new(limiter)), + block_threshold, } } /// Returns whether this limiter imposes a finite in-flight ceiling. #[cfg(test)] pub(crate) fn is_bounded(&self) -> bool { - self.inner.is_some() + match &self.mode { + LimiterMode::Local(inner) => inner.is_some(), + LimiterMode::Global(_) => true, + } + } + + /// Takes an in-flight slot, waiting up to this limiter's configured block + /// threshold. Returns `None` when the gate stays saturated for the whole wait. + pub(crate) async fn acquire(&self) -> Option { + self.acquire_within(self.block_threshold).await + } + + /// Returns the wait this limiter allows before reporting a saturated gate. + pub(crate) fn block_threshold(&self) -> Duration { + self.block_threshold } /// Takes an in-flight slot, waiting at most `max_wait` for one to free up. @@ -104,16 +159,19 @@ impl ConcurrencyLimiter { /// which callers treat as a blocked signal eligible for failover instead of /// queueing indefinitely. pub(crate) async fn acquire_within(&self, max_wait: Duration) -> Option { - let Some(semaphore) = &self.inner else { - return Some(PermitLease::Unbounded); - }; - match tokio::time::timeout(max_wait, Arc::clone(semaphore).acquire_owned()).await { - // Acquired within the deadline. - Ok(Ok(permit)) => Some(PermitLease::Held(permit)), - // Semaphore closed (never in practice); degrade to unbounded. - Ok(Err(_)) => Some(PermitLease::Unbounded), - // Still saturated after `max_wait`: report blocked. - Err(_) => None, + match &self.mode { + LimiterMode::Local(None) => Some(PermitLease::Unbounded), + LimiterMode::Local(Some(semaphore)) => { + match tokio::time::timeout(max_wait, Arc::clone(semaphore).acquire_owned()).await { + // Acquired within the deadline. + Ok(Ok(permit)) => Some(PermitLease::Held(permit)), + // Semaphore closed (never in practice); degrade to unbounded. + Ok(Err(_)) => Some(PermitLease::Unbounded), + // Still saturated after `max_wait`: report blocked. + Err(_) => None, + } + } + LimiterMode::Global(limiter) => limiter.acquire(max_wait).await, } } } diff --git a/crates/moa-providers/src/core/concurrency_factory.rs b/crates/moa-providers/src/core/concurrency_factory.rs new file mode 100644 index 00000000..b100b2b2 --- /dev/null +++ b/crates/moa-providers/src/core/concurrency_factory.rs @@ -0,0 +1,354 @@ +//! Builds per-provider concurrency limiters from provider configuration. +//! +//! Provider rate limits are tied to the account tier, so the in-flight budget is +//! **per (provider, credential)** — one shared ceiling that every call kind the +//! credential serves (e.g. Cohere embed + rerank on one key) draws from. The +//! effective limit is the provider's own `max_concurrent_requests`, else the +//! workspace-wide +//! [`default_max_in_flight`](moa_core::config::ProviderConcurrencyConfig). +//! +//! Local scope shares one process-local semaphore per budget key (so the two +//! Cohere clients above contend for one budget); global scope shares one lease +//! key in the runtime coordination store across replicas. That store is a single +//! per-process handle installed once by the composition layer via +//! [`install_coordination_store`]; limiter construction runs in `from_config`, +//! where the config is already in scope. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use moa_core::MoaConfig; +use moa_core::config::{ConcurrencyScope, ProviderConcurrencyConfig}; +use moa_core::traits::RuntimeCacheStore; +use tokio::sync::Semaphore; + +use super::concurrency::ConcurrencyLimiter; +use super::global_concurrency::GlobalConcurrency; + +/// The kind of provider call a limiter guards. The budget is shared per +/// (provider, credential) regardless of kind; this is used only as a metrics +/// label so per-kind traffic through the shared budget stays observable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CallKind { + /// Chat/LLM completion calls. + Chat, + /// Embedding calls. + Embedding, + /// Rerank calls. + Rerank, +} + +impl CallKind { + fn label(self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Embedding => "embedding", + Self::Rerank => "rerank", + } + } +} + +/// Process-wide runtime coordination store used to build global limiters. +/// +/// Installed once by the composition layer (runtime deps). When absent — single +/// node, dev, or tests — `global` scope falls back to a process-local limiter. +static COORDINATION_STORE: OnceLock> = OnceLock::new(); + +/// Process-local budgets: one shared semaphore per `(provider, credential)` so +/// every call kind on a credential contends for the same in-flight budget. +static LOCAL_BUDGETS: OnceLock>>> = OnceLock::new(); + +/// Installs the runtime coordination store for global concurrency (idempotent). +pub fn install_coordination_store(store: Arc) { + let _ = COORDINATION_STORE.set(store); +} + +/// Returns the shared local semaphore for one budget key, creating it once. +/// +/// The first caller for a key fixes its size; later limiters for the same +/// `(provider, credential)` clone that one semaphore, so kinds share the budget. +fn local_budget_semaphore(key: &str, limit: usize) -> Arc { + let registry = LOCAL_BUDGETS.get_or_init(|| Mutex::new(HashMap::new())); + let mut budgets = registry + .lock() + .expect("provider concurrency budget registry mutex poisoned"); + Arc::clone( + budgets + .entry(key.to_string()) + .or_insert_with(|| Arc::new(Semaphore::new(limit))), + ) +} + +/// Concurrency policy resolved from config plus the optional coordination store. +#[derive(Clone)] +pub(crate) struct ProviderConcurrency { + config: ProviderConcurrencyConfig, + store: Option>, +} + +impl ProviderConcurrency { + /// Resolves the concurrency policy from config and the installed store. + pub(crate) fn from_config(config: &MoaConfig) -> Self { + Self { + config: config.providers.concurrency.clone(), + store: COORDINATION_STORE.get().cloned(), + } + } + + /// Test constructor with an explicit (optional) store. + #[cfg(test)] + pub(crate) fn with_store( + config: ProviderConcurrencyConfig, + store: Option>, + ) -> Self { + Self { config, store } + } + + /// Builds the shared limiter for one `(provider, credential)` budget. + /// + /// The effective limit is the provider's `max_concurrent_requests` when set, + /// else the workspace `default_max_in_flight` (`0` = unbounded). Global scope + /// with a positive limit and an installed store yields a cross-replica limiter + /// keyed on the shared budget; every other case is process-local, sharing one + /// semaphore per budget key so all call kinds on the credential contend for + /// the same ceiling. + pub(crate) fn limiter( + &self, + kind: CallKind, + provider: &str, + credential: &str, + per_provider_override: Option, + ) -> ConcurrencyLimiter { + let limit = per_provider_override.unwrap_or(self.config.default_max_in_flight) as usize; + let block_threshold = Duration::from_millis(self.config.block_threshold_ms); + let key = budget_key(provider, credential); + + match (self.config.scope, &self.store) { + (ConcurrencyScope::Global, Some(store)) if limit > 0 => { + // The degrade-open fallback is the same shared per-(provider, + // credential) semaphore as the local path, so kinds still share + // one budget when the coordination store is unavailable. + let fallback = local_budget_semaphore(&key, limit); + let global = GlobalConcurrency::new( + Arc::clone(store), + key, + limit, + Duration::from_millis(self.config.lease_ttl_ms), + provider.to_string(), + kind.label(), + fallback, + ); + ConcurrencyLimiter::global(global, block_threshold) + } + _ => { + let semaphore = (limit > 0).then(|| local_budget_semaphore(&key, limit)); + ConcurrencyLimiter::from_local_semaphore(semaphore, block_threshold) + } + } + } +} + +/// Builds the shared budget key for one `(provider, credential)`, hashing the +/// credential so the raw API key never lands in the coordination store. +fn budget_key(provider: &str, credential: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + credential.hash(&mut hasher); + format!( + "moa:provider-concurrency:{provider}:{:016x}", + hasher.finish() + ) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use async_trait::async_trait; + use moa_core::Result; + use tokio::sync::Mutex; + + use super::*; + + fn global_config() -> ProviderConcurrencyConfig { + ProviderConcurrencyConfig { + scope: ConcurrencyScope::Global, + ..ProviderConcurrencyConfig::default() + } + } + + /// Minimal in-memory coordination store for the global-scope factory test. + #[derive(Default)] + struct TestStore { + entries: Mutex>>, + } + + #[async_trait] + impl RuntimeCacheStore for TestStore { + async fn get(&self, key: &str) -> Result>> { + Ok(self.entries.lock().await.get(key).cloned()) + } + async fn set(&self, key: &str, value: Vec, _ttl: Duration) -> Result<()> { + self.entries.lock().await.insert(key.to_string(), value); + Ok(()) + } + async fn delete(&self, key: &str) -> Result<()> { + self.entries.lock().await.remove(key); + Ok(()) + } + async fn compare_and_set( + &self, + key: &str, + expected: Option<&[u8]>, + value: Vec, + _ttl: Duration, + ) -> Result { + let mut entries = self.entries.lock().await; + if entries.get(key).map(|value| value.as_slice()) != expected { + return Ok(false); + } + entries.insert(key.to_string(), value); + Ok(true) + } + async fn expire(&self, _key: &str, _ttl: Duration) -> Result<()> { + Ok(()) + } + } + + #[test] + fn local_scope_builds_a_local_bounded_limiter() { + // Pins: the default (local) scope yields a bounded process-local limiter + // sized by the workspace default, ignoring any installed store. + let policy = ProviderConcurrency::with_store(ProviderConcurrencyConfig::default(), None); + let limiter = policy.limiter(CallKind::Chat, "anthropic", "local-scope-key", None); + assert!(limiter.is_bounded()); + } + + #[tokio::test] + async fn unconfigured_provider_uses_the_workspace_fallback_default() { + // Pins: a provider with no override draws from `default_max_in_flight` — a + // fallback of 1 bounds the credential to a single in-flight slot. + let config = ProviderConcurrencyConfig { + default_max_in_flight: 1, + ..ProviderConcurrencyConfig::default() + }; + let policy = ProviderConcurrency::with_store(config, None); + let first = policy.limiter(CallKind::Chat, "openai", "fallback-key", None); + let second = policy.limiter(CallKind::Chat, "openai", "fallback-key", None); + let held = first + .acquire_within(Duration::from_millis(10)) + .await + .expect("the fallback allows one slot"); + assert!( + second + .acquire_within(Duration::from_millis(10)) + .await + .is_none(), + "the workspace fallback of 1 must bound the credential to one slot" + ); + drop(held); + } + + #[test] + fn global_scope_without_a_store_falls_back_to_local() { + // Pins: global scope with no installed coordination store degrades to a + // local limiter so single-node/dev deployments still work. + let policy = ProviderConcurrency::with_store(global_config(), None); + let limiter = policy.limiter( + CallKind::Embedding, + "openai", + "global-no-store-key", + Some(4), + ); + assert!(limiter.is_bounded()); + } + + #[test] + fn per_provider_override_of_zero_is_unbounded() { + // Pins: an explicit 0 override opts back into unbounded, even under global + // scope (no coordination for an unbounded budget). + let policy = ProviderConcurrency::with_store(global_config(), None); + let limiter = policy.limiter(CallKind::Chat, "anthropic", "unbounded-key", Some(0)); + assert!(!limiter.is_bounded()); + } + + #[tokio::test] + async fn call_kinds_on_one_credential_share_a_local_budget() { + // Pins: limiters built for the same (provider, credential) — the embed, + // rerank, and chat clients on one Cohere key — contend for one shared + // process-local budget; a different provider has an independent budget. + let policy = ProviderConcurrency::with_store(ProviderConcurrencyConfig::default(), None); + let embed = policy.limiter(CallKind::Embedding, "cohere", "local-shared-key", Some(1)); + let rerank = policy.limiter(CallKind::Rerank, "cohere", "local-shared-key", Some(1)); + let chat = policy.limiter(CallKind::Chat, "cohere", "local-shared-key", Some(1)); + + let held = embed + .acquire_within(Duration::from_millis(10)) + .await + .expect("embed takes the only shared slot"); + assert!( + rerank + .acquire_within(Duration::from_millis(10)) + .await + .is_none(), + "rerank on the same credential must find the shared budget saturated" + ); + assert!( + chat.acquire_within(Duration::from_millis(10)) + .await + .is_none(), + "chat on the same credential must find the shared budget saturated" + ); + + // A different provider draws from an independent budget. + let other = policy.limiter(CallKind::Chat, "openai", "local-shared-key", Some(1)); + assert!( + other + .acquire_within(Duration::from_millis(10)) + .await + .is_some(), + "a different provider has its own budget" + ); + drop(held); + } + + #[tokio::test] + async fn call_kinds_on_one_credential_share_a_global_budget() { + // Pins: under global scope, kinds on one credential share one lease budget + // in the coordination store (same key, no call kind) — embed holding the + // only slot saturates rerank on the same credential. + let store: Arc = Arc::new(TestStore::default()); + let policy = ProviderConcurrency::with_store(global_config(), Some(store)); + let embed = policy.limiter(CallKind::Embedding, "cohere", "global-shared-key", Some(1)); + let rerank = policy.limiter(CallKind::Rerank, "cohere", "global-shared-key", Some(1)); + + let held = embed + .acquire_within(Duration::from_millis(50)) + .await + .expect("embed takes the only global lease"); + assert!( + rerank + .acquire_within(Duration::from_millis(50)) + .await + .is_none(), + "rerank must find the shared global lease budget saturated" + ); + drop(held); + } + + #[test] + fn budget_key_is_stable_per_provider_credential_and_hides_the_credential() { + // Pins: the budget key is deterministic per (provider, credential), differs + // across providers and credentials, and never contains the raw key. + let a = budget_key("cohere", "secret-key"); + let b = budget_key("cohere", "secret-key"); + let c = budget_key("cohere", "other-key"); + let d = budget_key("openai", "secret-key"); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, d); + assert!(!a.contains("secret-key")); + assert!(a.starts_with("moa:provider-concurrency:cohere:")); + } +} diff --git a/crates/moa-providers/src/core/global_concurrency.rs b/crates/moa-providers/src/core/global_concurrency.rs new file mode 100644 index 00000000..12aecfc5 --- /dev/null +++ b/crates/moa-providers/src/core/global_concurrency.rs @@ -0,0 +1,577 @@ +//! Cross-replica provider concurrency via TTL leases in the runtime store. +//! +//! Process-local [`ConcurrencyLimiter`](super::concurrency::ConcurrencyLimiter) +//! bounds in-flight calls per process, but an autoscaled fleet then multiplies a +//! shared provider/API-key quota by the replica count. [`GlobalConcurrency`] +//! coordinates one ceiling across replicas by recording a short-lived lease per +//! held slot in a per-key structure in the runtime coordination store +//! ([`RuntimeCacheStore`]). Admission is a compare-and-set over the lease set: +//! stale leases (from a crashed replica) are pruned by TTL as part of every +//! acquisition, so a killed pod's slots self-reclaim. +//! +//! Availability over strict bounding: if the coordination store is unavailable, +//! acquisition degrades open to a process-local semaphore of the same size rather +//! than failing provider calls. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use moa_core::traits::RuntimeCacheStore; +use moa_core::{MoaError, Result}; +use serde::{Deserialize, Serialize}; +use tokio::runtime::Handle; +use tokio::sync::Semaphore; +use tokio::time::sleep; + +use super::concurrency::PermitLease; + +/// One held global-concurrency slot recorded in the shared lease set. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Lease { + /// Globally-unique lease identity for this held slot. + id: String, + /// Wall-clock expiry (ms since epoch); the crash backstop for the slot. + expires_at_ms: u64, +} + +/// Millisecond wall clock, injectable so lease expiry is deterministic in tests. +#[derive(Clone)] +pub(crate) struct MillisClock(Arc u64 + Send + Sync>); + +impl MillisClock { + /// The real system clock. + pub(crate) fn system() -> Self { + Self(Arc::new(system_now_ms)) + } + + /// A manually-advanced clock backed by a shared counter (tests only). + #[cfg(test)] + pub(crate) fn manual(source: Arc) -> Self { + Self(Arc::new(move || source.load(Ordering::SeqCst))) + } + + fn now_ms(&self) -> u64 { + (self.0)() + } +} + +fn system_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// Returns a lease id unique across replicas and within this process. +/// +/// The process id plus a high-resolution timestamp separate replicas; a +/// monotonic counter separates leases within one process. No RNG dependency. +fn next_lease_id() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let sequence = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{}-{}", std::process::id(), system_now_ms(), sequence) +} + +/// Cross-replica in-flight gate backed by a TTL lease set in the runtime store. +pub(crate) struct GlobalConcurrency { + store: Arc, + key: String, + limit: usize, + lease_ttl: Duration, + clock: MillisClock, + provider: String, + /// Call-kind label for metrics only; the budget key is per (provider, credential). + kind: &'static str, + /// Degrade-open local bound used when the coordination store errors. Shared + /// per (provider, credential), so a degraded fleet still shares one budget. + local_fallback: Arc, +} + +impl GlobalConcurrency { + /// Builds a global limiter for one `(provider, credential)` budget. + pub(crate) fn new( + store: Arc, + key: String, + limit: usize, + lease_ttl: Duration, + provider: String, + kind: &'static str, + local_fallback: Arc, + ) -> Self { + Self { + store, + key, + limit, + lease_ttl, + clock: MillisClock::system(), + provider, + kind, + local_fallback, + } + } + + /// Test constructor with an injectable clock and store. + #[cfg(test)] + pub(crate) fn for_test( + store: Arc, + key: impl Into, + limit: usize, + lease_ttl: Duration, + clock: MillisClock, + ) -> Self { + Self { + store, + key: key.into(), + limit, + lease_ttl, + clock, + provider: "test".to_string(), + kind: "test", + local_fallback: Arc::new(Semaphore::new(limit)), + } + } + + /// Acquires a global slot, waiting at most `max_wait`. + /// + /// Returns `None` when the shared gate stays saturated for the whole wait + /// (the same failover-eligible signal as the local limiter). A coordination- + /// store failure degrades to the local fallback rather than blocking calls. + pub(crate) async fn acquire(&self, max_wait: Duration) -> Option { + let deadline = Instant::now() + max_wait; + let mut backoff = Duration::from_millis(10); + loop { + match self.try_acquire_once().await { + Ok(Some(guard)) => { + metrics::counter!( + "moa_provider_concurrency_global_acquired_total", + "provider" => self.provider.clone(), + "kind" => self.kind, + ) + .increment(1); + return Some(PermitLease::Global(guard)); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + provider = %self.provider, + error = %error, + "global concurrency store unavailable; degrading to local limiter" + ); + metrics::counter!( + "moa_provider_concurrency_degraded_local_total", + "provider" => self.provider.clone(), + "kind" => self.kind, + ) + .increment(1); + return self.acquire_local_fallback(deadline).await; + } + } + + let now = Instant::now(); + if now >= deadline { + metrics::counter!( + "moa_provider_concurrency_saturated_total", + "provider" => self.provider.clone(), + "kind" => self.kind, + ) + .increment(1); + return None; + } + let remaining = deadline.saturating_duration_since(now); + sleep(backoff.min(remaining)).await; + backoff = (backoff * 2).min(Duration::from_millis(100)); + } + } + + /// One admission attempt: prune expired leases, then CAS-add ours if there is + /// room. `Ok(None)` means the gate was full or another writer won the CAS + /// race; `Err` means the store failed and the caller should degrade. + async fn try_acquire_once(&self) -> Result> { + let raw = self.store.get(&self.key).await?; + let mut leases = decode_leases(raw.as_deref()); + let now = self.clock.now_ms(); + leases.retain(|lease| lease.expires_at_ms > now); + if leases.len() >= self.limit { + return Ok(None); + } + + let id = next_lease_id(); + leases.push(Lease { + id: id.clone(), + expires_at_ms: now + self.lease_ttl.as_millis() as u64, + }); + let live = leases.len(); + let encoded = encode_leases(&leases)?; + if self + .store + .compare_and_set(&self.key, raw.as_deref(), encoded, self.lease_ttl) + .await? + { + metrics::gauge!( + "moa_provider_concurrency_lease_count", + "provider" => self.provider.clone(), + "kind" => self.kind, + ) + .set(live as f64); + Ok(Some(GlobalLeaseGuard { + store: Arc::clone(&self.store), + key: self.key.clone(), + id, + lease_ttl: self.lease_ttl, + clock: self.clock.clone(), + })) + } else { + Ok(None) + } + } + + async fn acquire_local_fallback(&self, deadline: Instant) -> Option { + let remaining = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(remaining, Arc::clone(&self.local_fallback).acquire_owned()) + .await + { + Ok(Ok(permit)) => Some(PermitLease::Held(permit)), + // The semaphore is never closed while this limiter holds the Arc. + Ok(Err(_)) => Some(PermitLease::Unbounded), + Err(_) => { + metrics::counter!( + "moa_provider_concurrency_saturated_total", + "provider" => self.provider.clone(), + "kind" => self.kind, + ) + .increment(1); + None + } + } + } +} + +/// An acquired global slot; releasing it deletes the lease from the shared set. +/// +/// `Drop` spawns a best-effort async release. If that cannot run (no runtime) or +/// loses every CAS race, the lease's TTL reclaims the slot as the backstop. +pub(crate) struct GlobalLeaseGuard { + store: Arc, + key: String, + id: String, + lease_ttl: Duration, + clock: MillisClock, +} + +impl Drop for GlobalLeaseGuard { + fn drop(&mut self) { + let store = Arc::clone(&self.store); + let key = std::mem::take(&mut self.key); + let id = std::mem::take(&mut self.id); + let lease_ttl = self.lease_ttl; + let clock = self.clock.clone(); + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + if let Err(error) = + release_lease(store.as_ref(), &key, &id, lease_ttl, &clock).await + { + tracing::debug!( + error = %error, + "global concurrency lease release failed; TTL will reclaim the slot" + ); + } + }); + } + } +} + +/// Removes one lease id from the shared set, pruning expired entries too. +async fn release_lease( + store: &dyn RuntimeCacheStore, + key: &str, + id: &str, + ttl: Duration, + clock: &MillisClock, +) -> Result<()> { + for _ in 0..5 { + let Some(raw) = store.get(key).await? else { + return Ok(()); + }; + let mut leases = decode_leases(Some(&raw)); + let now = clock.now_ms(); + leases.retain(|lease| lease.id != id && lease.expires_at_ms > now); + let encoded = encode_leases(&leases)?; + if store.compare_and_set(key, Some(&raw), encoded, ttl).await? { + return Ok(()); + } + } + // The lease's own TTL is the backstop when release keeps losing CAS races. + Ok(()) +} + +fn decode_leases(raw: Option<&[u8]>) -> Vec { + raw.and_then(|bytes| serde_json::from_slice(bytes).ok()) + .unwrap_or_default() +} + +fn encode_leases(leases: &[Lease]) -> Result> { + serde_json::to_vec(leases).map_err(|error| { + MoaError::SerializationError(format!("encode vector sync leases: {error}")) + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::AtomicU64; + + use async_trait::async_trait; + use tokio::sync::Mutex; + + use super::*; + + /// A minimal in-memory store with a manually-advanced clock so lease TTLs are + /// deterministic. Values never expire on their own — expiry is modeled purely + /// through the lease `expires_at_ms` and the injected [`MillisClock`]. + #[derive(Default)] + struct TestStore { + entries: Mutex>>, + fail: std::sync::atomic::AtomicBool, + } + + impl TestStore { + fn shared() -> Arc { + Arc::new(Self::default()) + } + fn set_failing(&self, failing: bool) { + self.fail.store(failing, Ordering::SeqCst); + } + async fn lease_count(&self, key: &str) -> usize { + let entries = self.entries.lock().await; + entries + .get(key) + .map(|raw| decode_leases(Some(raw)).len()) + .unwrap_or(0) + } + } + + #[async_trait] + impl RuntimeCacheStore for TestStore { + async fn get(&self, key: &str) -> Result>> { + if self.fail.load(Ordering::SeqCst) { + return Err(MoaError::StorageError("test store unavailable".to_string())); + } + Ok(self.entries.lock().await.get(key).cloned()) + } + async fn set(&self, key: &str, value: Vec, _ttl: Duration) -> Result<()> { + self.entries.lock().await.insert(key.to_string(), value); + Ok(()) + } + async fn delete(&self, key: &str) -> Result<()> { + self.entries.lock().await.remove(key); + Ok(()) + } + async fn compare_and_set( + &self, + key: &str, + expected: Option<&[u8]>, + value: Vec, + _ttl: Duration, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(MoaError::StorageError("test store unavailable".to_string())); + } + let mut entries = self.entries.lock().await; + let current = entries.get(key).map(|value| value.as_slice()); + if current != expected { + return Ok(false); + } + entries.insert(key.to_string(), value); + Ok(true) + } + async fn expire(&self, _key: &str, _ttl: Duration) -> Result<()> { + Ok(()) + } + } + + fn limiter(store: Arc, clock: MillisClock, limit: usize) -> GlobalConcurrency { + GlobalConcurrency::for_test( + store, + "moa:concurrency:test", + limit, + Duration::from_secs(60), + clock, + ) + } + + #[tokio::test] + async fn two_handles_sharing_a_store_enforce_a_combined_limit() { + // Pins: leases in one shared store bound total in-flight across independent + // limiter handles (the multi-replica case) to the configured limit. + let store = TestStore::shared(); + let clock = MillisClock::manual(Arc::new(AtomicU64::new(1_000))); + let replica_a = limiter(Arc::clone(&store), clock.clone(), 2); + let replica_b = limiter(Arc::clone(&store), clock.clone(), 2); + + let a1 = replica_a + .acquire(Duration::from_millis(50)) + .await + .expect("slot 1"); + let b1 = replica_b + .acquire(Duration::from_millis(50)) + .await + .expect("slot 2"); + assert_eq!(store.lease_count("moa:concurrency:test").await, 2); + + // The combined limit of 2 is reached; a third caller on either handle is + // saturated. + assert!( + replica_a.acquire(Duration::from_millis(50)).await.is_none(), + "the shared gate must reject the third holder" + ); + assert!(replica_b.acquire(Duration::from_millis(50)).await.is_none()); + + drop(a1); + drop(b1); + } + + #[tokio::test] + async fn expired_lease_frees_a_slot_after_a_simulated_crash() { + // Pins: a crashed replica that never releases its lease does not strand the + // slot — the TTL prunes it and a later caller acquires. + let store = TestStore::shared(); + let now = Arc::new(AtomicU64::new(1_000)); + let clock = MillisClock::manual(Arc::clone(&now)); + let limiter = limiter(Arc::clone(&store), clock, 1); + + // Simulate a crashed holder: a lease that is never released via Drop. + std::mem::forget( + limiter + .acquire(Duration::from_millis(50)) + .await + .expect("first slot"), + ); + assert!( + limiter.acquire(Duration::from_millis(50)).await.is_none(), + "the single slot is held by the crashed lease" + ); + + // Advance past the 60s lease TTL: the stale lease is pruned on acquire. + now.fetch_add(61_000, Ordering::SeqCst); + assert!( + limiter.acquire(Duration::from_millis(50)).await.is_some(), + "the expired lease must free the slot" + ); + } + + #[tokio::test] + async fn saturated_gate_returns_none_at_the_deadline() { + // Pins: a full shared gate returns the failover-eligible None once the wait + // elapses rather than blocking indefinitely. + let store = TestStore::shared(); + let clock = MillisClock::manual(Arc::new(AtomicU64::new(1_000))); + let limiter = limiter(Arc::clone(&store), clock, 1); + std::mem::forget( + limiter + .acquire(Duration::from_millis(10)) + .await + .expect("slot"), + ); + + let started = Instant::now(); + assert!(limiter.acquire(Duration::from_millis(30)).await.is_none()); + assert!( + started.elapsed() < Duration::from_secs(2), + "acquisition must give up near the deadline, not hang" + ); + } + + #[tokio::test] + async fn store_failure_degrades_to_the_local_bound() { + // Pins: when the coordination store errors, acquisition degrades open to a + // local semaphore of the same size instead of failing the call — and still + // bounds concurrency locally. + let store = TestStore::shared(); + let clock = MillisClock::manual(Arc::new(AtomicU64::new(1_000))); + let limiter = limiter(Arc::clone(&store), clock, 1); + store.set_failing(true); + + let first = limiter + .acquire(Duration::from_millis(50)) + .await + .expect("degrades to a local slot rather than failing"); + // The local fallback still enforces the bound of 1. + assert!( + limiter.acquire(Duration::from_millis(50)).await.is_none(), + "the degraded local limiter still bounds in-flight calls" + ); + drop(first); + } + + /// Live coverage against a real Redis/Valkey coordination store. Requires + /// `MOA_RUN_LIVE_REDIS=1` and a reachable Redis at `MOA_RUN_LIVE_REDIS_URL` + /// (the local compose stack exposes Valkey; default `redis://127.0.0.1:6379`). + #[tokio::test] + #[ignore = "requires a live Redis; set MOA_RUN_LIVE_REDIS=1"] + async fn global_limiter_enforces_a_shared_limit_over_live_redis() { + let enabled = std::env::var("MOA_RUN_LIVE_REDIS") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false); + if !enabled { + panic!("MOA_RUN_LIVE_REDIS=1 is required to run the live Redis concurrency test"); + } + let url = std::env::var("MOA_RUN_LIVE_REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let store: Arc = Arc::new( + moa_runtime_store::RedisRuntimeCacheStore::new(&url) + .await + .expect("connect to live Redis"), + ); + let key = format!("moa:test:concurrency:{}", system_now_ms()); + let ttl = Duration::from_secs(30); + let replica_a = GlobalConcurrency::for_test( + Arc::clone(&store), + key.clone(), + 2, + ttl, + MillisClock::system(), + ); + let replica_b = GlobalConcurrency::for_test( + Arc::clone(&store), + key.clone(), + 2, + ttl, + MillisClock::system(), + ); + + let slot_a = replica_a + .acquire(Duration::from_millis(200)) + .await + .expect("first shared slot"); + let slot_b = replica_b + .acquire(Duration::from_millis(200)) + .await + .expect("second shared slot"); + assert!( + replica_a + .acquire(Duration::from_millis(200)) + .await + .is_none(), + "the shared Redis gate must reject the third holder" + ); + + // Releasing a slot deletes its lease from Redis (RAII drop spawns release). + drop(slot_a); + tokio::time::sleep(Duration::from_millis(300)).await; + let slot_c = replica_b + .acquire(Duration::from_millis(500)) + .await + .expect("a released slot frees for the next caller"); + + drop(slot_b); + drop(slot_c); + tokio::time::sleep(Duration::from_millis(300)).await; + let _ = store.delete(&key).await; + } +} diff --git a/crates/moa-providers/src/core/mod.rs b/crates/moa-providers/src/core/mod.rs index 9b65d858..9bf63a31 100644 --- a/crates/moa-providers/src/core/mod.rs +++ b/crates/moa-providers/src/core/mod.rs @@ -1,7 +1,9 @@ //! Shared provider plumbing used by vendor adapters. pub(crate) mod concurrency; +pub(crate) mod concurrency_factory; pub mod factory; +pub(crate) mod global_concurrency; pub(crate) mod http; pub(crate) mod instrumentation; pub mod models; diff --git a/crates/moa-providers/src/core/pacer.rs b/crates/moa-providers/src/core/pacer.rs index 057c9a67..ff3afe11 100644 --- a/crates/moa-providers/src/core/pacer.rs +++ b/crates/moa-providers/src/core/pacer.rs @@ -10,6 +10,12 @@ //! Pacing is process-local with no distributed coordination. Provider rate //! limits are enforced per API key, so a multi-instance fleet sharing one key //! should configure each instance with its fraction of the documented budget. +//! +//! Note: in-flight *concurrency* can now be coordinated across replicas via +//! runtime-store leases (see +//! [`global_concurrency`](super::global_concurrency)); per-minute *pacing* here +//! deliberately remains process-local — a distributed token bucket was out of +//! scope, and the per-instance-fraction guidance above still applies to pacing. use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; diff --git a/crates/moa-providers/src/embedding/cohere.rs b/crates/moa-providers/src/embedding/cohere.rs index 466f9329..f2a4e57d 100644 --- a/crates/moa-providers/src/embedding/cohere.rs +++ b/crates/moa-providers/src/embedding/cohere.rs @@ -7,9 +7,7 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; @@ -54,7 +52,7 @@ impl CohereEmbedding { input_type: COHERE_DEFAULT_INPUT_TYPE.to_string(), dimensions: COHERE_DEFAULT_DIMENSIONS, pacer: RatePacer::new(PacerConfig::inputs_per_min(COHERE_EMBED_INPUTS_PER_MIN)), - limiter: ConcurrencyLimiter::new(DEFAULT_EMBEDDING_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -81,6 +79,12 @@ impl CohereEmbedding { /// Overrides the in-flight concurrency ceiling for embedding requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -100,9 +104,13 @@ impl CohereEmbedding { async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // Take an in-flight slot before spending rate budget, then hold it across // the round trip (see `ConcurrencyLimiter` for the ordering rationale). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; // Cohere Embed is limited by inputs/min; pace on this chunk's input count. self.pacer.acquire(1, inputs.len() as u32).await; diff --git a/crates/moa-providers/src/embedding/factory.rs b/crates/moa-providers/src/embedding/factory.rs index 354a3a07..591c1719 100644 --- a/crates/moa-providers/src/embedding/factory.rs +++ b/crates/moa-providers/src/embedding/factory.rs @@ -26,6 +26,8 @@ use super::{ CohereEmbedding, EmbedderConstructionRole, GeminiEmbeddingEmbedder, OpenAIEmbedding, ZeroEntropyEmbedding, }; +use crate::core::concurrency::ConcurrencyLimiter; +use crate::core::concurrency_factory::{CallKind, ProviderConcurrency}; use crate::core::pacer::PacerConfig; use crate::model_selection::{normalize_provider_name, split_explicit_provider_model}; @@ -81,7 +83,7 @@ impl EmbeddingProviderKind { match self { Self::OpenAi => { let api_key = read_api_key("MOA_OPENAI_API_KEY", &config.providers.openai.api_key)?; - let provider = OpenAIEmbedding::new(api_key, model)?; + let provider = OpenAIEmbedding::new(api_key.clone(), model)?; if let Some(output_dim) = output_dim && provider.dimensions() != output_dim { @@ -92,19 +94,25 @@ impl EmbeddingProviderKind { } Ok(Arc::new(apply_overrides( provider, + config, + OPENAI_PROVIDER_NAME, + &api_key, config.providers.openai.max_inputs_per_min, config.providers.openai.max_concurrent_requests, ))) } Self::Cohere => { let api_key = read_api_key("MOA_COHERE_API_KEY", &config.providers.cohere.api_key)?; - let mut provider = CohereEmbedding::new(api_key, model)? + let mut provider = CohereEmbedding::new(api_key.clone(), model)? .with_input_type(cohere_input_type_for_role(role)); if let Some(output_dim) = output_dim { provider = provider.with_dimensions(output_dim)?; } Ok(Arc::new(apply_overrides( provider, + config, + COHERE_PROVIDER_NAME, + &api_key, config.providers.cohere.max_inputs_per_min, config.providers.cohere.max_concurrent_requests, ))) @@ -122,12 +130,15 @@ impl EmbeddingProviderKind { "MOA_ZEROENTROPY_API_KEY", &config.providers.zeroentropy.api_key, )?; - let mut provider = ZeroEntropyEmbedding::new(api_key, model)?; + let mut provider = ZeroEntropyEmbedding::new(api_key.clone(), model)?; if let Some(output_dim) = output_dim { provider = provider.with_dimensions(output_dim)?; } Ok(Arc::new(apply_overrides( provider, + config, + ZEROENTROPY_PROVIDER_NAME, + &api_key, config.providers.zeroentropy.max_inputs_per_min, config.providers.zeroentropy.max_concurrent_requests, ))) @@ -144,18 +155,10 @@ fn embed_pacer_override(max_inputs_per_min: Option) -> Option max_inputs_per_min.map(PacerConfig::inputs_per_min) } -/// Maps a configured `max_concurrent_requests` override to an in-flight ceiling. -/// -/// `None` leaves the provider's default embedding concurrency window in place; a -/// configured value overrides it (0 is treated as unbounded by the limiter). -fn concurrency_override(max_concurrent_requests: Option) -> Option { - max_concurrent_requests.map(|max| max as usize) -} - trait EmbeddingOverrides: Sized { fn with_rate_limits(self, config: PacerConfig) -> Self; - fn with_max_concurrent_requests(self, max_in_flight: usize) -> Self; + fn with_limiter(self, limiter: ConcurrencyLimiter) -> Self; } impl EmbeddingOverrides for OpenAIEmbedding { @@ -163,8 +166,8 @@ impl EmbeddingOverrides for OpenAIEmbedding { Self::with_rate_limits(self, config) } - fn with_max_concurrent_requests(self, max_in_flight: usize) -> Self { - Self::with_max_concurrent_requests(self, max_in_flight) + fn with_limiter(self, limiter: ConcurrencyLimiter) -> Self { + Self::with_limiter(self, limiter) } } @@ -173,8 +176,8 @@ impl EmbeddingOverrides for CohereEmbedding { Self::with_rate_limits(self, config) } - fn with_max_concurrent_requests(self, max_in_flight: usize) -> Self { - Self::with_max_concurrent_requests(self, max_in_flight) + fn with_limiter(self, limiter: ConcurrencyLimiter) -> Self { + Self::with_limiter(self, limiter) } } @@ -183,8 +186,8 @@ impl EmbeddingOverrides for GeminiEmbeddingEmbedder { Self::with_rate_limits(self, config) } - fn with_max_concurrent_requests(self, max_in_flight: usize) -> Self { - Self::with_max_concurrent_requests(self, max_in_flight) + fn with_limiter(self, limiter: ConcurrencyLimiter) -> Self { + Self::with_limiter(self, limiter) } } @@ -193,23 +196,31 @@ impl EmbeddingOverrides for ZeroEntropyEmbedding { Self::with_rate_limits(self, config) } - fn with_max_concurrent_requests(self, max_in_flight: usize) -> Self { - Self::with_max_concurrent_requests(self, max_in_flight) + fn with_limiter(self, limiter: ConcurrencyLimiter) -> Self { + Self::with_limiter(self, limiter) } } +/// Applies pacer overrides and the config-driven (or globally-coordinated) +/// embedding concurrency limiter for one provider credential. fn apply_overrides( mut provider: T, + config: &MoaConfig, + provider_name: &str, + credential: &str, max_inputs_per_min: Option, max_concurrent_requests: Option, ) -> T { if let Some(pacer) = embed_pacer_override(max_inputs_per_min) { provider = provider.with_rate_limits(pacer); } - if let Some(max) = concurrency_override(max_concurrent_requests) { - provider = provider.with_max_concurrent_requests(max); - } - provider + let limiter = ProviderConcurrency::from_config(config).limiter( + CallKind::Embedding, + provider_name, + credential, + max_concurrent_requests, + ); + provider.with_limiter(limiter) } /// Builds a vector-space embedder from the tenant memory embedder configuration. @@ -232,7 +243,10 @@ fn build_gemini_embedder( let cfg = &config.memory.vector.embedder; let api_key = read_api_key("MOA_GOOGLE_API_KEY", &config.providers.google.api_key)?; Ok(apply_overrides( - GeminiEmbeddingEmbedder::new(api_key, cfg.output_dim, role)?, + GeminiEmbeddingEmbedder::new(api_key.clone(), cfg.output_dim, role)?, + config, + GEMINI_PROVIDER_NAME, + &api_key, config.providers.google.max_inputs_per_min, config.providers.google.max_concurrent_requests, )) @@ -316,14 +330,6 @@ mod tests { ); } - #[test] - fn concurrency_override_maps_configured_in_flight_ceiling() { - // Pins: a configured max_concurrent_requests becomes the embedder's - // in-flight ceiling; an unset value leaves the provider default in place. - assert_eq!(super::concurrency_override(None), None); - assert_eq!(super::concurrency_override(Some(4)), Some(4)); - } - #[test] fn embedding_provider_kind_accepts_supported_provider_prefixes() { // Pins: provider:model parsing accepts provider ids, not model aliases in provider position. diff --git a/crates/moa-providers/src/embedding/gemini.rs b/crates/moa-providers/src/embedding/gemini.rs index 6612c78c..69191481 100644 --- a/crates/moa-providers/src/embedding/gemini.rs +++ b/crates/moa-providers/src/embedding/gemini.rs @@ -11,9 +11,7 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{ build_json_http_client, decode_json_response, validate_embedding_count, validate_embedding_dimension, @@ -72,7 +70,7 @@ impl GeminiEmbeddingEmbedder { role, // Pacing off by default; Gemini limits are tier-specific. pacer: RatePacer::new(PacerConfig::disabled()), - limiter: ConcurrencyLimiter::new(DEFAULT_EMBEDDING_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -92,6 +90,12 @@ impl GeminiEmbeddingEmbedder { /// Overrides the in-flight concurrency ceiling for embedding requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -103,9 +107,13 @@ impl GeminiEmbeddingEmbedder { /// embeddings preserve request order one-to-one with `texts`. async fn embed_batch(&self, texts: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; self.pacer.acquire(1, texts.len() as u32).await; let requests = texts diff --git a/crates/moa-providers/src/embedding/openai.rs b/crates/moa-providers/src/embedding/openai.rs index 5d493d8a..5eb6ad66 100644 --- a/crates/moa-providers/src/embedding/openai.rs +++ b/crates/moa-providers/src/embedding/openai.rs @@ -6,9 +6,7 @@ use moa_core::traits::EmbeddingProvider; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; @@ -41,7 +39,7 @@ impl OpenAIEmbedding { embeddings_url: OPENAI_EMBEDDINGS_URL.to_string(), // Pacing off by default; OpenAI limits are tier-specific. pacer: RatePacer::new(PacerConfig::disabled()), - limiter: ConcurrencyLimiter::new(DEFAULT_EMBEDDING_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -61,6 +59,12 @@ impl OpenAIEmbedding { /// Overrides the in-flight concurrency ceiling for embedding requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -72,9 +76,13 @@ impl OpenAIEmbedding { /// returned embeddings preserve request order one-to-one with `inputs`. async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; self.pacer.acquire(1, inputs.len() as u32).await; let payload: OpenAIEmbeddingResponse = post_json( diff --git a/crates/moa-providers/src/embedding/zeroentropy.rs b/crates/moa-providers/src/embedding/zeroentropy.rs index e37b013d..288f2b86 100644 --- a/crates/moa-providers/src/embedding/zeroentropy.rs +++ b/crates/moa-providers/src/embedding/zeroentropy.rs @@ -6,9 +6,7 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_EMBEDDING_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{ build_json_http_client, post_json, validate_embedding_count, validate_embedding_dimension, }; @@ -49,7 +47,7 @@ impl ZeroEntropyEmbedding { dimensions: ZEROENTROPY_DEFAULT_DIMENSIONS, // Pacing off by default; ZeroEntropy limits are tier-specific. pacer: RatePacer::new(PacerConfig::disabled()), - limiter: ConcurrencyLimiter::new(DEFAULT_EMBEDDING_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -69,6 +67,12 @@ impl ZeroEntropyEmbedding { /// Overrides the in-flight concurrency ceiling for embedding requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -88,9 +92,13 @@ impl ZeroEntropyEmbedding { async fn embed_chunk(&self, inputs: &[String]) -> Result>> { // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; self.pacer.acquire(1, inputs.len() as u32).await; let payload: ZeroEntropyEmbeddingResponse = post_json( diff --git a/crates/moa-providers/src/lib.rs b/crates/moa-providers/src/lib.rs index 00c18854..bd3ad937 100644 --- a/crates/moa-providers/src/lib.rs +++ b/crates/moa-providers/src/lib.rs @@ -20,6 +20,7 @@ pub use adapters::openai_responses::debug_build_openai_request_body; pub use adapters::scripted::{ ScriptedBlock, ScriptedFault, ScriptedProvider, ScriptedResponse, ScriptedTiming, }; +pub use core::concurrency_factory::install_coordination_store; pub use core::factory::{ build_provider_from_config, build_provider_from_model, build_provider_from_selection, resolve_provider_selection, resolve_rewriter_provider, diff --git a/crates/moa-providers/src/rerank/cohere.rs b/crates/moa-providers/src/rerank/cohere.rs index 96fff1c2..a1941559 100644 --- a/crates/moa-providers/src/rerank/cohere.rs +++ b/crates/moa-providers/src/rerank/cohere.rs @@ -5,9 +5,7 @@ use moa_core::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_RERANK_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{build_json_http_client, post_json}; use crate::core::pacer::{PacerConfig, RatePacer}; use crate::core::rate_guard; @@ -40,7 +38,7 @@ impl CohereReranker { pacer: RatePacer::new(PacerConfig::requests_per_min( COHERE_RERANK_REQUESTS_PER_MIN, )), - limiter: ConcurrencyLimiter::new(DEFAULT_RERANK_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -60,6 +58,12 @@ impl CohereReranker { /// Overrides the in-flight concurrency ceiling for rerank requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -80,9 +84,13 @@ impl Reranker for CohereReranker { } // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; // Cohere Rerank is limited by requests/min. self.pacer.acquire(1, 0).await; diff --git a/crates/moa-providers/src/rerank/factory.rs b/crates/moa-providers/src/rerank/factory.rs index de3add85..75d4e9f0 100644 --- a/crates/moa-providers/src/rerank/factory.rs +++ b/crates/moa-providers/src/rerank/factory.rs @@ -9,6 +9,7 @@ use super::cohere::COHERE_DEFAULT_RERANK_MODEL; use super::zeroentropy::ZEROENTROPY_DEFAULT_RERANK_MODEL; use super::zeroentropy::ZeroEntropyRerankLatency; use super::{CohereReranker, NOOP_RERANK_MODEL, NoopReranker, Reranker, ZeroEntropyReranker}; +use crate::core::concurrency_factory::{CallKind, ProviderConcurrency}; use crate::core::pacer::PacerConfig; use crate::model_selection::{normalize_provider_name, split_explicit_provider_model}; @@ -124,15 +125,17 @@ fn build_provider(provider: RerankerProviderKind, config: &MoaConfig) -> Result< "MOA_COHERE_API_KEY", &config.providers.cohere.api_key, )?; - let mut reranker = CohereReranker::new(api_key)?; + let mut reranker = CohereReranker::new(api_key.clone())?; if let Some(pacer) = rerank_pacer_override(config.providers.cohere.max_requests_per_min) { reranker = reranker.with_rate_limits(pacer); } - if let Some(max) = concurrency_override(config.providers.cohere.max_concurrent_requests) - { - reranker = reranker.with_max_concurrent_requests(max); - } + reranker = reranker.with_limiter(ProviderConcurrency::from_config(config).limiter( + CallKind::Rerank, + COHERE_PROVIDER_NAME, + &api_key, + config.providers.cohere.max_concurrent_requests, + )); Ok(Arc::new(reranker)) } RerankerProviderKind::ZeroEntropy => { @@ -148,17 +151,18 @@ fn build_provider(provider: RerankerProviderKind, config: &MoaConfig) -> Result< .filter(|value| !value.trim().is_empty()) .map(ZeroEntropyRerankLatency::parse) .transpose()?; - let mut reranker = ZeroEntropyReranker::new(api_key)?.with_latency(latency); + let mut reranker = ZeroEntropyReranker::new(api_key.clone())?.with_latency(latency); if let Some(pacer) = rerank_pacer_override(config.providers.zeroentropy.max_requests_per_min) { reranker = reranker.with_rate_limits(pacer); } - if let Some(max) = - concurrency_override(config.providers.zeroentropy.max_concurrent_requests) - { - reranker = reranker.with_max_concurrent_requests(max); - } + reranker = reranker.with_limiter(ProviderConcurrency::from_config(config).limiter( + CallKind::Rerank, + ZEROENTROPY_PROVIDER_NAME, + &api_key, + config.providers.zeroentropy.max_concurrent_requests, + )); Ok(Arc::new(reranker)) } } @@ -172,14 +176,6 @@ fn rerank_pacer_override(max_requests_per_min: Option) -> Option) -> Option { - max_concurrent_requests.map(|max| max as usize) -} - fn ensure_no_zeroentropy_latency(config: &MoaConfig, provider: RerankerProviderKind) -> Result<()> { if provider != RerankerProviderKind::ZeroEntropy && config @@ -218,14 +214,6 @@ mod tests { ); } - #[test] - fn concurrency_override_maps_configured_in_flight_ceiling() { - // Pins: a configured max_concurrent_requests becomes the reranker's - // in-flight ceiling; an unset value leaves the provider default in place. - assert_eq!(super::concurrency_override(None), None); - assert_eq!(super::concurrency_override(Some(3)), Some(3)); - } - #[test] fn reranker_provider_kind_accepts_supported_provider_prefixes() { // Pins: provider:model parsing accepts provider ids, not model aliases in provider position. diff --git a/crates/moa-providers/src/rerank/zeroentropy.rs b/crates/moa-providers/src/rerank/zeroentropy.rs index 4b0c275f..08a30454 100644 --- a/crates/moa-providers/src/rerank/zeroentropy.rs +++ b/crates/moa-providers/src/rerank/zeroentropy.rs @@ -5,9 +5,7 @@ use moa_core::{MoaError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::core::concurrency::{ - ConcurrencyLimiter, DEFAULT_BLOCK_THRESHOLD, DEFAULT_RERANK_CONCURRENCY, -}; +use crate::core::concurrency::{ConcurrencyLimiter, DEFAULT_MAX_IN_FLIGHT}; use crate::core::http::{build_json_http_client, post_json}; use crate::core::pacer::{PacerConfig, RatePacer}; use crate::core::rate_guard; @@ -67,7 +65,7 @@ impl ZeroEntropyReranker { latency: None, // Pacing off by default; ZeroEntropy limits are tier-specific. pacer: RatePacer::new(PacerConfig::disabled()), - limiter: ConcurrencyLimiter::new(DEFAULT_RERANK_CONCURRENCY), + limiter: ConcurrencyLimiter::new(DEFAULT_MAX_IN_FLIGHT), }) } @@ -87,6 +85,12 @@ impl ZeroEntropyReranker { /// Overrides the in-flight concurrency ceiling for rerank requests. #[must_use] + /// Replaces the in-flight concurrency limiter (config-driven or global). + pub(crate) fn with_limiter(mut self, limiter: ConcurrencyLimiter) -> Self { + self.limiter = limiter; + self + } + pub fn with_max_concurrent_requests(mut self, max_in_flight: usize) -> Self { self.limiter = ConcurrencyLimiter::new(max_in_flight); self @@ -114,9 +118,13 @@ impl Reranker for ZeroEntropyReranker { } // In-flight slot first, then rate budget (see `ConcurrencyLimiter`). - let _permit = match self.limiter.acquire_within(DEFAULT_BLOCK_THRESHOLD).await { + let _permit = match self.limiter.acquire().await { Some(lease) => lease, - None => return Err(rate_guard::rate_limited_saturated(DEFAULT_BLOCK_THRESHOLD)), + None => { + return Err(rate_guard::rate_limited_saturated( + self.limiter.block_threshold(), + )); + } }; self.pacer.acquire(1, 0).await; let body: ZeroEntropyRerankResponse = post_json( diff --git a/crates/moa-providers/tests/cohere_reranker_live.rs b/crates/moa-providers/tests/cohere_reranker_live.rs index 8e9b2f0f..1d095657 100644 --- a/crates/moa-providers/tests/cohere_reranker_live.rs +++ b/crates/moa-providers/tests/cohere_reranker_live.rs @@ -38,7 +38,7 @@ async fn cohere_rerank_v4_fast_prioritizes_relevant_retrieval_candidate() { }; let reranker = CohereReranker::new(api_key).expect("Cohere reranker should build"); let documents = vec![ - "MOA deploys its local validation service to fly.io.".to_string(), + "MOA deploys its local validation service to railway.".to_string(), "MOA stores memory facts in PostgreSQL tables with RLS.".to_string(), "The hosted API surfaces status output and approval prompts.".to_string(), ]; diff --git a/crates/moa-providers/tests/providers_offline/cohere_reranker_offline.rs b/crates/moa-providers/tests/providers_offline/cohere_reranker_offline.rs index 43b35c2c..2338069b 100644 --- a/crates/moa-providers/tests/providers_offline/cohere_reranker_offline.rs +++ b/crates/moa-providers/tests/providers_offline/cohere_reranker_offline.rs @@ -10,7 +10,7 @@ const QUERY: &str = "Where does MOA deploy the local validation service?"; fn documents() -> Vec { vec![ - "MOA deploys its local validation service to fly.io.".to_string(), + "MOA deploys its local validation service to railway.".to_string(), "MOA stores memory facts in PostgreSQL tables with RLS.".to_string(), "The hosted API surfaces status output and approval prompts.".to_string(), ] @@ -63,7 +63,7 @@ async fn cohere_reranker_offline_maps_out_of_order_hits_back_to_documents_and_dr ); assert_eq!( documents[hits[1].index], - "MOA deploys its local validation service to fly.io." + "MOA deploys its local validation service to railway." ); } diff --git a/crates/moa-providers/tests/providers_offline/zeroentropy_reranker_offline.rs b/crates/moa-providers/tests/providers_offline/zeroentropy_reranker_offline.rs index c084c966..e5be46ee 100644 --- a/crates/moa-providers/tests/providers_offline/zeroentropy_reranker_offline.rs +++ b/crates/moa-providers/tests/providers_offline/zeroentropy_reranker_offline.rs @@ -45,7 +45,7 @@ async fn zeroentropy_reranker_offline_maps_out_of_order_hits_back_to_documents_a .with_endpoint(format!("{}/v1/models/rerank", server.uri())) .with_latency(Some(ZeroEntropyRerankLatency::Fast)); let documents = vec![ - "MOA deploys its local validation service to fly.io.".to_string(), + "MOA deploys its local validation service to railway.".to_string(), "MOA stores memory facts in PostgreSQL tables with RLS.".to_string(), "The hosted API surfaces status output and approval prompts.".to_string(), ]; @@ -68,7 +68,7 @@ async fn zeroentropy_reranker_offline_maps_out_of_order_hits_back_to_documents_a ); assert_eq!( documents[hits[1].index], - "MOA deploys its local validation service to fly.io." + "MOA deploys its local validation service to railway." ); } diff --git a/crates/moa-providers/tests/zeroentropy_reranker_live.rs b/crates/moa-providers/tests/zeroentropy_reranker_live.rs index f3db7974..4743242f 100644 --- a/crates/moa-providers/tests/zeroentropy_reranker_live.rs +++ b/crates/moa-providers/tests/zeroentropy_reranker_live.rs @@ -38,7 +38,7 @@ async fn zeroentropy_zerank_2_prioritizes_relevant_retrieval_candidate() { }; let reranker = ZeroEntropyReranker::new(api_key).expect("ZeroEntropy reranker should build"); let documents = vec![ - "MOA deploys its local validation service to fly.io.".to_string(), + "MOA deploys its local validation service to railway.".to_string(), "MOA stores memory facts in PostgreSQL tables with RLS.".to_string(), "The hosted API surfaces status output and approval prompts.".to_string(), ]; diff --git a/crates/moa-skills/src/format.rs b/crates/moa-skills/src/format.rs index 178852a4..b9e436f1 100644 --- a/crates/moa-skills/src/format.rs +++ b/crates/moa-skills/src/format.rs @@ -275,17 +275,17 @@ mod tests { use super::{SkillDocument, parse_skill_markdown, render_skill_markdown, slugify_skill_name}; const VALID_SKILL: &str = r#"--- -name: deploy-to-fly -description: "Deploy applications to Fly.io staging and production" -compatibility: "Requires flyctl auth and repo write access" +name: deploy-to-staging +description: "Deploy applications to a staging and production environment" +compatibility: "Requires deploy auth and repo write access" allowed-tools: bash file_read metadata: moa-version: "1.2" - moa-tags: "deployment, fly, devops" + moa-tags: "deployment, staging, devops" moa-estimated-tokens: "1200" --- -# Deploy to Fly.io +# Deploy to Staging Run the deploy flow. "#; @@ -294,10 +294,10 @@ Run the deploy flow. fn parses_valid_skill_markdown() { let skill = parse_skill_markdown(VALID_SKILL).unwrap(); - assert_eq!(skill.frontmatter.name, "deploy-to-fly"); + assert_eq!(skill.frontmatter.name, "deploy-to-staging"); assert_eq!( skill.frontmatter.tags(), - vec!["deployment", "fly", "devops"] + vec!["deployment", "staging", "devops"] ); assert_eq!(skill.frontmatter.allowed_tools, vec!["bash", "file_read"]); assert_eq!(skill.frontmatter.estimated_tokens(&skill.body), 1200); @@ -388,14 +388,17 @@ metadata: #[test] fn slugifies_skill_names_consistently() { - assert_eq!(slugify_skill_name("Deploy to Fly.io"), "deploy-to-fly-io"); + assert_eq!( + slugify_skill_name("Deploy to Staging 2.0"), + "deploy-to-staging-2-0" + ); } #[test] fn builds_sandbox_skill_path() { assert_eq!( - super::build_skill_path("Deploy to Fly.io"), - ".moa/skills/deploy-to-fly-io/SKILL.md" + super::build_skill_path("Deploy to Staging 2.0"), + ".moa/skills/deploy-to-staging-2-0/SKILL.md" ); } @@ -408,12 +411,12 @@ metadata: }) .unwrap(); - assert!(rendered.contains("name: deploy-to-fly")); + assert!(rendered.contains("name: deploy-to-staging")); assert!(rendered.contains("allowed-tools: bash file_read")); assert!( rendered.contains("moa-version: '1.2'") || rendered.contains("moa-version: \"1.2\"") ); - assert!(rendered.contains("# Deploy to Fly.io")); + assert!(rendered.contains("# Deploy to Staging")); } #[test] diff --git a/crates/moa-skills/tests/distillation_db_memory.rs b/crates/moa-skills/tests/distillation_db_memory.rs index 069369cd..e0b74142 100644 --- a/crates/moa-skills/tests/distillation_db_memory.rs +++ b/crates/moa-skills/tests/distillation_db_memory.rs @@ -10,7 +10,7 @@ use moa_skills::distiller::{ DistillationOutcome, DistillationSkipReason, distill_skill_from_experience_with_learning, }; use support::{ - SESSION_WITH_4_TOOL_CALLS, SESSION_WITH_5_TOOL_CALLS, experience_input, learning_store, + SESSION_WITH_4_TOOL_CALLS, SESSION_WITH_8_TOOL_CALLS, experience_input, learning_store, load_optional_active_skill, load_session_fixture, scripted_router, seed_skill, session_storage_partition_id, setup_test_db, skill_markdown, tenant_scope, test_config, }; @@ -25,11 +25,11 @@ fn fixture_task(loaded: &support::LoadedSession) -> String { } #[tokio::test] -async fn resolved_experience_with_5_tool_calls_triggers_distillation() { +async fn resolved_experience_with_8_tool_calls_triggers_distillation() { // Pins: a learnable resolved experience above the tool-call threshold produces a // reviewable draft proposal without creating an active skill row. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let proposed = skill_markdown( "oauth-refresh-regression", @@ -88,7 +88,7 @@ async fn experience_with_4_tool_calls_does_not_trigger_distillation() { async fn failed_experience_does_not_trigger_distillation_even_above_threshold() { // Pins: an experience with a failed assessed outcome cannot seed a reusable skill, // regardless of how many tool calls the segment contains. - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let mut input = experience_input(&loaded, &fixture_task(&loaded)); input.experience.outcome = SegmentOutcome::Failed; @@ -113,7 +113,7 @@ async fn failed_experience_does_not_trigger_distillation_even_above_threshold() #[tokio::test] async fn distillation_above_similarity_threshold_routes_to_improver() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -158,7 +158,7 @@ async fn distillation_above_similarity_threshold_routes_to_improver() { #[tokio::test] async fn distillation_below_similarity_threshold_creates_new_skill() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -197,7 +197,7 @@ async fn distillation_candidate_includes_lineage_pointers_to_session_and_experie // Pins: the review candidate records both the originating session and the source // experience so promoted learning is auditable back to its evidence. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let proposed = skill_markdown( "auth-lineage-distilled", diff --git a/crates/moa-skills/tests/draft_proposals_db_memory.rs b/crates/moa-skills/tests/draft_proposals_db_memory.rs index 2c6d8ac1..43d2ac00 100644 --- a/crates/moa-skills/tests/draft_proposals_db_memory.rs +++ b/crates/moa-skills/tests/draft_proposals_db_memory.rs @@ -11,7 +11,7 @@ use moa_core::{LearningCandidateStatus, LearningCandidateType}; use moa_skills::distiller::{DistillationOutcome, distill_skill_from_experience_with_learning}; use moa_skills::improver::{ImprovementResult, improve_skill_from_experience_with_learning}; use support::{ - BASELINE_SKILL, IMPROVED_SKILL, SESSION_WITH_5_TOOL_CALLS, active_semantic_version, + BASELINE_SKILL, IMPROVED_SKILL, SESSION_WITH_8_TOOL_CALLS, active_semantic_version, artifact_revision_count, experience_input, learning_store, load_optional_active_skill, load_session_fixture, scripted_router, seed_skill, session_storage_partition_id, setup_test_db, skill_markdown, skill_row_count, tenant_scope, test_config, @@ -21,7 +21,7 @@ use support::{ async fn skill_creation_proposal_stores_draft_artifact_without_active_skill_db() { // Pins: generated skill creation remains a draft artifact and review candidate until accepted. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let proposed = skill_markdown( "draft-oauth-refresh", @@ -117,7 +117,7 @@ async fn skill_creation_proposal_stores_draft_artifact_without_active_skill_db() async fn skill_improvement_proposal_stores_draft_artifact_without_replacing_active_skill_db() { // Pins: generated skill improvement stores a draft and leaves the active skill unchanged. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -177,7 +177,7 @@ async fn skill_improvement_proposal_stores_draft_artifact_without_replacing_acti async fn skill_proposal_retry_reuses_candidate_id() { // Pins: retrying the same proposal reuses the candidate and draft artifact revision. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let proposed = skill_markdown( "retry-stable-draft", @@ -246,8 +246,8 @@ async fn open_proposal_for_same_skill_name_dedupes_across_sessions_db() { // skill name reuses the open Proposed candidate instead of filing a duplicate review // item and a second draft artifact revision. let test_db = setup_test_db().await; - let loaded_a = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); - let mut loaded_b = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded_a = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); + let mut loaded_b = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); loaded_b.session.id = moa_core::SessionId::new(); loaded_b.session.tenant_id = loaded_a.session.tenant_id; let (config, _temp_dir) = test_config(&test_db); @@ -306,20 +306,20 @@ async fn open_proposal_for_same_task_fingerprint_dedupes_across_skill_names_db() // open Proposed candidate for that task fingerprint is reused instead of filing a // second near-duplicate review item under the new name. let test_db = setup_test_db().await; - let loaded_a = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); - let mut loaded_b = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded_a = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); + let mut loaded_b = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); loaded_b.session.id = moa_core::SessionId::new(); loaded_b.session.tenant_id = loaded_a.session.tenant_id; let (config, _temp_dir) = test_config(&test_db); let first_name = skill_markdown( - "deploy-to-fly", - "Deploy the service to Fly", + "deploy-to-staging", + "Deploy the service to staging", "Reusable deploy workflow.", "1.0", ); let second_name = skill_markdown( - "fly-deployment", - "Deploy the service to Fly", + "staging-deployment", + "Deploy the service to staging", "Reusable deploy workflow.", "1.0", ); @@ -329,7 +329,7 @@ async fn open_proposal_for_same_task_fingerprint_dedupes_across_skill_names_db() let first = distill_skill_from_experience_with_learning( &config, &loaded_a.session, - experience_input(&loaded_a, "deploy service to fly"), + experience_input(&loaded_a, "deploy service to staging"), scripted_router([first_name]), Some(store.clone()), ) @@ -338,7 +338,7 @@ async fn open_proposal_for_same_task_fingerprint_dedupes_across_skill_names_db() // Empty scripted router: the fingerprint preflight must dedupe before any // LLM call, so an attempted generation would error the test. let _unused_second_name = second_name; - let input_b = experience_input(&loaded_b, "deploy service to fly"); + let input_b = experience_input(&loaded_b, "deploy service to staging"); let sibling_experience_id = input_b.experience.id; let second = distill_skill_from_experience_with_learning( &config, @@ -362,11 +362,11 @@ async fn open_proposal_for_same_task_fingerprint_dedupes_across_skill_names_db() "same-fingerprint proposal must reuse the open candidate despite the new name" ); assert_eq!( - artifact_revision_count(&test_db, &storage_partition_id, "deploy-to-fly").await, + artifact_revision_count(&test_db, &storage_partition_id, "deploy-to-staging").await, 1 ); assert_eq!( - artifact_revision_count(&test_db, &storage_partition_id, "fly-deployment").await, + artifact_revision_count(&test_db, &storage_partition_id, "staging-deployment").await, 0, "the differently-named duplicate must not create its own draft artifact" ); @@ -396,7 +396,7 @@ async fn open_proposal_for_same_task_fingerprint_dedupes_across_skill_names_db() async fn concurrent_skill_proposal_attempts_share_one_draft_artifact_db() { // Pins: duplicate workers proposing the same skill share one candidate and one draft revision. let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (config, _temp_dir) = test_config(&test_db); let proposed = skill_markdown( "concurrent-stable-draft", diff --git a/crates/moa-skills/tests/improver_db_memory.rs b/crates/moa-skills/tests/improver_db_memory.rs index 6821a606..a42fb49b 100644 --- a/crates/moa-skills/tests/improver_db_memory.rs +++ b/crates/moa-skills/tests/improver_db_memory.rs @@ -7,7 +7,7 @@ mod support; use moa_skills::improver::{ImprovementResult, improve_skill_from_experience_with_learning}; use support::{ - BASELINE_SKILL, IMPROVED_SKILL, REGRESSED_SKILL, RENAMED_SKILL, SESSION_WITH_5_TOOL_CALLS, + BASELINE_SKILL, IMPROVED_SKILL, REGRESSED_SKILL, RENAMED_SKILL, SESSION_WITH_8_TOOL_CALLS, active_semantic_version, artifact_revision_count, experience_input, learning_store, load_session_fixture, scripted_router, seed_skill, session_storage_partition_id, setup_test_db, skill_row_count, tenant_scope, test_config, @@ -18,7 +18,7 @@ async fn improver_that_renames_skill_is_rejected() { // Pins: an improvement that changes the skill `name` is rejected before any draft is stored, // and the active skill is left untouched (no new artifact revision). let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -57,7 +57,7 @@ async fn improver_that_renames_skill_is_rejected() { #[tokio::test] async fn improver_with_changed_body_bumps_minor_version() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -97,7 +97,7 @@ async fn improver_with_changed_body_bumps_minor_version() { #[tokio::test] async fn improver_with_unchanged_body_returns_unchanged_short_circuit() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -128,7 +128,7 @@ async fn improver_with_unchanged_body_returns_unchanged_short_circuit() { #[tokio::test] async fn improver_with_breaking_changes_to_skill_signature_bumps_major_version() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -158,7 +158,7 @@ async fn improver_with_breaking_changes_to_skill_signature_bumps_major_version() #[tokio::test] async fn improver_concurrent_attempts_on_same_skill_reuse_draft_proposal() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); @@ -233,7 +233,7 @@ async fn improver_concurrent_attempts_on_same_skill_reuse_draft_proposal() { #[tokio::test] async fn improver_emits_review_candidate_with_lineage_payload() { let test_db = setup_test_db().await; - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let (_config, _temp_dir) = test_config(&test_db); let storage_partition_id = session_storage_partition_id(&loaded.session); let scope = tenant_scope(&storage_partition_id); diff --git a/crates/moa-skills/tests/regression.rs b/crates/moa-skills/tests/regression.rs index 4b43baea..34d13ffd 100644 --- a/crates/moa-skills/tests/regression.rs +++ b/crates/moa-skills/tests/regression.rs @@ -14,12 +14,12 @@ use moa_skills::format::parse_skill_markdown; use moa_skills::regression::{ SkillRegressionSummary, compare_scores, generate_skill_test_suite_source, }; -use support::{SESSION_WITH_5_TOOL_CALLS, load_session_fixture, skill_markdown}; +use support::{SESSION_WITH_8_TOOL_CALLS, load_session_fixture, skill_markdown}; #[test] fn generated_suite_source_is_reviewable_without_writing_files() { // Pins: proposal generation can attach a regression suite as draft payload text. - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let markdown = skill_markdown( "suite-source-skill", "Generate suite source for review", @@ -79,7 +79,7 @@ fn summary(average_score: f64, failed_runs: usize) -> SkillRegressionSummary { async fn generated_regression_suite_runs_green_against_the_skill_canonical_trajectory() { // Pins: a generated skill regression suite, executed offline through the eval // evaluators, passes for the skill's own canonical behavior and catches drift. - let loaded = load_session_fixture(SESSION_WITH_5_TOOL_CALLS); + let loaded = load_session_fixture(SESSION_WITH_8_TOOL_CALLS); let markdown = skill_markdown( "drift-guard-skill", "Run the learned OAuth-refresh workflow", diff --git a/crates/moa-skills/tests/support/common.rs b/crates/moa-skills/tests/support/common.rs index 7dfa7365..336a193a 100644 --- a/crates/moa-skills/tests/support/common.rs +++ b/crates/moa-skills/tests/support/common.rs @@ -36,7 +36,7 @@ use tempfile::TempDir; use uuid::Uuid; /// Successful session fixture with exactly five tool calls. -pub const SESSION_WITH_5_TOOL_CALLS: &str = include_str!("fixtures/session_with_5_tool_calls.json"); +pub const SESSION_WITH_8_TOOL_CALLS: &str = include_str!("fixtures/session_with_8_tool_calls.json"); /// Successful session fixture below the distillation threshold. pub const SESSION_WITH_4_TOOL_CALLS: &str = include_str!("fixtures/session_with_4_tool_calls.json"); /// Baseline skill fixture used by improvement and regression tests. diff --git a/crates/moa-skills/tests/support/fixtures/session_with_5_tool_calls.json b/crates/moa-skills/tests/support/fixtures/session_with_8_tool_calls.json similarity index 63% rename from crates/moa-skills/tests/support/fixtures/session_with_5_tool_calls.json rename to crates/moa-skills/tests/support/fixtures/session_with_8_tool_calls.json index 82645b9f..a7c9f7b7 100644 --- a/crates/moa-skills/tests/support/fixtures/session_with_5_tool_calls.json +++ b/crates/moa-skills/tests/support/fixtures/session_with_8_tool_calls.json @@ -1,5 +1,5 @@ { - "comment": "Fixture: successful session with exactly five tool calls; proves the distillation threshold admits real multi-step runs.", + "comment": "Fixture: successful session with exactly eight tool calls; proves the distillation threshold admits real multi-step runs.", "session_id": "018f1a30-0000-7000-8000-000000000015", "storage_partition_id": "skills-distillation-workspace", "user_id": "skills-user", @@ -9,7 +9,10 @@ { "tool_name": "bash", "input": { "cmd": "cargo test auth_refresh" }, "output": "failing auth refresh test" }, { "tool_name": "file_search", "input": { "query": "refresh token" }, "output": "found refresh handler" }, { "tool_name": "file_read", "input": { "path": "auth.rs" }, "output": "refresh handler source" }, + { "tool_name": "grep", "input": { "pattern": "refresh_token" }, "output": "call sites listed" }, { "tool_name": "file_write", "input": { "path": "auth.rs" }, "output": "patched refresh handler" }, - { "tool_name": "bash", "input": { "cmd": "cargo test auth_refresh" }, "output": "auth refresh test passed" } + { "tool_name": "bash", "input": { "cmd": "cargo build" }, "output": "build succeeded" }, + { "tool_name": "bash", "input": { "cmd": "cargo test auth_refresh" }, "output": "auth refresh test passed" }, + { "tool_name": "bash", "input": { "cmd": "cargo clippy" }, "output": "clippy clean" } ] } diff --git a/docs/09-skills-and-learning.md b/docs/09-skills-and-learning.md index ba0baa91..cf842164 100644 --- a/docs/09-skills-and-learning.md +++ b/docs/09-skills-and-learning.md @@ -8,7 +8,7 @@ MOA uses Agent Skills-style packages: ```text .moa/skills/ - deploy-to-fly/ + deploy-to-staging/ SKILL.md scripts/ references/ diff --git a/docs/23-environment-variables.md b/docs/23-environment-variables.md new file mode 100644 index 00000000..3b1c7a0b --- /dev/null +++ b/docs/23-environment-variables.md @@ -0,0 +1,644 @@ +# Environment Variables + +This is the reference for MOA's runtime configuration through environment +variables. The variable tables below are **generated from source** (the +`MoaEnvOverlay` field set and `MoaConfig::default()`), so they stay accurate as +config changes — see [Regenerating the tables](#regenerating-the-tables). + +## How configuration works + +MOA is configured from two layers, highest precedence first: + +1. **`MOA_*` environment variables** — a flat overlay applied on top of the file + config. In cloud/Kubernetes this is the primary mechanism. +2. **Built-in defaults** — every field has a default from its Rust `Default` + impl. A field left unset keeps its default. + +The environment overlay always wins over the defaults. The typed defaults are +what you get with no `MOA_*` variables set at all. + +### Variable naming + +Each overlay variable is `MOA_` + the overlay field name, uppercased. The field +maps to a nested config path, shown in the **Config path** column: + +- `MOA_MODELS_MAIN` → `models.main` +- `MOA_PROVIDERS_CONCURRENCY_SCOPE` → `providers.concurrency.scope` +- `MOA_ANTHROPIC_API_KEY` → `providers.anthropic.api_key` + +There is **no** double-underscore nested form for the application — every +variable uses single underscores as shown above. + +### Unknown-variable check (typo protection) + +`envy` silently ignores an unrecognized `MOA_*` variable, so a typo like +`MOA_MODELS_MIAN` would quietly fall back to the default. On startup MOA audits +every `MOA_*` variable in the environment against the known overlay fields plus +an allowlist of approved special variables (see +[Special variables](#special-non-overlay-variables)): + +- **Default (warn):** unrecognized names are logged as a warning and ignored, + with a "did you mean `MOA_MODELS_MAIN`?" suggestion. +- **Strict:** set `MOA_CONFIG_ENV_STRICT=1` (or `true`/`yes`/`on`) to **fail + startup** listing the offending names. Recommended in production. + +### Secrets + +Variables marked **(secret)** carry credentials (API keys, tokens, private keys, +HMAC/hex secrets). In production, source them from a secret store (Kubernetes +Secret, Vault) rather than a checked-in file or plain env block. + +## Overlay variable reference + +Grouped by top-level config section. `_unset_`/`_none_` means the field is +`None`/absent by default; `_empty_` means an empty string default. + +### `general` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_GENERAL_DEFAULT_PROVIDER` | `general.default_provider` | openai | Default provider key | +| `MOA_GENERAL_REASONING_EFFORT` | `general.reasoning_effort` | medium | Requested reasoning effort | +| `MOA_GENERAL_USER_INSTRUCTIONS` | `general.user_instructions` | _none_ | Optional user-level preferences injected into the prompt | +| `MOA_GENERAL_WEB_SEARCH_ENABLED` | `general.web_search_enabled` | true | Whether provider-native web search should be offered to supported models | +| `MOA_GENERAL_WORKSPACE_INSTRUCTIONS` | `general.workspace_instructions` | _none_ | Optional repository-workspace instructions injected into the prompt | + +### `models` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_MODELS_AUXILIARY` | `models.auxiliary` | _none_ | Optional lower-cost model for auxiliary tasks | +| `MOA_MODELS_FALLBACK_MODELS` | `models.fallback_models` | _unset_ | Ordered fallback chain for the main-loop model, each `provider:model` or a bare model id | +| `MOA_MODELS_MAIN` | `models.main` | gpt-5.4 | Default model for the primary user-facing agent loop | + +### `providers` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_ANTHROPIC_API_KEY` | `providers.anthropic.api_key` | _empty_ | API key value loaded from runtime configuration **(secret)** | +| `MOA_ANTHROPIC_MAX_CONCURRENT_REQUESTS` | `providers.anthropic.max_concurrent_requests` | _unset_ | In-flight concurrency ceiling for this provider account — the natural place to express the credential's tier | +| `MOA_ANTHROPIC_MAX_INPUTS_PER_MIN` | `providers.anthropic.max_inputs_per_min` | _unset_ | Optional per-minute input-rate cap; `None` keeps the provider default | +| `MOA_ANTHROPIC_MAX_REQUESTS_PER_MIN` | `providers.anthropic.max_requests_per_min` | _unset_ | Optional per-minute request-rate cap; `None` keeps the provider default | +| `MOA_COHERE_API_KEY` | `providers.cohere.api_key` | _empty_ | API key value loaded from runtime configuration **(secret)** | +| `MOA_COHERE_MAX_CONCURRENT_REQUESTS` | `providers.cohere.max_concurrent_requests` | _unset_ | In-flight concurrency ceiling for this provider account — the natural place to express the credential's tier | +| `MOA_COHERE_MAX_INPUTS_PER_MIN` | `providers.cohere.max_inputs_per_min` | _unset_ | Optional per-minute input-rate cap; `None` keeps the provider default | +| `MOA_COHERE_MAX_REQUESTS_PER_MIN` | `providers.cohere.max_requests_per_min` | _unset_ | Optional per-minute request-rate cap; `None` keeps the provider default | +| `MOA_GOOGLE_API_KEY` | `providers.google.api_key` | _empty_ | API key value loaded from runtime configuration **(secret)** | +| `MOA_GOOGLE_MAX_CONCURRENT_REQUESTS` | `providers.google.max_concurrent_requests` | _unset_ | In-flight concurrency ceiling for this provider account — the natural place to express the credential's tier | +| `MOA_GOOGLE_MAX_INPUTS_PER_MIN` | `providers.google.max_inputs_per_min` | _unset_ | Optional per-minute input-rate cap; `None` keeps the provider default | +| `MOA_GOOGLE_MAX_REQUESTS_PER_MIN` | `providers.google.max_requests_per_min` | _unset_ | Optional per-minute request-rate cap; `None` keeps the provider default | +| `MOA_OPENAI_API_KEY` | `providers.openai.api_key` | _empty_ | API key value loaded from runtime configuration **(secret)** | +| `MOA_OPENAI_MAX_CONCURRENT_REQUESTS` | `providers.openai.max_concurrent_requests` | _unset_ | In-flight concurrency ceiling for this provider account — the natural place to express the credential's tier | +| `MOA_OPENAI_MAX_INPUTS_PER_MIN` | `providers.openai.max_inputs_per_min` | _unset_ | Optional per-minute input-rate cap; `None` keeps the provider default | +| `MOA_OPENAI_MAX_REQUESTS_PER_MIN` | `providers.openai.max_requests_per_min` | _unset_ | Optional per-minute request-rate cap; `None` keeps the provider default | +| `MOA_PROVIDERS_CONCURRENCY_BLOCK_THRESHOLD_MS` | `providers.concurrency.block_threshold_ms` | 2000 | How long a caller waits for a slot before reporting "saturated", in ms | +| `MOA_PROVIDERS_CONCURRENCY_DEFAULT_MAX_IN_FLIGHT` | `providers.concurrency.default_max_in_flight` | 16 | Fallback in-flight ceiling for any provider that sets no `max_concurrent_requests` of its own (`0` = unbounded) | +| `MOA_PROVIDERS_CONCURRENCY_LEASE_TTL_MS` | `providers.concurrency.lease_ttl_ms` | 600000 | Global-scope lease time-to-live, in ms: the crash backstop for a held slot | +| `MOA_PROVIDERS_CONCURRENCY_SCOPE` | `providers.concurrency.scope` | local | Whether the ceiling is enforced per process or shared across replicas | +| `MOA_ZEROENTROPY_API_KEY` | `providers.zeroentropy.api_key` | _empty_ | API key value loaded from runtime configuration **(secret)** | +| `MOA_ZEROENTROPY_MAX_CONCURRENT_REQUESTS` | `providers.zeroentropy.max_concurrent_requests` | _unset_ | In-flight concurrency ceiling for this provider account — the natural place to express the credential's tier | +| `MOA_ZEROENTROPY_MAX_INPUTS_PER_MIN` | `providers.zeroentropy.max_inputs_per_min` | _unset_ | Optional per-minute input-rate cap; `None` keeps the provider default | +| `MOA_ZEROENTROPY_MAX_REQUESTS_PER_MIN` | `providers.zeroentropy.max_requests_per_min` | _unset_ | Optional per-minute request-rate cap; `None` keeps the provider default | + +### `database` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_DATABASE_ADMIN_URL` | `database.admin_url` | _none_ | Optional direct/admin database URL for migrations and other session-sensitive flows | +| `MOA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `database.connect_timeout_seconds` | 10 | Pool acquire timeout, in seconds | +| `MOA_DATABASE_MAX_CONNECTIONS` | `database.max_connections` | 20 | Maximum pool size for the shared Postgres client | +| `MOA_DATABASE_NEON_API_KEY` | `database.neon.api_key` | _empty_ | Neon API key value loaded from runtime configuration **(secret)** | +| `MOA_DATABASE_NEON_CHECKPOINT_TTL_HOURS` | `database.neon.checkpoint_ttl_hours` | 24 | TTL for automatic checkpoint cleanup, in hours | +| `MOA_DATABASE_NEON_ENABLED` | `database.neon.enabled` | false | Whether Neon checkpoint management is enabled | +| `MOA_DATABASE_NEON_MAX_CHECKPOINTS` | `database.neon.max_checkpoints` | 5 | Maximum number of active MOA checkpoint branches | +| `MOA_DATABASE_NEON_PARENT_BRANCH_ID` | `database.neon.parent_branch_id` | main | Parent branch name or id used for checkpoint creation | +| `MOA_DATABASE_NEON_POOLED` | `database.neon.pooled` | true | Whether pooled connection URIs should be requested for checkpoint branches | +| `MOA_DATABASE_NEON_PROJECT_ID` | `database.neon.project_id` | _empty_ | Neon project identifier used for branch management | +| `MOA_DATABASE_NEON_SUSPEND_TIMEOUT_SECONDS` | `database.neon.suspend_timeout_seconds` | 300 | Auto-suspend timeout in seconds for checkpoint endpoints | +| `MOA_DATABASE_SCHEMA` | `database.schema` | _none_ | Optional Postgres schema name for isolated runtime stores | +| `MOA_DATABASE_URL` | `database.url` | postgres://moa_owner:dev@localhost:10040/moa | Runtime Postgres connection URL | + +### `memory` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_MEMORY_DIGEST_ENABLED` | `memory.digest.enabled` | false | Whether the brain context pipeline injects stored digest rows | +| `MOA_MEMORY_DIGEST_MAX_TOKENS` | `memory.digest.max_tokens` | 600 | Maximum rendered digest size using the rough chars/4 token estimate | +| `MOA_MEMORY_DIGEST_REBUILD_MIN_INTERVAL_HOURS` | `memory.digest.rebuild_min_interval_hours` | 6 | Minimum interval between digest row rebuilds during consolidation | +| `MOA_MEMORY_EMBEDDING_MODEL` | `memory.embedding_model` | openai:text-embedding-3-small | Embedding model selector used for graph memory embedding backfills and queries | +| `MOA_MEMORY_EXTRACTION_ENABLED` | `memory.extraction.enabled` | false | Whether model-backed fact extraction is enabled | +| `MOA_MEMORY_EXTRACTION_MAX_FACTS_PER_CHUNK` | `memory.extraction.max_facts_per_chunk` | 12 | Maximum facts accepted from one chunk | +| `MOA_MEMORY_EXTRACTION_MODEL` | `memory.extraction.model` | gpt-5.4-mini | Provider model selector used for extraction and memory-ingest judging | +| `MOA_MEMORY_EXTRACTION_TIMEOUT_MS` | `memory.extraction.timeout_ms` | 10000 | Provider request timeout in milliseconds | +| `MOA_MEMORY_RETRIEVAL_LINEAGE_ENABLED` | `memory.retrieval.lineage_enabled` | true | Whether retrieval writes narrow quality-scoring lineage rows | +| `MOA_MEMORY_RETRIEVAL_LINEAGE_SAMPLE_RATE` | `memory.retrieval.lineage_sample_rate` | 1.0 | Fraction of turns that write lineage rows when lineage is enabled | +| `MOA_MEMORY_RETRIEVAL_RERANKER_LATENCY` | `memory.retrieval.reranker_latency` | _none_ | Optional provider-specific reranker latency mode | +| `MOA_MEMORY_RETRIEVAL_RERANKER_MODEL` | `memory.retrieval.reranker_model` | noop | Reranker model selector | +| `MOA_MEMORY_VECTOR_EMBEDDER_NAME` | `memory.vector.embedder.name` | gemini:gemini-embedding-2 | Embedder model name | +| `MOA_MEMORY_VECTOR_EMBEDDER_OUTPUT_DIM` | `memory.vector.embedder.output_dim` | 1024 | Requested output dimensionality | +| `MOA_PII_SERVICE_URL` | `memory.pii_service_url` | _none_ | Optional HTTP base URL for the PII classification sidecar | +| `MOA_TURBOPUFFER_API_KEY` | `memory.vector.turbopuffer.api_key` | _empty_ | Turbopuffer API key value loaded from runtime configuration **(secret)** | +| `MOA_TURBOPUFFER_BAA` | `memory.vector.turbopuffer.baa_enabled` | false | Whether the configured Turbopuffer account has a BAA for restricted data | +| `MOA_TURBOPUFFER_BASE_URL` | `memory.vector.turbopuffer.base_url` | _none_ | Optional Turbopuffer API base URL override | +| `MOA_TURBOPUFFER_ENVIRONMENT` | `memory.vector.turbopuffer.environment` | _none_ | Optional namespace environment segment | +| `MOA_TURBOPUFFER_VECTOR_TYPE` | `memory.vector.turbopuffer.vector_type` | f16 | Vector element type used for the Turbopuffer projection namespace | + +### `knowledge` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_KNOWLEDGE_EXTERNAL_PARSER_DEFAULT` | `knowledge.parser.external_default` | llamaparse | Default parser for external synced records and files | +| `MOA_KNOWLEDGE_PARSERS_ENABLED` | `knowledge.parsers.enabled` | ["native","llamaparse","unstructured","reducto"] | Parser identifiers allowed for request-time or sync-run selection | +| `MOA_KNOWLEDGE_PARSER_DEFAULT` | `knowledge.parser.default` | native | Default parser for local or already-normalized content | +| `MOA_KNOWLEDGE_PROVIDERS_ENABLED` | `knowledge.providers.enabled` | ["nango","merge"] | Provider identifiers allowed for link and sync runs | +| `MOA_LLAMAPARSE_API_KEY` | `knowledge.llamaparse.api_key` | _empty_ | LlamaParse API key loaded from runtime configuration **(secret)** | +| `MOA_LLAMAPARSE_API_URL` | `knowledge.llamaparse.api_base_url` | https://api.cloud.llamaindex.ai | LlamaParse API base URL | +| `MOA_LLAMAPARSE_TIER` | `knowledge.llamaparse.tier` | agentic | LlamaParse plan or routing tier | +| `MOA_LLAMAPARSE_WEBHOOK_HEADER_NAME` | `knowledge.llamaparse.webhook_header_name` | _unset_ | Optional custom header name required on LlamaParse webhooks | +| `MOA_LLAMAPARSE_WEBHOOK_HEADER_VALUE` | `knowledge.llamaparse.webhook_header_value` | _unset_ | Optional custom header value required on LlamaParse webhooks | +| `MOA_LLAMAPARSE_WEBHOOK_SIGNING_KEY` | `knowledge.llamaparse.webhook_signing_key` | _empty_ | LlamaParse webhook signing key loaded from runtime configuration **(secret)** | +| `MOA_MERGE_API_BASE_URL` | `knowledge.merge.api_base_url` | https://api.merge.dev | Merge API base URL | +| `MOA_MERGE_API_KEY` | `knowledge.merge.api_key` | _empty_ | Merge API key loaded from runtime configuration **(secret)** | +| `MOA_MERGE_WEBHOOK_SIGNATURE_KEY` | `knowledge.merge.webhook_signature_key` | _empty_ | Merge webhook signature key loaded from runtime configuration **(secret)** | +| `MOA_NANGO_API_BASE_URL` | `knowledge.nango.api_base_url` | https://api.nango.dev | Nango API base URL | +| `MOA_NANGO_API_KEY` | `knowledge.nango.api_key` | _empty_ | Nango API key loaded from runtime configuration **(secret)** | +| `MOA_NANGO_WEBHOOK_SIGNING_KEY` | `knowledge.nango.webhook_signing_key` | _empty_ | Nango webhook signing key loaded from runtime configuration **(secret)** | +| `MOA_REDUCTO_API_KEY` | `knowledge.reducto.api_key` | _empty_ | Reducto API key loaded from runtime configuration **(secret)** | +| `MOA_REDUCTO_API_URL` | `knowledge.reducto.api_base_url` | https://platform.reducto.ai | Reducto API base URL | +| `MOA_REDUCTO_ASYNC_ENABLED` | `knowledge.reducto.async_enabled` | true | Whether Reducto asynchronous parsing is enabled | +| `MOA_REDUCTO_CHUNK_MODE` | `knowledge.reducto.chunk_mode` | variable | Reducto chunk mode | +| `MOA_REDUCTO_PARSE_MODE` | `knowledge.reducto.parse_mode` | standard | Reducto parse mode | +| `MOA_REDUCTO_WEBHOOK_HEADER_NAME` | `knowledge.reducto.webhook_header_name` | _unset_ | Optional custom header name required on Reducto webhooks | +| `MOA_REDUCTO_WEBHOOK_HEADER_VALUE` | `knowledge.reducto.webhook_header_value` | _unset_ | Optional custom header value required on Reducto webhooks | +| `MOA_REDUCTO_WEBHOOK_SIGNING_KEY` | `knowledge.reducto.webhook_signing_key` | _empty_ | Reducto webhook signing key loaded from runtime configuration **(secret)** | +| `MOA_UNSTRUCTURED_API_KEY` | `knowledge.unstructured.api_key` | _empty_ | Unstructured API key loaded from runtime configuration **(secret)** | +| `MOA_UNSTRUCTURED_API_URL` | `knowledge.unstructured.api_base_url` | https://api.unstructuredapp.io | Unstructured API base URL | +| `MOA_UNSTRUCTURED_CHUNKING_STRATEGY` | `knowledge.unstructured.chunking_strategy` | by_title | Unstructured chunking strategy | +| `MOA_UNSTRUCTURED_STRATEGY` | `knowledge.unstructured.strategy` | auto | Unstructured partition strategy | + +### `session` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_SESSION_ATTACHMENT_ACCESS_KEY_ID` | `session.attachments.access_key_id` | _none_ | Optional explicit S3 access key | +| `MOA_SESSION_ATTACHMENT_ALLOW_HTTP` | `session.attachments.allow_http` | false | Allows HTTP endpoints for local S3-compatible development | +| `MOA_SESSION_ATTACHMENT_BACKEND` | `session.attachments.backend` | s3 | Object store backend | +| `MOA_SESSION_ATTACHMENT_BUCKET` | `session.attachments.bucket` | moa-session-attachments | Bucket that stores attachment objects | +| `MOA_SESSION_ATTACHMENT_ENDPOINT` | `session.attachments.endpoint` | _none_ | Optional S3-compatible endpoint | +| `MOA_SESSION_ATTACHMENT_GCP_APPLICATION_CREDENTIALS_PATH` | `session.attachments.gcp_application_credentials_path` | _none_ | Optional GCS application credentials file path | +| `MOA_SESSION_ATTACHMENT_GCP_SERVICE_ACCOUNT_KEY` | `session.attachments.gcp_service_account_key` | _none_ | Optional inline GCS service account JSON **(secret)** | +| `MOA_SESSION_ATTACHMENT_GCP_SERVICE_ACCOUNT_PATH` | `session.attachments.gcp_service_account_path` | _none_ | Optional GCS service account file path | +| `MOA_SESSION_ATTACHMENT_PREFIX` | `session.attachments.prefix` | session-attachments | Prefix used for all MOA attachment objects in the bucket | +| `MOA_SESSION_ATTACHMENT_REGION` | `session.attachments.region` | us-east-1 | AWS/S3-compatible region | +| `MOA_SESSION_ATTACHMENT_SECRET_ACCESS_KEY` | `session.attachments.secret_access_key` | _none_ | Optional explicit S3 secret key **(secret)** | +| `MOA_SESSION_ATTACHMENT_VIRTUAL_HOSTED_STYLE` | `session.attachments.virtual_hosted_style` | false | Uses virtual-hosted-style S3 requests when true | +| `MOA_SESSION_BLOB_BACKEND` | `session.blob_backend` | postgres | Backend used for claim-check blob payloads | +| `MOA_SESSION_BLOB_DIR` | `session.blob_dir` | _none_ | Root directory for local blob storage | +| `MOA_SESSION_BLOB_THRESHOLD_BYTES` | `session.blob_threshold_bytes` | 65536 | Offload threshold in bytes for large event payload strings | + +### `session_limits` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_SESSION_LIMITS_LOOP_DETECTION_THRESHOLD` | `session_limits.loop_detection_threshold` | 3 | Number of identical consecutive turn fingerprints that triggers a loop pause | +| `MOA_SESSION_LIMITS_MAX_TOOL_CALLS` | `session_limits.max_tool_calls` | 30 | Maximum tool calls allowed within one turn | +| `MOA_SESSION_LIMITS_MAX_TURNS` | `session_limits.max_turns` | 50 | Maximum completed turns per session before pausing | +| `MOA_SESSION_LIMITS_PROGRESS_FIRST_DELAY_MS` | `session_limits.progress_first_delay_ms` | 8000 | Delay before the first durable progress update is eligible, in milliseconds | +| `MOA_SESSION_LIMITS_PROGRESS_INTERVAL_MS` | `session_limits.progress_interval_ms` | 8000 | Minimum interval between durable progress updates, in milliseconds | +| `MOA_SESSION_LIMITS_PROGRESS_NARRATION_ENABLED` | `session_limits.progress_narration_enabled` | true | Whether default-on natural-language progress narration is enabled | +| `MOA_SESSION_LIMITS_PROGRESS_NARRATION_INTERVAL_MS` | `session_limits.progress_narration_interval_ms` | 20000 | Minimum interval between progress narrations, in milliseconds | +| `MOA_SESSION_LIMITS_PROGRESS_NARRATION_MAX_PER_WINDOW` | `session_limits.progress_narration_max_per_window` | 30 | Maximum number of narrations per rolling window before the narrator backs off | +| `MOA_SESSION_LIMITS_PROGRESS_NARRATION_MAX_TOKENS` | `session_limits.progress_narration_max_tokens` | 120 | Maximum output tokens for one progress-narration completion | +| `MOA_SESSION_LIMITS_PROGRESS_NARRATION_MODEL` | `session_limits.progress_narration_model` | _none_ | Optional model id override for progress narration | +| `MOA_SESSION_LIMITS_SIMPLE_MAX_TURNS` | `session_limits.simple_max_turns` | 1 | Maximum model loop iterations for requests classified as simple | +| `MOA_SESSION_LIMITS_STANDARD_MAX_TURNS` | `session_limits.standard_max_turns` | 6 | Maximum model loop iterations for requests classified as standard | +| `MOA_SESSION_LIMITS_WORKER_CLEANUP_GRACE_MS` | `session_limits.worker_cleanup_grace_ms` | 60000 | Grace window before a terminal worker self-cleans (removes itself from the parent fan-out and clears its VO state) after reporting its result | +| `MOA_SESSION_LIMITS_WORKER_HEARTBEAT_INTERVAL_MS` | `session_limits.worker_heartbeat_interval_ms` | 15000 | Target cadence, in milliseconds, at which an active child refreshes its telemetry-plane heartbeat while running | +| `MOA_SESSION_LIMITS_WORKER_HEARTBEAT_STALE_MS` | `session_limits.worker_heartbeat_stale_ms` | 60000 | Age, in milliseconds, beyond which an active child's last heartbeat is treated as stale by the per-child liveness watchdog | +| `MOA_SESSION_LIMITS_WORKER_INPUT_TIMEOUT_MS` | `session_limits.worker_input_timeout_ms` | 1800000 | Maximum time a child `request_input` round-trip blocks on its awakeable before returning a "no input received" result so the child can proceed or abort | +| `MOA_SESSION_LIMITS_WORKER_RESUME_MAX_PER_WINDOW` | `session_limits.worker_resume_max_per_window` | 6 | Maximum guarded coordinator auto-resumes dispatched per rolling window before the resume path backs off | +| `MOA_SESSION_LIMITS_WORKER_RESUME_WINDOW_MS` | `session_limits.worker_resume_window_ms` | 600000 | Rolling-window length, in milliseconds, for the guarded parent-resume budget | + +### `compaction` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_COMPACTION_ENABLED` | `compaction.enabled` | true | Whether reversible history compaction is enabled | +| `MOA_COMPACTION_EVENT_THRESHOLD` | `compaction.event_threshold` | 100 | Emit a checkpoint after this many unsummarized events | +| `MOA_COMPACTION_PRESERVE_ERRORS` | `compaction.preserve_errors` | true | Whether old error events must stay verbatim in the compiled view | +| `MOA_COMPACTION_RECENT_TURNS_VERBATIM` | `compaction.recent_turns_verbatim` | 5 | Number of most recent user turns to keep verbatim in context | +| `MOA_COMPACTION_TOKEN_RATIO_THRESHOLD` | `compaction.token_ratio_threshold` | 0.7 | Emit a checkpoint after unsummarized history reaches this fraction of the token budget | + +### `context_snapshot` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_CONTEXT_SNAPSHOT_ENABLED` | `context_snapshot.enabled` | true | Whether compiled context snapshots are enabled | +| `MOA_CONTEXT_SNAPSHOT_MAX_SIZE_BYTES` | `context_snapshot.max_size_bytes` | 5000000 | Warn when a serialized snapshot exceeds this size | + +### `orchestrator` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_ORCHESTRATOR_ENDPOINT` | `orchestrator.endpoint` | http://localhost:10010 | Restate ingress URL fronting the `moa-orchestrator` deployment | +| `MOA_ORCHESTRATOR_HEALTH_URL` | `orchestrator.health_url` | _none_ | Direct health URL for the orchestrator process | +| `MOA_RESTATE_ADMIN_URL` | `orchestrator.restate_admin_url` | http://localhost:10011 | Restate admin API base URL used for deployment registration and probes | +| `MOA_RESTATE_INGRESS_URL` | `orchestrator.restate_ingress_url` | http://localhost:10010 | Restate ingress URL used by hosted runtime clients and tests | +| `MOA_RESTATE_LLM_GATEWAY_URL` | `orchestrator.llm_gateway_url` | _none_ | Optional LLM gateway URL for direct service calls | + +### `runtime_cache` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_RUNTIME_CACHE_BACKEND` | `runtime_cache.backend` | auto | Backend used for runtime cache operations | +| `MOA_RUNTIME_CACHE_REDIS_URL` | `runtime_cache.redis_url` | _none_ | Redis URL used when the Redis backend is selected | + +### `auth` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_AUTH_AUTH0_AUDIENCE` | `auth.auth0.audience` | _empty_ | Expected API audience | +| `MOA_AUTH_AUTH0_CLIENT_ID` | `auth.auth0.client_id` | _empty_ | Auth0 client id loaded from runtime configuration | +| `MOA_AUTH_AUTH0_CLIENT_SECRET` | `auth.auth0.client_secret` | _empty_ | Auth0 client secret loaded from runtime configuration **(secret)** | +| `MOA_AUTH_AUTH0_DOMAIN` | `auth.auth0.domain` | _empty_ | Auth0 tenant domain | +| `MOA_AUTH_AUTH0_WEBHOOK_SECRET` | `auth.auth0_webhook_secret` | _none_ | Shared secret used to verify Auth0 connection-linked webhooks **(secret)** | +| `MOA_AUTH_CONTACT_TOKENS_AUDIENCE` | `auth.contact_tokens.audience` | moa-agent-contact | Expected audience for contact JWTs | +| `MOA_AUTH_CONTACT_TOKENS_CONTACT_POINT_HASH_KEY_HEX` | `auth.contact_tokens.contact_point_hash_key_hex` | _empty_ | 32-byte hex key used for contact point lookup hashes **(secret)** | +| `MOA_AUTH_CONTACT_TOKENS_ISSUER` | `auth.contact_tokens.issuer` | https://moa.local/contacts | Expected issuer for contact JWTs | +| `MOA_AUTH_CONTACT_TOKENS_KEY_ID` | `auth.contact_tokens.key_id` | moa-contact-rs256 | JWT key id placed in the token header | +| `MOA_AUTH_CONTACT_TOKENS_PRIVATE_KEY_PEM` | `auth.contact_tokens.private_key_pem` | _empty_ | RSA private key PEM for issuance **(secret)** | +| `MOA_AUTH_CONTACT_TOKENS_PUBLIC_KEY_PEM` | `auth.contact_tokens.public_key_pem` | _empty_ | RSA public key PEM for verification | +| `MOA_AUTH_CONTACT_TOKENS_UNVERIFIED_TTL_SECONDS` | `auth.contact_tokens.unverified_ttl_seconds` | 3600 | TTL for unverified contact tokens | +| `MOA_AUTH_CONTACT_TOKENS_VERIFICATION_TTL_SECONDS` | `auth.contact_tokens.verification_ttl_seconds` | 600 | TTL for one-time verification challenges | +| `MOA_AUTH_CONTACT_TOKENS_VERIFIED_TTL_SECONDS` | `auth.contact_tokens.verified_ttl_seconds` | 7200 | TTL for verified contact tokens, in seconds | +| `MOA_AUTH_OIDC_AUDIENCE` | `auth.oidc.audience` | _empty_ | Expected token audience | +| `MOA_AUTH_OIDC_ISSUER` | `auth.oidc.issuer` | _empty_ | OIDC issuer URL | +| `MOA_AUTH_OIDC_JWKS_URL` | `auth.oidc.jwks_url` | _empty_ | JWKS endpoint URL | +| `MOA_AUTH_PROVIDER` | `auth.provider` | local | Selected authentication provider | + +### `authz` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_AUTHZ_ENGINE` | `authz.engine` | openfga | Authorization engine selection | +| `MOA_AUTHZ_OPENFGA_MODEL_ID` | `authz.openfga.model_id` | _empty_ | OpenFGA authorization model ID | +| `MOA_AUTHZ_OPENFGA_PRESHARED_KEY` | `authz.openfga.preshared_key` | _empty_ | Preshared key configured in OpenFGA **(secret)** | +| `MOA_AUTHZ_OPENFGA_STORE_ID` | `authz.openfga.store_id` | _empty_ | OpenFGA store ID | +| `MOA_AUTHZ_OPENFGA_TIMEOUT_MS` | `authz.openfga.timeout_ms` | 2000 | Per-request HTTP timeout in milliseconds | +| `MOA_AUTHZ_OPENFGA_URL` | `authz.openfga.url` | _empty_ | OpenFGA HTTP API base URL | + +### `async_authz` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_ASYNC_AUTHZ_DEFAULT_TIMEOUT_SECS` | `async_authz.default_timeout_secs` | 900 | Default approval timeout in seconds | +| `MOA_ASYNC_AUTHZ_PROVIDER` | `async_authz.provider` | builtin | Selected async authorization provider | + +### `token_vault` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_TOKEN_VAULT_PROVIDER` | `token_vault.provider` | none | Selected token vault provider | + +### `compliance` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_LINEAGE_AUDIT_SIGNING_KEY_HEX` | `compliance.lineage_audit_signing_key_hex` | _none_ | Private key material used to verify lineage audit roots **(secret)** | +| `MOA_LINEAGE_AUDIT_SIGNING_KEY_ID` | `compliance.lineage_audit_signing_key_id` | moa-lineage-audit-ops | Stable key identifier used for lineage audit-root signatures | +| `MOA_PII_VAULT_SECRET_HEX` | `compliance.pii_vault_secret_hex` | _none_ | Optional secret used to compute PII-vault subject pseudonyms **(secret)** | +| `MOA_PRIVACY_APPROVAL_PUBLIC_KEY_HEX` | `compliance.privacy_approval_public_key_hex` | _none_ | Public key material used to verify signed privacy approval tokens **(secret)** | +| `MOA_PRIVACY_EXPORT_SIGNING_KEY_HEX` | `compliance.privacy_export_signing_key_hex` | _none_ | Private key material used to sign privacy export and lineage DSAR manifests **(secret)** | +| `MOA_PRIVACY_EXPORT_SIGNING_KEY_ID` | `compliance.privacy_export_signing_key_id` | moa-privacy-export-ops | Stable key identifier recorded on privacy export manifests | + +### `audit_security` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_AUDIT_SECURITY_EMIT_AUTHZ_ALLOWS` | `audit_security.emit_authz_allows` | true | Emit allowed authorization decisions in addition to denied decisions | + +### `observability` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_OBSERVABILITY_ENABLED` | `observability.enabled` | false | Whether OTLP export is enabled | +| `MOA_OBSERVABILITY_ENVIRONMENT` | `observability.environment` | _none_ | Deployment environment resource attribute | +| `MOA_OBSERVABILITY_LINEAGE_BATCH_MAX_AGE_SECS` | `observability.lineage.batch_max_age_secs` | 2 | Maximum age for a partial worker batch | +| `MOA_OBSERVABILITY_LINEAGE_BATCH_SIZE` | `observability.lineage.batch_size` | 512 | Maximum rows written per worker flush | +| `MOA_OBSERVABILITY_LINEAGE_CHANNEL_CAPACITY` | `observability.lineage.channel_capacity` | 8192 | Bounded hot-path channel capacity | +| `MOA_OBSERVABILITY_LINEAGE_ENABLED` | `observability.lineage.enabled` | false | Whether durable lineage capture is enabled | +| `MOA_OBSERVABILITY_LINEAGE_JOURNAL_PATH` | `observability.lineage.journal_path` | ~/.moa/lineage-journal | Durable fjall journal path | +| `MOA_OBSERVABILITY_LINEAGE_SAMPLE_PGVECTOR_EXPLAIN` | `observability.lineage.sample_pgvector_explain` | 0.01 | Fraction of pgvector queries that run full EXPLAIN ANALYZE | +| `MOA_OBSERVABILITY_OTLP_ENDPOINT` | `observability.otlp_endpoint` | _none_ | Optional OTLP endpoint override | +| `MOA_OBSERVABILITY_OTLP_HEADERS` | `observability.otlp_headers` | {} | Additional OTLP headers for exporter auth and routing | +| `MOA_OBSERVABILITY_OTLP_PROTOCOL` | `observability.otlp_protocol` | grpc | OTLP transport protocol | +| `MOA_OBSERVABILITY_RELEASE` | `observability.release` | _none_ | Application release or version resource attribute | +| `MOA_OBSERVABILITY_SAMPLE_RATE` | `observability.sample_rate` | 0.01 | Trace sampling ratio from 0.0 to 1.0 | +| `MOA_OBSERVABILITY_SERVICE_NAME` | `observability.service_name` | moa | Logical service name for traces | + +### `metrics` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_METRICS_ENABLED` | `metrics.enabled` | false | Whether the Prometheus scrape endpoint should be exposed | +| `MOA_METRICS_LISTEN` | `metrics.listen` | 0.0.0.0:9090 | Listener address for the Prometheus scrape endpoint | + +### `messaging` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_MESSAGING_EMAIL_FROM` | `messaging.email_from` | _empty_ | Default sender address for outbound email | +| `MOA_MESSAGING_EMAIL_REPLY_TO` | `messaging.email_reply_to` | _none_ | Optional reply-to address for outbound email | +| `MOA_MESSAGING_POSTMARK_BASE_URL` | `messaging.postmark_base_url` | https://api.postmarkapp.com | Base URL for the Postmark email API | +| `MOA_MESSAGING_POSTMARK_MESSAGE_STREAM` | `messaging.postmark_message_stream` | outbound | Default Postmark message stream | +| `MOA_MESSAGING_SLACK_APP_TOKEN` | `messaging.slack_app_token` | _empty_ | Slack app token loaded from runtime configuration **(secret)** | +| `MOA_MESSAGING_SLACK_TOKEN` | `messaging.slack_token` | _empty_ | Slack bot token loaded from runtime configuration | +| `MOA_MESSAGING_TWILIO_BASE_URL` | `messaging.twilio_base_url` | https://api.twilio.com | Base URL for Twilio's REST API | + +### `cloud` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_CLOUD_HANDS_ALLOW_LOCAL` | `cloud.hands.allow_local_provider` | false | Development-only opt-in that permits routing hand tools to the local host provider | +| `MOA_CLOUD_HANDS_DAYTONA_API_KEY` | `cloud.hands.daytona_api_key` | _none_ | Daytona API key loaded from runtime configuration **(secret)** | +| `MOA_CLOUD_HANDS_DAYTONA_API_URL` | `cloud.hands.daytona_api_url` | _none_ | Optional Daytona API base URL override | +| `MOA_CLOUD_HANDS_DAYTONA_DEFAULT_IMAGE` | `cloud.hands.daytona_default_image` | _none_ | Optional default image for Daytona sandboxes | +| `MOA_CLOUD_HANDS_DEFAULT_PROVIDER` | `cloud.hands.default_provider` | _none_ | Default hand provider | +| `MOA_CLOUD_HANDS_E2B_API_KEY` | `cloud.hands.e2b_api_key` | _none_ | E2B API key loaded from runtime configuration **(secret)** | +| `MOA_CLOUD_HANDS_E2B_API_URL` | `cloud.hands.e2b_api_url` | _none_ | Optional E2B API base URL override | +| `MOA_CLOUD_HANDS_E2B_DOMAIN` | `cloud.hands.e2b_domain` | _none_ | Optional E2B domain override | +| `MOA_CLOUD_HANDS_E2B_TEMPLATE` | `cloud.hands.e2b_template` | _none_ | Optional default E2B template identifier | +| `MOA_CLOUD_HANDS_FALLBACK_PROVIDERS` | `cloud.hands.fallback_providers` | [] | Ordered fallback cloud providers attempted when the selected cloud hand is unavailable | +| `MOA_CLOUD_MEMORY_DIR` | `cloud.memory_dir` | _none_ | Optional alternate memory root for cloud deployments | + +### `permissions` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_PERMISSIONS_ADMIN_REVIEW` | `permissions.admin_review` | [] | Tools that require tenant-admin review | +| `MOA_PERMISSIONS_ALWAYS_DENY` | `permissions.always_deny` | [] | Tools always denied | +| `MOA_PERMISSIONS_DEFAULT_EFFECT` | `permissions.default_effect` | allow | Default effect when neither persisted rules nor tool-specific config match | + +### `tool_output` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_TOOL_OUTPUT_HEAD_RATIO` | `tool_output.head_ratio` | 0.4 | Fraction of the truncation budget allocated to the head of the output | +| `MOA_TOOL_OUTPUT_MAX_BASH_LINES` | `tool_output.max_bash_lines` | 200 | Maximum preserved lines for bash output before head+tail truncation | +| `MOA_TOOL_OUTPUT_MAX_REPLAY_CHARS` | `tool_output.max_replay_chars` | 20000 | Maximum characters for replayed tool output | + +### `tool_budgets` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_TOOL_BUDGETS_BASH_STDERR` | `tool_budgets.bash_stderr` | 2000 | Approximate token budget for successful `bash` stderr | +| `MOA_TOOL_BUDGETS_BASH_STDOUT` | `tool_budgets.bash_stdout` | 4000 | Approximate token budget for successful `bash` stdout | +| `MOA_TOOL_BUDGETS_DEFAULT` | `tool_budgets.default` | 4000 | Approximate token budget for tools without a dedicated override, including MCP tools | +| `MOA_TOOL_BUDGETS_FILE_OUTLINE` | `tool_budgets.file_outline` | 2000 | Approximate token budget for `file_outline` | +| `MOA_TOOL_BUDGETS_FILE_READ` | `tool_budgets.file_read` | 8000 | Approximate token budget for `file_read` | +| `MOA_TOOL_BUDGETS_FILE_SEARCH` | `tool_budgets.file_search` | 4000 | Approximate token budget for `file_search` | +| `MOA_TOOL_BUDGETS_GREP` | `tool_budgets.grep` | 4000 | Approximate token budget for `grep` | +| `MOA_TOOL_BUDGETS_MEMORY_SEARCH` | `tool_budgets.memory_search` | 3000 | Approximate token budget for `memory_search` | + +### `skill_budget` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_SKILL_BUDGET_MAX_MANIFEST_CHARS` | `skill_budget.max_manifest_chars` | _none_ | Maximum characters for the entire skill manifest | +| `MOA_SKILL_BUDGET_MAX_PER_SKILL_CHARS` | `skill_budget.max_per_skill_chars` | 1536 | Maximum characters for one individual skill entry before truncation | +| `MOA_SKILL_BUDGET_SHOW_TOKEN_ESTIMATES` | `skill_budget.show_token_estimates` | true | Whether manifest entries should include estimated token counts | + +### `query_rewrite` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_QUERY_REWRITE_CIRCUIT_BREAKER_COOLDOWN_SECS` | `query_rewrite.circuit_breaker_cooldown_secs` | 60 | Circuit-breaker cooldown length in seconds after tripping | +| `MOA_QUERY_REWRITE_CIRCUIT_BREAKER_THRESHOLD` | `query_rewrite.circuit_breaker_threshold` | 0.05 | Circuit-breaker error-rate threshold that disables rewriting | +| `MOA_QUERY_REWRITE_CIRCUIT_BREAKER_WINDOW_SECS` | `query_rewrite.circuit_breaker_window_secs` | 60 | Circuit-breaker sliding window length in seconds | +| `MOA_QUERY_REWRITE_ENABLED` | `query_rewrite.enabled` | true | Whether query rewriting is enabled | +| `MOA_QUERY_REWRITE_MIN_QUERY_TOKENS` | `query_rewrite.min_query_tokens` | 15 | Minimum token count in a single-turn query to trigger rewriting | +| `MOA_QUERY_REWRITE_MODEL` | `query_rewrite.model` | _none_ | Model to use for rewriting | +| `MOA_QUERY_REWRITE_SKIP_SINGLE_TURN` | `query_rewrite.skip_single_turn` | true | Whether to skip rewriting on single-turn conversations below the token threshold | +| `MOA_QUERY_REWRITE_TIMEOUT_MS` | `query_rewrite.timeout_ms` | 5000 | Hard timeout for the rewriter LLM call | + +### `resolution` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_RESOLUTION_ENABLED` | `resolution.enabled` | true | Whether automated segment assessment is enabled | +| `MOA_RESOLUTION_IDLE_TIMEOUT_MINUTES` | `resolution.idle_timeout_minutes` | 30 | Idle timeout used for final continuation assessment | +| `MOA_RESOLUTION_REPHRASE_SIMILARITY_THRESHOLD` | `resolution.rephrase_similarity_threshold` | 0.85 | Similarity threshold above which a later user message is treated as a rephrase | +| `MOA_RESOLUTION_STRUCTURAL_MIN_SAMPLES` | `resolution.structural_min_samples` | 20 | Minimum historical sample count before structural baselines are used | +| `MOA_RESOLUTION_WEIGHTS_CONTINUATION` | `resolution.weights.continuation` | 0.25 | Weight assigned to user continuation behavior | +| `MOA_RESOLUTION_WEIGHTS_SELF_ASSESSMENT` | `resolution.weights.self_assessment` | 0.15 | Weight assigned to agent final-response self-assessment | +| `MOA_RESOLUTION_WEIGHTS_STRUCTURAL` | `resolution.weights.structural` | 0.1 | Weight assigned to structural anomaly detection | +| `MOA_RESOLUTION_WEIGHTS_TOOL` | `resolution.weights.tool` | 0.2 | Weight assigned to tool outcome analysis | +| `MOA_RESOLUTION_WEIGHTS_VERIFICATION` | `resolution.weights.verification` | 0.3 | Weight assigned to verification command detection | + +### `learning` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_LEARNING_SKILLS_MIN_TOOL_CALLS` | `learning.skills.min_tool_calls` | 8 | Minimum tool-call count a segment must contain before it is eligible for skill distillation | + +### `budgets` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_BUDGETS_DAILY_TENANT_CENTS` | `budgets.daily_tenant_cents` | 2000 | Maximum daily spend per tenant in cents | + +### `clickhouse` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_CLICKHOUSE_DATABASE` | `clickhouse.database` | moa | Target database; created at startup when missing | +| `MOA_CLICKHOUSE_EXPORT_BATCH_ROWS` | `clickhouse.export_batch_rows` | 5000 | Maximum rows pulled from Postgres and inserted into ClickHouse per analytics-export batch | +| `MOA_CLICKHOUSE_EXPORT_POLL_SECS` | `clickhouse.export_poll_secs` | 15 | Poll interval in seconds for the analytics exporter loop; also sets the cursor rewind overlap (`2 × export_poll_secs`) | +| `MOA_CLICKHOUSE_LINEAGE_TTL_DAYS` | `clickhouse.lineage_ttl_days` | 30 | Row TTL in days for `turn_lineage`, mirroring the Postgres/Timescale 30-day retention drop | +| `MOA_CLICKHOUSE_PASSWORD` | `clickhouse.password` | _none_ | Optional password for HTTP basic auth **(secret)** | +| `MOA_CLICKHOUSE_URL` | `clickhouse.url` | _empty_ | HTTP interface endpoint, for example `http://localhost:8123` | +| `MOA_CLICKHOUSE_USER` | `clickhouse.user` | _none_ | Optional user for HTTP basic auth | + +### `local` + +| Variable | Config path | Default | Description | +|---|---|---|---| +| `MOA_LOCAL_DOCKER_ENABLED` | `local.docker_enabled` | true | Whether local Docker hands are enabled | +| `MOA_LOCAL_MEMORY_DIR` | `local.memory_dir` | ~/.moa/memory | Memory root directory | +| `MOA_LOCAL_SANDBOX_DIR` | `local.sandbox_dir` | ~/.moa/sandbox | Sandbox working directory | +## Special (non-overlay) variables + +These `MOA_*` variables are **not** part of the typed overlay — they are read +directly by test lanes, deploy scripts, or Docker Compose. They are allowlisted +by the startup check (in `crates/moa-core/src/config/env_overlay.rs`) so they do +not trip the unknown-variable audit. They do not affect application config. + +### Approved prefixes + +| Prefix | Consumed by | +|---|---| +| `MOA_RUN_*` (incl. `MOA_RUN_LIVE_*`) | Gates for live/docker/chaos tests (e.g. `MOA_RUN_LIVE_COHERE_TESTS`, `MOA_RUN_CHAOS_TESTS`); `#[ignore]` lanes read them | +| `MOA_TEST_*` | Test-only config (e.g. Auth0 test-tenant values) | +| `MOA_FIXTURE_*` | Integration-test fixtures (e.g. fixture OpenFGA endpoint) | +| `MOA_PENTEST_*` | Cross-tenant pentest harness knobs | +| `MOA_LOADTEST_*` | k6/loadtest harness knobs (`crates/moa-loadtest`) | +| `MOA_CLEAN_E2E_*` | `scripts/run-clean-e2e.sh` harness (ports, run id, thread count) | +| `MOA_RUSTFS_*` | Local Docker Compose object-store ports | +| `MOA_FGA_*` | OpenFGA bootstrap scripts | +| `MOA_BOOTSTRAP_*` | Tenant/user bootstrap scripts | +| `MOA_EVAL_*` | Eval harness (`crates/moa-eval`) | +| `MOA_TRACE_*` | Tracing/debug sampling toggles read at runtime | +| `MOA_TWILIO_*` | Twilio live-messaging credentials (live tests) | +| `MOA_DAYTONA_*` | Daytona sandbox credentials (compose/live) | +| `MOA_NEON_*` | Neon branching credentials (deploy/live tests) | +| `MOA_OPENFGA_*` | OpenFGA compose/bootstrap vars (distinct from the `MOA_AUTHZ_OPENFGA_*` overlay) | +| `MOA_POSTMARK_*` | Postmark live-email credentials | +| `MOA_E2B_*` | E2B sandbox credentials | +| `MOA_OPENROUTER_*` | OpenRouter credentials (deploy) | +| `MOA_EDGE_*` | Edge binary bind/upstream (`MOA_EDGE_BIND`, `MOA_EDGE_UPSTREAM`) | +| `MOA_RESTATE_DEPLOYMENT_*` | Restate deploy-registration (`MOA_RESTATE_DEPLOYMENT_HOST`/`_URI`) | + +### Approved exact names + +| Variable | Consumed by | +|---|---| +| `MOA_CONFIG_ENV_STRICT` | This check's own strictness switch (warn vs fail) | +| `MOA_SKIP_FGA` | Skips OpenFGA bootstrap in local/dev startup | +| `MOA_DEREGISTER_ON_SHUTDOWN` | Orchestrator: deregister Restate deployment on shutdown | +| `MOA_REQUIRE_RESTATE_REGISTRATION_FOR_READINESS` | Orchestrator readiness gate | +| `MOA_ORCHESTRATOR_BIN` / `MOA_ORCHESTRATOR_FEATURES` | e2e harness: orchestrator binary path / cargo features | +| `MOA_MEMORY_AUTO_BOOTSTRAP` | Auto-run memory schema bootstrap on startup | +| `MOA_MEMORY_EXTRACTION_MODEL` / `_TIMEOUT_MS` / `_MAX_FACTS_PER_CHUNK` | Memory fact-extraction overrides read directly | +| `MOA_AUTHZ_DECISION_CACHE_TTL_MS` | Authz decision-cache TTL, read directly (see [Hardcoded tuning knobs](#hardcoded-tuning-knobs)) | +| `MOA_AUTHZ_OPENFGA_STORE_NAME` | OpenFGA store name for bootstrap | +| `MOA_AUDIT_BUCKET` / `MOA_AUDIT_OBJECT_LOCK_MODE` / `MOA_AUDIT_RETENTION_YEARS` | Audit shipper (object-lock WORM) config | +| `MOA_AUTH_HEADER_TRUST` | Trusted auth-header mode toggle | +| `MOA_AUTH0_CLIENT_ID` | Auth0 client id (compose/test) | +| `MOA_LINEAGE_SINK` | Lineage sink selection | +| `MOA_PERSIST_TURN_METRICS` | Persist per-turn metrics rows | +| `MOA_PROVIDERS_OVERRIDE` | Provider-catalog override (tests/tools) | +| `MOA_SCIM_BASE_URL` | SCIM base URL | +| `MOA_TOXIPROXY_URL` | Toxiproxy control URL (chaos tests) | +| `MOA_TURBOPUFFER_LIVE_NEWS_FACTS` | Live Turbopuffer news-facts eval fixture | +| `MOA_DOCKER_SECCOMP_PROFILE` | Docker seccomp profile path for sandbox runs | +| `MOA_VENDOR_NAME` | Vendor label surfaced in metadata | + +## Provider concurrency + +Concurrency limits for outbound provider calls live under +`providers.concurrency` plus a per-provider cap: + +- `MOA_PROVIDERS_CONCURRENCY_DEFAULT_MAX_IN_FLIGHT` (default `16`) bounds any + provider that sets no cap of its own; `0` = unbounded. +- `MOA__MAX_CONCURRENT_REQUESTS` (e.g. `MOA_OPENAI_MAX_CONCURRENT_REQUESTS`) + overrides per provider; unset keeps the provider's built-in default. +- `MOA_PROVIDERS_CONCURRENCY_SCOPE` (`local` | `global`) enforces the ceiling + per process or shared across replicas; `global` uses + `MOA_PROVIDERS_CONCURRENCY_LEASE_TTL_MS` as the crash backstop. +- `MOA_PROVIDERS_CONCURRENCY_BLOCK_THRESHOLD_MS` (default `2000`) is how long a + caller waits for a slot before it is reported saturated. + +See the [`providers`](#providers) table for exact defaults. + +## Operational notes + +### Database connection pool sizing + +`database.max_connections` (`MOA_DATABASE_MAX_CONNECTIONS`, default `20`) is the +**whole process's** Postgres budget — sessions, authz, graph memory, lineage, +and ingest all share this one pool. The default is dev-appropriate. Size it in +production to the process's worker fan-out (roughly `50–100`), and keep the sum +across replicas under the Postgres server's `max_connections`. + +### Metrics endpoint exposure + +`metrics.listen` (`MOA_METRICS_LISTEN`) defaults to `0.0.0.0:9090`. That is +correct for a Kubernetes scrape, but it binds all interfaces — restrict access +with a NetworkPolicy (or bind a private interface) so the metrics port is not +reachable from outside the cluster. + +### MCP tool permission posture + +Non-builtin tools (MCP servers) now default to **admin review** at the tool +descriptor level (`crates/moa-hands/src/core/policy.rs`): a tenant must approve +an MCP tool action unless an operator action-policy rule (or config) grants it. +Builtin hands keep their own per-tool defaults. Operator rules always override +the descriptor default. + +### Hardcoded tuning knobs + +A few operationally relevant values are compile-time constants, not overlay +variables. They are listed here as a pointer, not full documentation. + +| Value | Default | Where | Override | +|---|---|---|---| +| Authz decision-cache TTL | 2000 ms | `moa-auth/authz` `require.rs` | `MOA_AUTHZ_DECISION_CACHE_TTL_MS` (env) | +| JWKS refresh cooldown | 10 s | `moa-auth/auth0` `jwks_cache.rs` | constant | +| JWKS unknown-`kid` negative TTL | 60 s | `moa-auth/auth0` `jwks_cache.rs` | constant | +| JWKS negative-cache capacity | 1024 | `moa-auth/auth0` `jwks_cache.rs` | constant | +| Authz outbox poller interval | 500 ms | `moa-auth/authz` `poller.rs` | constant | +| Authz outbox poller batch | 64 rows | `moa-auth/authz` `poller.rs` | constant | +| Authz outbox max delivery attempts | 8 | `moa-auth/authz` `poller.rs` | constant | + +## How to set them + +### Local shell / `.env.local` + +```bash +export MOA_DATABASE_URL="postgres://moa_owner:dev@127.0.0.1:10040/moa" +export MOA_MODELS_MAIN="claude-fable-5" +export MOA_ANTHROPIC_API_KEY="sk-ant-..." +``` + +**Local trap:** the local/dev stack reads a `.env.local` if present. An +**empty or provider-less `.env.local`** leaves no model provider configured and +the stack falls back to the **`mock`** provider — set +`MOA_GENERAL_DEFAULT_PROVIDER` / `MOA_MODELS_MAIN` (or the compose +`MODEL_PROVIDER`) explicitly to use a real model. + +### Docker Compose + +The compose stack passes a curated set of `MOA_*` variables through to the edge, +orchestrator, PII, audit-shipper, and loadtest services (Postgres/Restate/ +OpenFGA endpoints, ports, and the `MOA_RUSTFS_*` / `MOA_CLEAN_E2E_*` families). +Set them in your shell before `docker compose up`, or in a compose `.env` file; +compose substitutes `${MOA_...}` into the service `environment:` blocks. + +### Kubernetes + +Non-secret config as plain env, secrets from a `Secret`: + +```yaml +env: + - name: MOA_CONFIG_ENV_STRICT + value: "1" # fail startup on an unknown MOA_* var + - name: MOA_MODELS_MAIN + value: "claude-fable-5" + - name: MOA_PROVIDERS_CONCURRENCY_SCOPE + value: "global" + - name: MOA_DATABASE_MAX_CONNECTIONS + value: "80" # size to worker fan-out, not the dev default + - name: MOA_ANTHROPIC_API_KEY + valueFrom: + secretKeyRef: { name: moa-provider-secrets, key: anthropic-api-key } + - name: MOA_DATABASE_URL + valueFrom: + secretKeyRef: { name: moa-db, key: url } +``` + +## Regenerating the tables + +The overlay tables are produced from source, not hand-maintained. The +`#[ignore]` dev test `dump_env_var_reference` in +`crates/moa-core/src/config/env_overlay.rs` walks `MoaEnvOverlay` and +`MoaConfig::default()` and prints `env_var | config_path | default` for every +variable: + +```bash +cargo test -p moa-core dump_env_var_reference -- --ignored --nocapture +``` + +Descriptions come from each config field's doc comment, resolved along the full +config path (`MoaConfig` → section struct → … → leaf field) so a nested field +never inherits a same-named parent field's comment. When adding or renaming an +overlay field, re-run the dump and refresh the affected section table. diff --git a/fly.toml b/fly.toml deleted file mode 100644 index 137b1526..00000000 --- a/fly.toml +++ /dev/null @@ -1,39 +0,0 @@ -app = "moa-brains" -primary_region = "iad" -kill_signal = "SIGTERM" -kill_timeout = "30s" - -[build] - dockerfile = "Dockerfile" - -[env] - MOA_CLOUD_ENABLED = "true" - MOA_CLOUD_HANDS_DEFAULT_PROVIDER = "e2b" - MOA_CLOUD_HANDS_ALLOW_LOCAL = "false" - MOA_LOCAL_MEMORY_DIR = "/data/memory" - MOA_LOCAL_SANDBOX_DIR = "/data/sandbox" - MOA_CLOUD_MEMORY_DIR = "/data/memory" - MOA_CLOUD_FLYIO_INTERNAL_PORT = "8080" - MOA_CLOUD_FLYIO_HEALTH_BIND = "0.0.0.0" - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = "suspend" - auto_start_machines = true - min_machines_running = 0 - - [[http_service.checks]] - grace_period = "10s" - interval = "30s" - method = "GET" - timeout = "5s" - path = "/health" - -[[mounts]] - source = "moa_data" - destination = "/data" - -[[vm]] - size = "shared-cpu-1x" - memory = "256mb" diff --git a/scripts/fly-live-smoke.sh b/scripts/fly-live-smoke.sh deleted file mode 100755 index 5259008c..00000000 --- a/scripts/fly-live-smoke.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FLYCTL_BIN="${FLYCTL_BIN:-flyctl}" -APP_NAME="${MOA_FLY_APP:-moa-brains-smoke-$(date +%s)}" -REGION="${MOA_FLY_REGION:-iad}" -KEEP_APP="${MOA_FLY_KEEP_APP:-0}" -LOCAL_ONLY="${MOA_FLY_LOCAL_ONLY:-1}" -CONFIG_HOME="$(mktemp -d)" - -: "${FLY_API_TOKEN:?set FLY_API_TOKEN}" -: "${MOA_OPENAI_API_KEY:?set MOA_OPENAI_API_KEY}" - -fly() { - HOME="$CONFIG_HOME" NO_COLOR=1 FLY_API_TOKEN="$FLY_API_TOKEN" "$FLYCTL_BIN" "$@" -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - -machine_ids() { - fly machine list -a "$APP_NAME" 2>/dev/null | awk '$1 ~ /^[0-9a-f]+$/ {print $1}' -} - -volume_ids() { - fly volumes list -a "$APP_NAME" 2>/dev/null | awk '$1 ~ /^vol_/ {print $1}' -} - -health_probe() { - local output - local status - local elapsed - output="$(curl -sS -o /tmp/moa-fly-health-body.$$ -w '%{http_code} %{time_total}' "https://${APP_NAME}.fly.dev/health")" - status="${output%% *}" - elapsed="${output##* }" - cat /tmp/moa-fly-health-body.$$ - rm -f /tmp/moa-fly-health-body.$$ - if [[ "$status" != "200" ]]; then - echo "health probe failed: status=$status elapsed=${elapsed}s" >&2 - exit 1 - fi - printf '\nhealth: %s in %ss\n' "$status" "$elapsed" -} - -cleanup() { - local id - local volume_id - - if [[ "$KEEP_APP" == "1" ]]; then - echo "keeping Fly app ${APP_NAME}" - rm -rf "$CONFIG_HOME" - return - fi - - for id in $(machine_ids); do - fly machine destroy "$id" -a "$APP_NAME" -f >/dev/null 2>&1 || true - done - - for volume_id in $(volume_ids); do - fly volumes destroy "$volume_id" -a "$APP_NAME" --yes >/dev/null 2>&1 || true - done - - fly apps destroy "$APP_NAME" --yes >/dev/null 2>&1 || true - rm -rf "$CONFIG_HOME" -} - -trap cleanup EXIT - -require_cmd "$FLYCTL_BIN" -require_cmd curl -require_cmd awk - -echo "using app: $APP_NAME" -fly apps create "$APP_NAME" >/dev/null 2>&1 || true - -if ! volume_ids | grep -q .; then - fly volumes create moa_data --region "$REGION" --size 1 -a "$APP_NAME" --yes >/dev/null -fi - -secret_args=("MOA_OPENAI_API_KEY=$MOA_OPENAI_API_KEY") -if [[ -n "${MOA_DATABASE_URL:-}" ]]; then - secret_args+=("MOA__DATABASE__URL=$MOA_DATABASE_URL") -fi -fly secrets set -a "$APP_NAME" "${secret_args[@]}" >/dev/null - -deploy_args=(deploy --config "$ROOT_DIR/fly.toml" -a "$APP_NAME" --yes) -if [[ "$LOCAL_ONLY" == "1" ]]; then - deploy_args=(deploy --local-only --config "$ROOT_DIR/fly.toml" -a "$APP_NAME" --yes) -fi -fly "${deploy_args[@]}" >/dev/null - -echo "initial health probe" -health_probe - -current_machine="$(machine_ids | head -n 1)" -if [[ -z "$current_machine" ]]; then - echo "failed to discover deployed machine id" >&2 - exit 1 -fi - -echo "stopping machine $current_machine" -fly machine stop "$current_machine" -a "$APP_NAME" --wait-timeout 30s >/dev/null -health_probe - -current_machine="$(machine_ids | head -n 1)" -echo "suspending machine $current_machine" -fly machine suspend "$current_machine" -a "$APP_NAME" --wait-timeout 30s >/dev/null -health_probe - -echo "fly live smoke passed for ${APP_NAME}" From f509e60997321e88fe16ac86297dbae5c5c1a131 Mon Sep 17 00:00:00 2001 From: Hwuiwon Kim Date: Fri, 10 Jul 2026 15:47:21 -0400 Subject: [PATCH 4/4] update skills --- .agents/skills/moa-100-session-sweep/SKILL.md | 23 +++++++++++++++++++ .../scripts/run_100_session_sweep.py | 11 +++++++++ 2 files changed, 34 insertions(+) diff --git a/.agents/skills/moa-100-session-sweep/SKILL.md b/.agents/skills/moa-100-session-sweep/SKILL.md index e6bc90d6..ca454489 100644 --- a/.agents/skills/moa-100-session-sweep/SKILL.md +++ b/.agents/skills/moa-100-session-sweep/SKILL.md @@ -99,6 +99,29 @@ Useful env overrides: - `MOA_SWEEP_IDS`: comma-separated session ids for focused lanes - `MOA_SWEEP_MODEL`: pinned model, usually `gpt-5.4-mini` - `MOA_SWEEP_REDIS_URL`: runtime-cache Redis URL, default `redis://127.0.0.1:10051/0` +- `MOA_SWEEP_PROVIDER_MAX_IN_FLIGHT`: per-credential provider in-flight budget for the + sweep orchestrator, default `64`. Chat calls are bounded per provider credential by + default (16 unless configured), which a full sweep can saturate; the runner passes this + through as `MOA_OPENAI_MAX_CONCURRENT_REQUESTS` so the budget never shapes outcomes. + The runner also forces `MOA_PROVIDERS_CONCURRENCY_SCOPE=local` so the sweep never + shares Redis lease budgets with the long-running compose orchestrator. + +## Runtime-Behavior Notes (defaults changed 2026-07-10) + +Interpretation guidance for sweeps at or after the 2026-07-10 defaults changes: + +- **Non-builtin (MCP) tools default to admin review.** Sweep personas use builtin tools + only, so current cases are unaffected — but any future persona that registers an MCP + tool will stall on an approval unless the case seeds an operator allow rule or handles + the review flow. Do not read such a stall as a delegation/fan-in regression. +- **Skill distillation now requires 8+ tool calls per segment** (was 5). This gates + skill *learning*, not skill *activation* — `F-SKILL-INJECT` reads persisted + `skills_activated` segment evidence and is unaffected. Expect fewer + distillation proposals from short sweep sessions; that is intended, not a regression. +- **Provider chat concurrency is bounded per credential by default.** The runner sizes + the sweep budget via `MOA_SWEEP_PROVIDER_MAX_IN_FLIGHT` (see above). If a sweep shows + clustered latency spikes or rate-limit failovers, check that override before suspecting + a provider or orchestrator regression. ## Baseline Rules diff --git a/.agents/skills/moa-100-session-sweep/scripts/run_100_session_sweep.py b/.agents/skills/moa-100-session-sweep/scripts/run_100_session_sweep.py index a6391247..c07ee9dc 100755 --- a/.agents/skills/moa-100-session-sweep/scripts/run_100_session_sweep.py +++ b/.agents/skills/moa-100-session-sweep/scripts/run_100_session_sweep.py @@ -39,6 +39,11 @@ # Minimum contiguous match length (chars) that counts as the final reply leaking raw worker output. RAW_LEAK_MIN_CHARS = 120 MAX_WORKERS = int(os.environ.get("MOA_SWEEP_CONCURRENCY", "4")) +# Provider in-flight budget for the sweep's single live key. Chat calls are +# bounded per provider credential by default (16 unless configured), which a +# full sweep (MOA_SWEEP_CONCURRENCY sessions x coordinator + spawned workers) +# can saturate; size generously so the budget never shapes sweep outcomes. +PROVIDER_MAX_IN_FLIGHT = os.environ.get("MOA_SWEEP_PROVIDER_MAX_IN_FLIGHT", "64") SESSION_TIMEOUT_S = int(os.environ.get("MOA_SWEEP_SESSION_TIMEOUT_S", "260")) TURN_LIMIT = int(os.environ.get("MOA_SWEEP_MAX_TURNS", "6")) CASE_LIMIT = int(os.environ.get("MOA_SWEEP_LIMIT", "0")) @@ -279,6 +284,12 @@ def start_orchestrator(env): "MOA_LOCAL_DOCKER_ENABLED": "false", "MOA_RUNTIME_CACHE_BACKEND": "redis", "MOA_RUNTIME_CACHE_REDIS_URL": SWEEP_REDIS_URL, + # The sweep shares the compose Redis; force local concurrency scope + # so an inherited global scope can never make the sweep orchestrator + # share provider lease budgets with the long-running compose stack. + "MOA_PROVIDERS_CONCURRENCY_SCOPE": "local", + # Sweep model is routed through the OpenAI provider credential. + "MOA_OPENAI_MAX_CONCURRENT_REQUESTS": PROVIDER_MAX_IN_FLIGHT, "RUST_LOG": os.environ.get("RUST_LOG", "info,moa_orchestrator=info"), } )