From 9e9c180d4f9bc8c88b96770ea413e9a1c2bcc233 Mon Sep 17 00:00:00 2001 From: Travis James Date: Sun, 20 Sep 2026 20:53:34 -0500 Subject: [PATCH] perf(operations): bound receipt lookup payload --- .../proposal.md | 7 +- .../spec.md | 11 +++ src/operations.rs | 89 ++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/openspec/changes/bound-operation-reconciliation-projection/proposal.md b/openspec/changes/bound-operation-reconciliation-projection/proposal.md index 589e6ad..e6238e7 100644 --- a/openspec/changes/bound-operation-reconciliation-projection/proposal.md +++ b/openspec/changes/bound-operation-reconciliation-projection/proposal.md @@ -8,9 +8,10 @@ The coordinator needs only each operation identity to schedule recovery. ## What Changes - Project only `operation_id` when listing nonterminal work. +- Project only public receipt fields when clients poll an operation. - Retain deterministic ordering and the existing state filter. -- Add a database-backed regression proving large payload bytes do not enter the - reconciliation list result. +- Add database-backed regressions proving large payload bytes do not enter + reconciliation or receipt query results. ## Capabilities @@ -25,6 +26,6 @@ None. ## Impact -This changes one internal query and its row type in `src/operations.rs`. The +This changes two internal queries and their row types in `src/operations.rs`. The operation schema, state machine, processing order, HTTP contract, and stored payloads remain unchanged. diff --git a/openspec/changes/bound-operation-reconciliation-projection/specs/operation-reconciliation-projection/spec.md b/openspec/changes/bound-operation-reconciliation-projection/specs/operation-reconciliation-projection/spec.md index ba7277f..be4be40 100644 --- a/openspec/changes/bound-operation-reconciliation-projection/specs/operation-reconciliation-projection/spec.md +++ b/openspec/changes/bound-operation-reconciliation-projection/specs/operation-reconciliation-projection/spec.md @@ -19,3 +19,14 @@ executor fields into the reconciliation list result. #### Scenario: Terminal operations share the ledger - **WHEN** committed or rejected operations exist beside nonterminal operations - **THEN** the reconciliation list excludes terminal identities without loading their payloads + +### Requirement: Receipt lookup excludes request payloads + +The durable coordinator SHALL return an operation receipt by projecting only +the fields in the public receipt contract. It MUST NOT load the stored request +payload into the receipt query result. + +#### Scenario: A client polls an operation with a large request payload +- **WHEN** the client retrieves that operation's receipt by its stable identity +- **THEN** the query result contains every public receipt field +- **AND** the query result does not contain the stored request payload diff --git a/src/operations.rs b/src/operations.rs index 779bbc1..1685885 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -183,6 +183,33 @@ struct DbOperationId { const LIST_NONTERMINAL_OPERATION_IDS_QUERY: &str = "SELECT operation_id FROM memory_operation WHERE state NOT IN ['committed', 'rejected'] ORDER BY operation_id ASC"; +#[derive(Debug, Deserialize, SurrealValue)] +struct DbOperationReceipt { + operation_id: String, + schema_version: u32, + kind: String, + dependencies: Vec, + payload_hash: String, + state: String, + blocked_by: Vec, + result: Option, + error: Option, + executor_generation: u64, + #[serde(default)] + executor_progress_seq: u64, + #[serde(default)] + executor_exit_count: u64, + #[serde(default)] + executor_last_exit: Option, + #[serde(default)] + executor_error: Option, + progress_seq: u64, + created_at: Datetime, + updated_at: Datetime, +} + +const GET_OPERATION_RECEIPT_QUERY: &str = "SELECT operation_id, schema_version, kind, dependencies, payload_hash, state, blocked_by, result, error, executor_generation, executor_progress_seq, executor_exit_count, executor_last_exit, executor_error, progress_seq, created_at, updated_at FROM memory_operation WHERE operation_id = $id LIMIT 1"; + #[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)] struct DbOperationEvent { #[serde(default)] @@ -250,6 +277,32 @@ impl TryFrom for OperationReceipt { } } +impl TryFrom for OperationReceipt { + type Error = anyhow::Error; + + fn try_from(value: DbOperationReceipt) -> Result { + Ok(Self { + operation_id: value.operation_id, + schema_version: value.schema_version, + kind: value.kind, + payload_hash: value.payload_hash, + dependencies: value.dependencies, + state: parse_state(&value.state)?, + blocked_by: value.blocked_by, + result: value.result, + error: value.error, + executor_generation: value.executor_generation, + executor_progress_seq: value.executor_progress_seq, + executor_exit_count: value.executor_exit_count, + executor_last_exit: value.executor_last_exit, + executor_error: value.executor_error, + progress_seq: value.progress_seq, + created_at: value.created_at.to_string(), + updated_at: value.updated_at.to_string(), + }) + } +} + impl From for OperationEvent { fn from(value: DbOperationEvent) -> Self { Self { @@ -427,10 +480,13 @@ impl OperationService { pub async fn get(&self, operation_id: &str) -> Result> { let db = self.surreal()?.db()?; - let operation: Option = db - .select(("memory_operation", record_key(operation_id))) - .await?; - operation.map(TryInto::try_into).transpose() + let mut rows: Vec = db + .query(GET_OPERATION_RECEIPT_QUERY) + .bind(("id", operation_id.to_owned())) + .await? + .check()? + .take(0)?; + rows.pop().map(TryInto::try_into).transpose() } async fn get_db(&self, operation_id: &str) -> Result> { @@ -2037,6 +2093,31 @@ mod tests { .expect("nonterminal operation identity is projected"); assert_eq!(row.as_object().unwrap().len(), 1); assert!(!serde_json::to_string(&rows).unwrap().contains(sentinel)); + + let receipt = service + .get("projected-reconciliation") + .await + .unwrap() + .expect("operation receipt"); + assert_eq!(receipt.operation_id, "projected-reconciliation"); + let receipt_rows: Vec = storage + .db() + .unwrap() + .query(GET_OPERATION_RECEIPT_QUERY) + .bind(("id", "projected-reconciliation".to_owned())) + .await + .unwrap() + .check() + .unwrap() + .take(0) + .unwrap(); + let receipt_row = receipt_rows.first().expect("projected receipt row"); + assert!(!receipt_row.as_object().unwrap().contains_key("payload")); + assert!( + !serde_json::to_string(&receipt_rows) + .unwrap() + .contains(sentinel) + ); } #[tokio::test]