diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ac6d4db..f6c149bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Security
+
+- **DDL now updates encryption metadata transactionally**: Proxy applies schema changes only after PostgreSQL confirms execution, keeps successful changes connection-local until commit, and atomically publishes schema and EQL domain metadata before reporting idle readiness. Extended-protocol DDL, explicit transactions, savepoints, rollbacks, one-`Sync` pipelining, and already-open connections now observe the correct schema generation. Unmodelled DDL, simple-query batches whose DDL may change encryption metadata before a dependent statement, and failed catalog publication fail closed instead of risking plaintext writes through stale metadata; encryption-neutral DDL and native temporary-table batches remain compatible.
+
## [3.0.1] - 2026-08-05
### Added
diff --git a/docs/errors.md b/docs/errors.md
index 8f9ae535..76436257 100644
--- a/docs/errors.md
+++ b/docs/errors.md
@@ -12,6 +12,8 @@
- [Invalid SQL statement](#mapping-invalid-sql-statement)
- [Unsupported parameter type](#mapping-unsupported-parameter-type)
- [Statement could not be type checked](#mapping-statement-could-not-be-type-checked)
+ - [Dependent statement after DDL](#mapping-dependent-statement-after-ddl)
+ - [Unmodelled DDL](#mapping-unmodelled-ddl)
- [Unmappable encrypted column](#mapping-unmappable-encrypted-column)
- [Internal Error](#mapping-internal-error)
@@ -249,6 +251,36 @@ If the error persists, please contact CipherStash [support](https://cipherstash.
+
+
+
+## Dependent statement after DDL
+
+A simple-query batch contains a schema-dependent statement after DDL. Proxy cannot observe the
+DDL execution result between statements in one simple-query message, so it refuses the complete
+batch before PostgreSQL executes any part of it.
+
+### How to fix
+
+Send the DDL and the dependent statement as separate queries. Extended-protocol clients may
+pipeline them; Proxy defers dependent mapping until PostgreSQL reports the DDL outcome.
+
+
+
+
+
+## Unmodelled DDL
+
+PostgreSQL successfully executed a schema change whose connection-local effect Proxy cannot model
+safely, such as conditional or cascading DDL. Schema-dependent statements are refused for the
+rest of that transaction.
+
+### How to fix
+
+Roll back the transaction, or commit it and wait for Proxy to publish an authoritative catalog
+snapshot before issuing schema-dependent statements.
+
+
diff --git a/packages/cipherstash-proxy-integration/src/lib.rs b/packages/cipherstash-proxy-integration/src/lib.rs
index 756ab8d2..70921320 100644
--- a/packages/cipherstash-proxy-integration/src/lib.rs
+++ b/packages/cipherstash-proxy-integration/src/lib.rs
@@ -24,6 +24,7 @@ mod multitenant;
mod ore_order_helpers;
mod passthrough;
mod pipeline;
+/// Database-backed transaction-aware schema middleware regressions.
mod schema_change;
mod select;
mod set_keyset_error;
diff --git a/packages/cipherstash-proxy-integration/src/schema_change.rs b/packages/cipherstash-proxy-integration/src/schema_change.rs
index ff1fa042..a60b8286 100644
--- a/packages/cipherstash-proxy-integration/src/schema_change.rs
+++ b/packages/cipherstash-proxy-integration/src/schema_change.rs
@@ -1,25 +1,191 @@
#[cfg(test)]
+/// End-to-end schema-change tests through Proxy and directly against PostgreSQL.
mod tests {
- use crate::common::{connect_with_tls, random_id, PROXY};
+ use crate::common::{connect, connect_with_tls, get_database_port, random_id, PROXY};
+ use tokio_postgres::Client;
+
+ async fn connect_for_test(port: u16) -> Client {
+ if std::env::var("CS_TEST_USE_TLS").as_deref() == Ok("false") {
+ connect(port).await
+ } else {
+ connect_with_tls(port).await
+ }
+ }
+
+ fn table(prefix: &str) -> String {
+ format!("{prefix}_{}", random_id())
+ }
+
+ fn create_encrypted_table(table: &str) -> String {
+ format!("CREATE TABLE {table} (id bigint PRIMARY KEY, secret eql_v3_text_search NOT NULL)")
+ }
+
+ async fn assert_ciphertext_at_rest(table: &str, id: i64, plaintext: &str) {
+ let postgres = connect_for_test(get_database_port()).await;
+ let sql = format!("SELECT secret::text FROM {table} WHERE id = $1");
+ let stored: String = postgres.query_one(&sql, &[&id]).await.unwrap().get(0);
+
+ assert!(
+ !stored.contains(plaintext),
+ "plaintext reached PostgreSQL: {stored}"
+ );
+ let payload: serde_json::Value = serde_json::from_str(&stored).unwrap();
+ assert!(
+ payload.get("c").is_some(),
+ "missing record ciphertext: {payload}"
+ );
+ }
+
+ async fn insert_secret(client: &Client, table: &str, id: i64, plaintext: &str) {
+ let sql = format!("INSERT INTO {table} (id, secret) VALUES ($1, $2)");
+ assert_eq!(client.execute(&sql, &[&id, &plaintext]).await.unwrap(), 1);
+ }
+
+ #[tokio::test]
+ async fn later_connection_encrypts_immediately_after_extended_protocol_ddl() {
+ let ddl_connection = connect_for_test(*PROXY).await;
+ let already_open_connection = connect_for_test(*PROXY).await;
+ let table = table("bug_308_extended");
+
+ ddl_connection
+ .execute(&create_encrypted_table(&table), &[])
+ .await
+ .unwrap();
+
+ insert_secret(&already_open_connection, &table, 1, "classified").await;
+ assert_ciphertext_at_rest(&table, 1, "classified").await;
+ }
#[tokio::test]
- async fn schema_change_reloads_schema() {
- let client = connect_with_tls(*PROXY).await;
+ async fn explicit_transaction_uses_successful_ddl_overlay_before_commit() {
+ let client = connect_for_test(*PROXY).await;
+ let table = table("bug_308_transaction");
- let id = random_id();
+ client.batch_execute("BEGIN").await.unwrap();
+ client
+ .execute(&create_encrypted_table(&table), &[])
+ .await
+ .unwrap();
+ insert_secret(&client, &table, 1, "inside transaction").await;
+ client.batch_execute("COMMIT").await.unwrap();
- let sql = format!(
- "CREATE TABLE table_{id} (
- id bigint,
- PRIMARY KEY(id)
- );"
+ assert_ciphertext_at_rest(&table, 1, "inside transaction").await;
+ }
+
+ #[tokio::test]
+ async fn encryption_neutral_alter_table_keeps_transaction_mappable() {
+ let client = connect_for_test(*PROXY).await;
+ let table = table("bug_308_safe_alter");
+
+ client
+ .execute(&create_encrypted_table(&table), &[])
+ .await
+ .unwrap();
+ client.batch_execute("BEGIN").await.unwrap();
+ client
+ .batch_execute(&format!(
+ "ALTER TABLE {table} ALTER COLUMN secret SET NOT NULL"
+ ))
+ .await
+ .unwrap();
+ insert_secret(&client, &table, 1, "after safe alter").await;
+ client.batch_execute("COMMIT").await.unwrap();
+
+ assert_ciphertext_at_rest(&table, 1, "after safe alter").await;
+ }
+
+ #[tokio::test]
+ async fn pipelined_statement_waits_for_extended_ddl_activation() {
+ let client = connect_for_test(*PROXY).await;
+ let table = table("bug_308_pipeline");
+ let create = create_encrypted_table(&table);
+ let insert = format!("INSERT INTO {table} (id, secret) VALUES ($1, $2)");
+ let create = client.prepare(&create).await.unwrap();
+
+ let (created, inserted) = tokio::join!(
+ client.execute(&create, &[]),
+ client.execute(&insert, &[&1_i64, &"pipelined"]),
);
+ created.unwrap();
+ assert_eq!(inserted.unwrap(), 1);
+
+ assert_ciphertext_at_rest(&table, 1, "pipelined").await;
+ }
+
+ #[tokio::test]
+ async fn rollback_discards_successful_ddl_overlay() {
+ let client = connect_for_test(*PROXY).await;
+ let postgres = connect_for_test(get_database_port()).await;
+ let table = table("bug_308_rollback");
- let _ = client.execute(&sql, &[]).await.unwrap();
+ client.batch_execute("BEGIN").await.unwrap();
+ client
+ .execute(&create_encrypted_table(&table), &[])
+ .await
+ .unwrap();
+ client.batch_execute("ROLLBACK").await.unwrap();
+
+ let exists: bool = postgres
+ .query_one("SELECT to_regclass($1) IS NOT NULL", &[&table])
+ .await
+ .unwrap()
+ .get(0);
+ assert!(!exists);
+ }
+
+ #[tokio::test]
+ async fn rollback_to_savepoint_restores_schema_and_encryption_overlay() {
+ let client = connect_for_test(*PROXY).await;
+ let postgres = connect_for_test(get_database_port()).await;
+ let retained = table("bug_308_retained");
+ let reverted = table("bug_308_reverted");
+
+ client.batch_execute("BEGIN").await.unwrap();
+ client
+ .execute(&create_encrypted_table(&retained), &[])
+ .await
+ .unwrap();
+ client
+ .batch_execute("SAVEPOINT Before_Reverted")
+ .await
+ .unwrap();
+ client
+ .execute(&create_encrypted_table(&reverted), &[])
+ .await
+ .unwrap();
+ client
+ .batch_execute("ROLLBACK TO SAVEPOINT before_reverted")
+ .await
+ .unwrap();
+ insert_secret(&client, &retained, 1, "savepoint secret").await;
+ client.batch_execute("COMMIT").await.unwrap();
+
+ assert_ciphertext_at_rest(&retained, 1, "savepoint secret").await;
+ let exists: bool = postgres
+ .query_one("SELECT to_regclass($1) IS NOT NULL", &[&reverted])
+ .await
+ .unwrap()
+ .get(0);
+ assert!(!exists);
+ }
+
+ #[tokio::test]
+ async fn simple_query_batch_with_dependent_post_ddl_statement_fails_closed() {
+ let client = connect_for_test(*PROXY).await;
+ let postgres = connect_for_test(get_database_port()).await;
+ let table = table("bug_308_simple_batch");
+ let batch = format!(
+ "{}; INSERT INTO {table} (id, secret) VALUES (1, 'plaintext')",
+ create_encrypted_table(&table)
+ );
- let sql = format!("SELECT id FROM table_{id}");
- let rows = client.query(&sql, &[]).await.unwrap();
+ assert!(client.simple_query(&batch).await.is_err());
- assert!(rows.is_empty());
+ let exists: bool = postgres
+ .query_one("SELECT to_regclass($1) IS NOT NULL", &[&table])
+ .await
+ .unwrap()
+ .get(0);
+ assert!(!exists);
}
}
diff --git a/packages/cipherstash-proxy/CONTEXT.md b/packages/cipherstash-proxy/CONTEXT.md
index 9289b0a0..c74c9dc5 100644
--- a/packages/cipherstash-proxy/CONTEXT.md
+++ b/packages/cipherstash-proxy/CONTEXT.md
@@ -103,8 +103,40 @@ Proxy's in-band control API, intercepted rather than forwarded — `KEYSET_ID`,
that print `CIPHERSTASH.DISABLE_MAPPING` are wrong.
**Reload**:
-Re-reading state from the database after observed DDL. Two independent things reload: the
-database schema, and the column encrypt config.
+Re-reading authoritative schema state from PostgreSQL after observed DDL. A reload produces
+one **committed schema snapshot**; it does not merge Proxy's inferred DDL effects into shared
+state.
+
+**Committed schema snapshot**:
+An immutable, monotonically versioned pair of database structure and column encryption
+metadata loaded from PostgreSQL. The pair is published atomically because a table without its
+encryption policy (or an encryption policy without its table) is not a valid observable state.
+
+**Transaction schema overlay**:
+The confirmed effects of successful DDL executions in one connection's current transaction.
+It is checkpointed by savepoints, restored by `ROLLBACK TO SAVEPOINT`, and discarded by a full
+rollback. Parsed or prepared DDL is only intent; it enters the overlay after PostgreSQL reports
+successful execution.
+
+**Effective schema**:
+The committed schema snapshot pinned when a transaction starts, with that transaction's schema
+overlay applied. EQL Mapper type-checks and transforms against this view. An idle connection
+adopts the latest committed snapshot before its next transaction.
+
+**Schema publication**:
+Atomically replacing the shared committed schema snapshot after the outermost transaction
+containing DDL commits and an authoritative catalog reload succeeds. Proxy completes publication
+before forwarding `ReadyForQuery(I)`, so a connection opened after readiness observes the new
+schema and encryption metadata. Failed publication is fail-closed: the affected connection is
+closed without forwarding readiness, and the dirty publication remains eligible for retry.
+
+**Schema middleware**:
+The owner of transactional schema state. Frontend and Backend report protocol lifecycle events;
+they do not directly change overlays or dirty flags. The middleware owns DDL detection, prepared
+DDL effects, successful-execution activation, savepoint and transaction transitions,
+effective-schema resolution, and the decision that publication is required. `Context` performs the
+authoritative reload round trip, while `SchemaManager` coalesces reloads and orders their
+generations. See `docs/adr/0001-transaction-aware-schema-middleware.md`.
## Note on `session`
diff --git a/packages/cipherstash-proxy/docs/adr/0001-transaction-aware-schema-middleware.md b/packages/cipherstash-proxy/docs/adr/0001-transaction-aware-schema-middleware.md
new file mode 100644
index 00000000..2f0c55fa
--- /dev/null
+++ b/packages/cipherstash-proxy/docs/adr/0001-transaction-aware-schema-middleware.md
@@ -0,0 +1,102 @@
+---
+status: accepted
+issue: BUG-308
+---
+
+# Transaction-aware schema middleware
+
+## Context
+
+Proxy must map a statement using the schema PostgreSQL makes visible to that statement. DDL is
+transactional: its effects become visible inside the transaction after successful execution,
+but other connections cannot observe them until the outermost transaction commits.
+
+The existing design marks schema change while parsing SQL and reloads through a separate database
+connection. In the extended protocol, a client's preparation `Sync` can consume that marker before
+the DDL is executed. Even moving the reload to every `ReadyForQuery` is insufficient: a
+`ReadyForQuery(T)` occurs inside an explicit transaction, where the loader connection still cannot
+see uncommitted DDL. Schema and column encryption configuration are also loaded and published
+separately, while existing connections retain snapshots taken when their contexts were created.
+
+These behaviours can make later connections use stale mapping or encryption metadata. They also
+make connection-local behaviour depend on speculative DDL inferred at `Parse`, even if execution
+later fails.
+
+## Decision
+
+Introduce standalone schema middleware as the sole owner of the transactional schema lifecycle.
+Frontend and Backend report protocol events to it; neither manipulates schema-change flags or
+reload managers directly.
+
+### State model
+
+- A **committed schema snapshot** is immutable and monotonically versioned. It contains database
+ structure and encryption metadata derived from EQL domain types as one atomic value.
+- A transaction pins the current committed snapshot. Existing idle connections adopt the latest
+ snapshot before starting their next transaction.
+- A **transaction schema overlay** contains only effects confirmed by successful DDL execution.
+ The connection's **effective schema** is its pinned snapshot plus this overlay.
+- Savepoints checkpoint the overlay. `ROLLBACK TO SAVEPOINT` restores its checkpoint, full rollback
+ discards it, and release preserves its effects in the enclosing transaction.
+- Parsed or prepared DDL records intent with the prepared statement. Each successful execution
+ applies its effect; `Parse` alone never changes schema state.
+- If a successful DDL cannot be modelled accurately, later schema-dependent statements in that
+ transaction fail closed.
+
+### Protocol ordering
+
+After a DDL `Execute` is forwarded, protocol-control messages required to complete that execution
+continue to flow, but later schema-dependent operations wait until its success or failure is known.
+Proxy injects `Flush` after the DDL `Execute`, allowing PostgreSQL to return `CommandComplete`
+without waiting for a client `Sync`. The client's `Sync` remains the sole synchronization boundary,
+so it still receives exactly one `ReadyForQuery`. This supports clients that pipeline DDL and a
+dependent `Parse` in one batch without speculative mapping or a protocol deadlock.
+
+A simple-query message fails closed when DDL may change encryption metadata and a later statement
+requires that metadata for mapping. Native DDL and native temporary-table batches continue to pass
+through because they introduce no encryption obligation. Proxy derives execution intents from the
+statements it actually forwards, including when a mapping error uses the compatibility passthrough
+fallback, so backend outcomes cannot become misaligned with phantom intents.
+
+### Publication
+
+When the outermost transaction containing successful DDL commits, one reload coordinator reads the
+authoritative PostgreSQL catalog. Concurrent publication requests are coalesced, and generation
+ordering prevents an older reload from replacing newer state. The coordinator atomically publishes
+the combined schema and encryption snapshot before Proxy forwards `ReadyForQuery(I)`.
+
+Proxy does not merge its inferred overlay into shared state. PostgreSQL remains authoritative for
+cascades, conditional DDL, server-side effects, and the final outcome of the transaction.
+
+If publication fails after PostgreSQL has committed, Proxy retains the dirty publication for retry,
+does not forward successful readiness, and closes the affected client connection. The database
+commit cannot be undone, but Proxy must not imply that stale encryption metadata is safe to use.
+
+## Consequences
+
+- DDL becomes visible to later statements on the same connection immediately after successful
+ execution, including within an explicit transaction.
+- Other connections observe DDL only after commit and successful publication.
+- Every transaction maps against a stable schema and encryption-policy generation.
+- Frontend and Backend become protocol adapters around a testable schema state machine.
+- Extended-protocol pipelining requires bounded deferral after DDL execution.
+- Native temporary tables are connection-local and absent from authoritative reloads. Proxy ignores
+ them only when they cannot shadow an encrypted table or introduce EQL columns; unsafe cases fail
+ closed for the rest of the connection (unless rollback to an earlier savepoint removes the
+ object), because a global catalog reload cannot prove connection-local state disappeared.
+- The local overlay deliberately models only deterministic schema changes. Encryption-neutral
+ constraints, defaults, nullability, ownership, trigger/rule state, and row-level-security state
+ are accepted, while conditional, cascading, table-rewriting, view, and type-changing operations
+ remain unmodelled within a transaction.
+- Availability is intentionally sacrificed when committed schema state cannot be published safely.
+- Schema and encryption managers can no longer publish independent observable states.
+
+## Verification
+
+State-machine tests cover successful execution, execution failure, explicit commit, full rollback,
+savepoint rollback, generation ordering, deferral, unmodelled DDL, and reload failure. Database-backed
+tests cover extended-protocol autocommit, explicit transactions, an already-open second connection,
+pipelining with a single client `Sync`, direct ciphertext verification, safe `ALTER TABLE`, native
+temporary tables, compatibility fallback bookkeeping, and failure before readiness. Simple-query
+coverage proves both that encryption-dependent post-DDL batches fail closed and native batches
+continue to work.
diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs
index dffc1493..32370a2d 100644
--- a/packages/cipherstash-proxy/src/error.rs
+++ b/packages/cipherstash-proxy/src/error.rs
@@ -74,7 +74,11 @@ impl Error {
// Forwarding a statement that references a legacy EQL v2 column
// stores plaintext in a column its operator believes is encrypted
// (CIP-3688). No configuration may turn that back on.
- Error::Mapping(MappingError::UnmappableEncryptedColumn { .. })
+ Error::Mapping(
+ MappingError::UnmappableEncryptedColumn { .. }
+ | MappingError::DependentStatementAfterDdl
+ | MappingError::UnmodelledDdl
+ )
)
}
}
@@ -99,6 +103,14 @@ pub enum ZeroKMSError {
#[derive(Error, Debug)]
pub enum MappingError {
+ /// A simple-query batch would map against encryption metadata changed earlier in the batch.
+ #[error("A simple-query batch cannot contain a schema-dependent statement after DDL that may change encryption metadata. Send the DDL and dependent statement as separate queries. For help visit {}#mapping-dependent-statement-after-ddl", ERROR_DOC_BASE_URL)]
+ DependentStatementAfterDdl,
+
+ /// Confirmed DDL cannot be represented safely by the transaction overlay.
+ #[error("A successful schema change in this transaction cannot be modelled safely. Roll back the transaction before issuing schema-dependent statements. For help visit {}#mapping-unmodelled-ddl", ERROR_DOC_BASE_URL)]
+ UnmodelledDdl,
+
#[error("Invalid parameter for column '{}' of type '{}' in table '{}' (OID {}). For help visit {}#mapping-invalid-parameter",
_0.column_name(), _0.cast_type(), _0.table_name(), _0.oid(), ERROR_DOC_BASE_URL)]
InvalidParameter(Box),
diff --git a/packages/cipherstash-proxy/src/postgresql/backend.rs b/packages/cipherstash-proxy/src/postgresql/backend.rs
index 1e2a24f1..c081d80c 100644
--- a/packages/cipherstash-proxy/src/postgresql/backend.rs
+++ b/packages/cipherstash-proxy/src/postgresql/backend.rs
@@ -18,6 +18,7 @@ use crate::prometheus::{
DECRYPTION_ERROR_TOTAL, DECRYPTION_REQUESTS_TOTAL, ROWS_ENCRYPTED_TOTAL,
ROWS_PASSTHROUGH_TOTAL, ROWS_TOTAL, SERVER_BYTES_RECEIVED_TOTAL,
};
+use crate::proxy::schema::TransactionStatus;
use crate::proxy::EncryptionService;
use crate::EqlCiphertext;
use bytes::BytesMut;
@@ -185,7 +186,7 @@ where
// client opening its next connection after ReadyForQuery observes
// the newly loaded schema and encrypt configuration.
if matches!(code.into(), BackendCode::ReadyForQuery) {
- self.context.reload_schema_if_changed().await;
+ self.handle_ready_for_query(&bytes).await?;
}
self.write_with_flush(bytes).await?;
@@ -202,8 +203,13 @@ where
match code.into() {
BackendCode::CommandComplete
| BackendCode::EmptyQueryResponse
- | BackendCode::PortalSuspended
- | BackendCode::ErrorResponse => {
+ | BackendCode::PortalSuspended => {
+ self.context.schema_execution_succeeded();
+ self.context.complete_execution();
+ self.context.finish_session();
+ }
+ BackendCode::ErrorResponse => {
+ self.context.schema_execution_failed();
self.context.complete_execution();
self.context.finish_session();
}
@@ -240,10 +246,12 @@ where
}
}
+ self.context.schema_execution_succeeded();
self.context.complete_execution();
self.context.finish_session();
}
BackendCode::ErrorResponse => {
+ self.context.schema_execution_failed();
if let Some(b) = self.error_response_handler(&bytes)? {
bytes = b
}
@@ -291,7 +299,7 @@ where
client_id = self.context.client_id,
msg = "ReadyForQuery"
);
- self.context.reload_schema_if_changed().await;
+ self.handle_ready_for_query(&bytes).await?;
}
code => {
@@ -308,6 +316,20 @@ where
Ok(())
}
+ /// Publishes committed DDL before exposing idle readiness, then updates
+ /// connection-local transaction state from the same authoritative boundary.
+ async fn handle_ready_for_query(&self, bytes: &BytesMut) -> Result<(), Error> {
+ let Some(status) = bytes.last().copied().map(TransactionStatus::from) else {
+ return Ok(());
+ };
+
+ if status.is_idle() {
+ self.context.publish_schema_if_changed().await?;
+ }
+ self.context.schema_ready_for_query(status);
+ Ok(())
+ }
+
/// Handles PostgreSQL ErrorResponse messages from the server.
///
/// ErrorResponse messages indicate that an error occurred during SQL execution.
@@ -757,6 +779,7 @@ mod tests {
use crate::log;
use crate::postgresql::context::KeysetIdentifier;
use crate::postgresql::messages::Name;
+ use crate::postgresql::parser::SqlParser;
use crate::proxy::{EncryptConfig, EncryptionService};
use eql_mapper::Schema;
use std::io::Cursor;
@@ -847,7 +870,7 @@ mod tests {
}
#[tokio::test]
- async fn passthrough_reloads_changed_schema_before_ready_for_query() {
+ async fn publication_failure_closes_connection_before_idle_readiness() {
let config = Arc::new(TandemConfig::for_testing());
let encrypt_config = Arc::new(EncryptConfig::default());
let schema = Arc::new(Schema::new("public"));
@@ -860,7 +883,9 @@ mod tests {
TestService {},
reload_sender,
);
- context.set_schema_changed();
+ let ddl = SqlParser::parse_statement("create table reports (id bigint)").unwrap();
+ context.execute_simple_schema_statements(&[ddl]);
+ context.schema_execution_succeeded();
let reload_task = tokio::spawn(async move {
let Some(crate::proxy::ReloadCommand::DatabaseSchema(responder)) =
@@ -868,20 +893,16 @@ mod tests {
else {
panic!("expected a database schema reload command");
};
- responder.send(true).expect("reload receiver must be open");
+ responder.send(false).unwrap();
});
let (client_sender, mut client_receiver) = mpsc::unbounded_channel();
let reader = Cursor::new(ready_for_query_bytes().to_vec());
let mut backend = Backend::new(client_sender, reader, context);
- backend.rewrite().await.unwrap();
+ assert!(backend.rewrite().await.is_err());
reload_task.await.unwrap();
- assert_eq!(
- client_receiver.recv().await.unwrap(),
- ready_for_query_bytes()
- );
- assert!(!backend.context.take_schema_changed());
+ assert!(client_receiver.try_recv().is_err());
}
/// Regression test for BUG-300 (passthrough memory leak).
diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
index 3ea0cbd5..e3b22e51 100644
--- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs
+++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
@@ -11,13 +11,16 @@ use super::{
};
use crate::{
config::TandemConfig,
- error::{EncryptError, Error},
+ error::{ConfigError, EncryptError, Error},
log::{CONTEXT, SLOW_STATEMENTS},
prometheus::{
SLOW_STATEMENTS_TOTAL, STATEMENTS_EXECUTION_DURATION_SECONDS,
STATEMENTS_SESSION_DURATION_SECONDS,
},
- proxy::{EncryptConfig, EncryptionService, ReloadCommand, ReloadSender},
+ proxy::{
+ schema::{CommittedSchemaStore, SchemaMiddleware},
+ EncryptConfig, EncryptionService, ReloadCommand, ReloadSender,
+ },
};
use cipherstash_client::IdentifiedBy;
use eql_mapper::{Schema, TableResolver};
@@ -28,7 +31,7 @@ pub use statement_metadata::StatementMetadata;
use std::{
collections::{HashMap, VecDeque},
sync::{
- atomic::{AtomicBool, AtomicU64, Ordering},
+ atomic::{AtomicU64, Ordering},
Arc, LazyLock, RwLock,
},
time::{Duration, Instant},
@@ -61,18 +64,15 @@ where
{
pub client_id: i32,
config: Arc,
- encrypt_config: Arc,
encryption: T,
reload_sender: ReloadSender,
- column_mapper: ColumnMapper,
+ schema_middleware: SchemaMiddleware,
statements: Arc>>>,
statement_sessions: Arc>>,
portals: Arc>>,
describe: Arc>,
execute: Arc>,
- schema_changed: Arc,
session_metrics: Arc>,
- table_resolver: Arc,
unsafe_disable_mapping: bool,
keyset_id: Arc>>,
session_id_counter: Arc,
@@ -177,7 +177,20 @@ where
encryption: T,
reload_sender: ReloadSender,
) -> Context {
- let column_mapper = ColumnMapper::new(encrypt_config.clone());
+ let schema_store =
+ CommittedSchemaStore::from_parts((*schema).clone(), (*encrypt_config).clone());
+ Self::new_with_schema_store(client_id, config, schema_store, encryption, reload_sender)
+ }
+
+ /// Constructs a connection context over the shared committed schema store.
+ pub fn new_with_schema_store(
+ client_id: i32,
+ config: Arc,
+ schema_store: CommittedSchemaStore,
+ encryption: T,
+ reload_sender: ReloadSender,
+ ) -> Context {
+ let schema_middleware = SchemaMiddleware::from_store(schema_store);
Context {
statements: Arc::new(RwLock::new(HashMap::new())),
@@ -185,13 +198,10 @@ where
portals: Arc::new(RwLock::new(HashMap::new())),
describe: Arc::new(RwLock::from(Queue::new())),
execute: Arc::new(RwLock::from(Queue::new())),
- schema_changed: Arc::new(AtomicBool::new(false)),
session_metrics: Arc::new(RwLock::from(Queue::new())),
- table_resolver: Arc::new(TableResolver::new_editable(schema)),
client_id,
config,
- encrypt_config,
- column_mapper,
+ schema_middleware,
encryption,
reload_sender,
unsafe_disable_mapping: false,
@@ -562,20 +572,91 @@ where
Some(session_context.to_owned())
}
- pub fn set_schema_changed(&self) {
- debug!(target: CONTEXT,
- client_id = self.client_id,
- msg = "Schema changed"
- );
- self.schema_changed.store(true, Ordering::Release);
+ /// Returns the resolver for this connection's effective schema snapshot.
+ pub fn get_table_resolver(&self) -> Arc {
+ self.schema_middleware.resolver()
}
- pub fn take_schema_changed(&self) -> bool {
- self.schema_changed.swap(false, Ordering::AcqRel)
+ /// Records schema intent for a parsed prepared statement.
+ pub fn prepare_schema_statement(&self, name: Name, statement: sqltk::parser::ast::Statement) {
+ self.schema_middleware.prepare(name, statement);
}
- pub fn get_table_resolver(&self) -> Arc {
- self.table_resolver.clone()
+ /// Associates a bound portal with its prepared statement's schema intent.
+ pub fn bind_schema_statement(&self, portal: Name, prepared_statement: &Name) {
+ self.schema_middleware.bind(portal, prepared_statement);
+ }
+
+ /// Records a portal execution and returns whether its DDL needs an injected flush.
+ pub fn execute_schema_portal(&self, portal: &Name) -> bool {
+ self.schema_middleware.execute(portal)
+ }
+
+ /// Records statements in one simple-query protocol message.
+ pub fn execute_simple_schema_statements(&self, statements: &[sqltk::parser::ast::Statement]) {
+ self.schema_middleware.simple_query(statements);
+ }
+
+ /// Returns whether a simple-query batch must be rejected to protect encryption.
+ pub fn simple_query_requires_fail_closed(
+ &self,
+ statements: &[sqltk::parser::ast::Statement],
+ ) -> bool {
+ self.schema_middleware
+ .simple_query_requires_fail_closed(statements)
+ }
+
+ /// Records an extended-protocol synchronization boundary.
+ pub fn mark_schema_protocol_boundary(&self) {
+ self.schema_middleware.protocol_boundary();
+ }
+
+ /// Reports successful execution of the next queued statement.
+ pub fn schema_execution_succeeded(&self) {
+ self.schema_middleware.execution_succeeded();
+ }
+
+ /// Reports failed execution of the next queued statement.
+ pub fn schema_execution_failed(&self) {
+ self.schema_middleware.execution_failed();
+ }
+
+ /// Waits for preceding schema-changing executions to resolve.
+ pub async fn wait_for_schema_execution(&self) {
+ self.schema_middleware.wait_for_ddl().await;
+ }
+
+ /// Refuses schema-dependent work after confirmed unmodelled DDL.
+ pub fn ensure_schema_modelled(&self) -> Result<(), Error> {
+ if self.schema_middleware.has_unmodelled_ddl() {
+ return Err(crate::error::MappingError::UnmodelledDdl.into());
+ }
+ Ok(())
+ }
+
+ /// Replaces idle connection-local state with the latest committed snapshot.
+ pub fn adopt_latest_schema(&self) {
+ self.schema_middleware.adopt_latest();
+ }
+
+ /// Publishes pending shared state, then prepares the effective schema for mapping.
+ pub async fn prepare_schema_for_statement(&self) -> Result<(), Error> {
+ if self
+ .schema_middleware
+ .requires_publication_before_statement()
+ {
+ if !self.reload_schema().await {
+ return Err(ConfigError::SchemaCouldNotBeLoaded.into());
+ }
+ self.schema_middleware.publication_succeeded();
+ }
+ self.schema_middleware.before_statement();
+ Ok(())
+ }
+
+ /// Reports a readiness boundary and PostgreSQL transaction status.
+ pub fn schema_ready_for_query(&self, status: crate::proxy::schema::TransactionStatus) {
+ self.schema_middleware.ready_for_query(status);
}
/// Examines a [`sqltk::parser::ast::Statement`] and if it is precisely equal to `SET UNSAFE_DISABLE_MAPPING = {boolean};`
@@ -791,16 +872,26 @@ where
}
/// Reload schema if it has changed since last check.
- pub async fn reload_schema_if_changed(&self) {
- if self.take_schema_changed() && !self.reload_schema().await {
- // Preserve the dirty state when the reload task is unavailable so
- // a later statement can retry instead of silently losing the DDL.
- self.set_schema_changed();
+ pub async fn publish_schema_if_changed(&self) -> Result<(), Error> {
+ if !self.schema_middleware.needs_publication() {
+ self.adopt_latest_schema();
+ return Ok(());
+ }
+
+ if self.schema_middleware.has_local_changes() {
+ self.schema_middleware.mark_publication_pending();
}
+
+ if !self.reload_schema().await {
+ return Err(ConfigError::SchemaCouldNotBeLoaded.into());
+ }
+
+ self.schema_middleware.publication_succeeded();
+ Ok(())
}
pub fn is_passthrough(&self) -> bool {
- self.encrypt_config.is_empty() || self.config.mapping_disabled()
+ self.schema_middleware.encrypt_config().is_empty() || self.config.mapping_disabled()
}
// Column processing delegation methods
@@ -808,28 +899,31 @@ where
&self,
typed_statement: &eql_mapper::TypeCheckedStatement<'_>,
) -> Result>, Error> {
- self.column_mapper.get_projection_columns(typed_statement)
+ ColumnMapper::new(self.schema_middleware.encrypt_config())
+ .get_projection_columns(typed_statement)
}
pub fn get_param_columns(
&self,
typed_statement: &eql_mapper::TypeCheckedStatement<'_>,
) -> Result>, Error> {
- self.column_mapper.get_param_columns(typed_statement)
+ ColumnMapper::new(self.schema_middleware.encrypt_config())
+ .get_param_columns(typed_statement)
}
pub fn get_output_param_columns(
&self,
plan: &eql_mapper::ParamPlan,
) -> Result>, Error> {
- self.column_mapper.get_output_param_columns(plan)
+ ColumnMapper::new(self.schema_middleware.encrypt_config()).get_output_param_columns(plan)
}
pub fn get_literal_columns(
&self,
typed_statement: &eql_mapper::TypeCheckedStatement<'_>,
) -> Result>, Error> {
- self.column_mapper.get_literal_columns(typed_statement)
+ ColumnMapper::new(self.schema_middleware.encrypt_config())
+ .get_literal_columns(typed_statement)
}
// Direct config access methods
@@ -1069,7 +1163,7 @@ mod tests {
messages::{Name, Target},
Column,
},
- proxy::{EncryptConfig, EncryptionService, ReloadCommand},
+ proxy::{EncryptConfig, EncryptionService},
TandemConfig,
};
use cipherstash_client::IdentifiedBy;
@@ -1121,68 +1215,6 @@ mod tests {
)
}
- #[tokio::test]
- async fn successful_schema_reload_consumes_change_flag_once() {
- let config = Arc::new(TandemConfig::for_testing());
- let encrypt_config = Arc::new(EncryptConfig::default());
- let schema = Arc::new(Schema::new("public"));
- let (reload_sender, mut reload_receiver) = mpsc::unbounded_channel();
- let context = Context::new(
- 1,
- config,
- encrypt_config,
- schema,
- TestService {},
- reload_sender,
- );
- let reload_task = tokio::spawn(async move {
- let Some(ReloadCommand::DatabaseSchema(responder)) = reload_receiver.recv().await
- else {
- panic!("expected database schema reload");
- };
- responder.send(true).expect("reload receiver is alive");
- tokio::time::timeout(std::time::Duration::from_millis(20), reload_receiver.recv())
- .await
- .is_err()
- });
-
- context.set_schema_changed();
- context.reload_schema_if_changed().await;
- context.reload_schema_if_changed().await;
-
- assert!(!context.take_schema_changed());
- assert!(reload_task.await.expect("reload task did not panic"));
- }
-
- #[tokio::test]
- async fn failed_schema_reload_keeps_change_flag_for_retry() {
- let config = Arc::new(TandemConfig::for_testing());
- let encrypt_config = Arc::new(EncryptConfig::default());
- let schema = Arc::new(Schema::new("public"));
- let (reload_sender, mut reload_receiver) = mpsc::unbounded_channel();
- let context = Context::new(
- 1,
- config,
- encrypt_config,
- schema,
- TestService {},
- reload_sender,
- );
- let reload_task = tokio::spawn(async move {
- let Some(ReloadCommand::DatabaseSchema(responder)) = reload_receiver.recv().await
- else {
- panic!("expected database schema reload");
- };
- responder.send(false).expect("reload receiver is alive");
- });
-
- context.set_schema_changed();
- context.reload_schema_if_changed().await;
-
- reload_task.await.expect("reload task did not panic");
- assert!(context.take_schema_changed());
- }
-
fn statement() -> Statement {
Statement {
param_columns: vec![],
diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs
index 0e543642..73345639 100644
--- a/packages/cipherstash-proxy/src/postgresql/frontend.rs
+++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs
@@ -205,6 +205,7 @@ where
}
}
+ let mut flush_after_write = false;
match code {
Code::Query => {
match self.query_handler(&bytes).await {
@@ -221,7 +222,10 @@ where
// The server's ErrorResponse and ReadyForQuery answer
// the client's Query in order. See handle_statement_error.
match self.handle_statement_error(err)? {
- Some(exception) => bytes = exception,
+ Some(exception) => {
+ self.context.mark_schema_protocol_boundary();
+ bytes = exception;
+ }
// FATAL error written directly to the client; the
// simple protocol still expects a ReadyForQuery.
None => {
@@ -236,7 +240,7 @@ where
self.describe_handler(&bytes).await?;
}
Code::Execute => {
- self.execute_handler(&bytes).await?;
+ flush_after_write = self.execute_handler(&bytes).await?;
}
Code::Parse => {
match self.parse_handler(&bytes).await {
@@ -255,6 +259,7 @@ where
// client's batch in order. See handle_statement_error.
match self.handle_statement_error(err)? {
Some(exception) => {
+ self.context.mark_schema_protocol_boundary();
self.error_state = Some(ErrorState::ExceptionInjected);
bytes = exception;
}
@@ -283,6 +288,7 @@ where
// client's batch in order. See handle_statement_error.
match self.handle_statement_error(err)? {
Some(exception) => {
+ self.context.mark_schema_protocol_boundary();
self.error_state = Some(ErrorState::ExceptionInjected);
bytes = exception;
}
@@ -300,8 +306,6 @@ where
?code,
);
- self.context.reload_schema_if_changed().await;
-
match self.error_state.take() {
Some(ErrorState::ExceptionInjected) => {
// The exception statement injected on failure already
@@ -322,6 +326,7 @@ where
}
None => {}
}
+ self.context.mark_schema_protocol_boundary();
}
Code::Close => {
self.close_handler(&bytes).await?;
@@ -336,6 +341,9 @@ where
}
self.write_to_server(bytes).await?;
+ if flush_after_write {
+ self.write_to_server(postgresql_flush_message()).await?;
+ }
Ok(())
}
@@ -378,12 +386,13 @@ where
Ok(())
}
- async fn execute_handler(&mut self, bytes: &BytesMut) -> Result<(), Error> {
+ async fn execute_handler(&mut self, bytes: &BytesMut) -> Result {
let execute = Execute::try_from(bytes)?;
debug!(target: PROTOCOL, client_id = self.context.client_id, ?execute);
+ let executes_ddl = self.context.execute_schema_portal(&execute.portal);
self.context
.set_execute_for_portal(execute.portal.to_owned());
- Ok(())
+ Ok(executes_ddl)
}
/// Handles PostgreSQL Query messages (simple query protocol).
@@ -433,7 +442,21 @@ where
// Simple Query may contain many statements
let parsed_statements = SqlParser::parse_statements(&query.statement)?;
- let mut transformed_statements = vec![];
+ self.context.prepare_schema_for_statement().await?;
+ if self
+ .context
+ .simple_query_requires_fail_closed(&parsed_statements)
+ {
+ return Err(MappingError::DependentStatementAfterDdl.into());
+ }
+ if parsed_statements
+ .iter()
+ .any(eql_mapper::requires_type_check)
+ {
+ self.context.wait_for_schema_execution().await;
+ self.context.ensure_schema_modelled()?;
+ }
+ let mut forwarded_statements = vec![];
debug!(target: MAPPER,
client_id = self.context.client_id,
@@ -457,15 +480,15 @@ where
warn!(msg = "Encrypted statement mapping is not enabled");
counter!(STATEMENTS_PASSTHROUGH_MAPPING_DISABLED_TOTAL).increment(1);
counter!(STATEMENTS_PASSTHROUGH_TOTAL).increment(1);
+ forwarded_statements.push(statement.clone());
continue;
}
self.handle_set_keyset(statement)?;
- self.check_for_schema_change(statement);
-
if !eql_mapper::requires_type_check(statement) {
counter!(STATEMENTS_PASSTHROUGH_TOTAL).increment(1);
+ forwarded_statements.push(statement.clone());
continue;
}
@@ -480,6 +503,11 @@ where
if self.context.mapping_errors_enabled() || err.must_fail_closed() {
return Err(err);
} else {
+ self.record_simple_schema_execution(
+ session_id,
+ Portal::passthrough(Some(session_id)),
+ &parsed_statements,
+ );
return Ok(None);
};
}
@@ -492,6 +520,7 @@ where
msg = "Encryptable Statement",
);
+ let mut transformed = false;
if typed_statement.requires_transform() {
// Record parse duration before encryption work starts
if !parse_duration_recorded {
@@ -519,11 +548,16 @@ where
// The simple protocol has no params, so the plan is
// always empty here — only the SQL is needed.
- transformed_statements.push(transformed_statement.statement);
+ forwarded_statements.push(transformed_statement.statement);
encrypted = true;
+ transformed = true;
}
}
+ if !transformed {
+ forwarded_statements.push(typed_statement.statement.clone());
+ }
+
counter!(STATEMENTS_ENCRYPTED_TOTAL).increment(1);
// Set Encrypted portal and mark as mapped
@@ -538,7 +572,7 @@ where
msg = "Passthrough Statement"
);
counter!(STATEMENTS_PASSTHROUGH_TOTAL).increment(1);
- transformed_statements.push(statement.clone());
+ forwarded_statements.push(statement.clone());
}
};
}
@@ -568,11 +602,10 @@ where
m.set_query_fingerprint(&query.statement);
});
- self.context.add_portal(Name::unnamed(), portal);
- self.context.set_execute(Name::unnamed(), Some(session_id));
+ self.record_simple_schema_execution(session_id, portal, &forwarded_statements);
if encrypted {
- let transformed_statement = transformed_statements
+ let transformed_statement = forwarded_statements
.iter()
.map(|s| s.to_string())
.collect::>()
@@ -611,6 +644,18 @@ where
}
}
+ /// Records the portal and schema intents for the statements actually sent to PostgreSQL.
+ fn record_simple_schema_execution(
+ &mut self,
+ session_id: SessionId,
+ portal: Portal,
+ statements: &[ast::Statement],
+ ) {
+ self.context.add_portal(Name::unnamed(), portal);
+ self.context.set_execute(Name::unnamed(), Some(session_id));
+ self.context.execute_simple_schema_statements(statements);
+ }
+
/// Encrypts literal values found in SQL statements.
///
/// Takes literal values extracted from SQL statements and encrypts those that
@@ -826,6 +871,13 @@ where
.set_statement_session(message.name.to_owned(), session_id);
let statement = SqlParser::parse_statement(&message.statement)?;
+ self.context.prepare_schema_for_statement().await?;
+ if eql_mapper::requires_type_check(&statement) {
+ self.context.wait_for_schema_execution().await;
+ self.context.ensure_schema_modelled()?;
+ }
+ self.context
+ .prepare_schema_statement(message.name.to_owned(), statement.clone());
if let Some(mapping_disabled) = self.context.maybe_set_unsafe_disable_mapping(&statement) {
warn!(
@@ -843,8 +895,6 @@ where
self.handle_set_keyset(&statement)?;
- self.check_for_schema_change(&statement);
-
if !eql_mapper::requires_type_check(&statement) {
counter!(STATEMENTS_PASSTHROUGH_TOTAL).increment(1);
return Ok(None);
@@ -945,19 +995,6 @@ where
}
}
- ///
- /// Check the Statement AST for DDL
- /// Sets a schema changed flag in the Context
- ///
- ///
- fn check_for_schema_change(&self, statement: &ast::Statement) {
- let schema_changed = eql_mapper::collect_ddl(self.context.get_table_resolver(), statement);
-
- if schema_changed {
- self.context.set_schema_changed();
- }
- }
-
///
/// Handles `SET CIPHERSTASH KEYSET_*` statements
///
@@ -1085,6 +1122,10 @@ where
/// - `Ok(None)` - No parameter encryption needed, forward original message
/// - `Err(error)` - Processing failed, error should be sent to client
async fn bind_handler(&mut self, bytes: &BytesMut) -> Result