diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7d00852..0c47cf18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **PostgreSQL protocol error handling after the pg-proto migration**: Proxy now rejects `require_tls` configurations that omit a certificate, preserves PostgreSQL transaction state when statement mapping fails, returns decryption failures as PostgreSQL errors without dropping the connection, and reloads changed schemas only after PostgreSQL confirms the transaction boundary. Prepared-statement replacement also preserves existing portals and overlapping statement metrics.
+### 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/Cargo.lock b/Cargo.lock
index 6d3f842e..1c10c355 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3157,9 +3157,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pg-proto"
-version = "0.11.1"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f5b88d40736045b71bb72d114a6f212c98095e1f43cfc998c948e429b55012"
+checksum = "d3e010cd297396de7654638926d75d89a0a313cc9e526fa42a480f1c0e0f0197"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -3180,9 +3180,9 @@ dependencies = [
[[package]]
name = "pg-proto-fsm"
-version = "0.11.1"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc37a28331c337145f6500402d7857565ff807891712d79833e0fbc00567c48d"
+checksum = "bb7664de912f117f8ce3e2abf9a032cd69e577b5a8edd0efe72b48559337d6fb"
dependencies = [
"proc-macro2",
"quote",
diff --git a/docs/errors.md b/docs/errors.md
index d7267cce..7f085306 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)
@@ -264,6 +266,38 @@ 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 that may change encryption
+metadata. 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. Native DDL
+and native temporary-table batches continue to pass through because they introduce no encryption
+obligation.
+
+### 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 d2a713d5..63b1e022 100644
--- a/packages/cipherstash-proxy-integration/src/lib.rs
+++ b/packages/cipherstash-proxy-integration/src/lib.rs
@@ -25,6 +25,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..e610219b 100644
--- a/packages/cipherstash-proxy-integration/src/schema_change.rs
+++ b/packages/cipherstash-proxy-integration/src/schema_change.rs
@@ -1,25 +1,243 @@
#[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 schema_change_reloads_schema() {
- let client = connect_with_tls(*PROXY).await;
+ 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");
- let id = random_id();
+ ddl_connection
+ .execute(&create_encrypted_table(&table), &[])
+ .await
+ .unwrap();
- let sql = format!(
- "CREATE TABLE table_{id} (
- id bigint,
- PRIMARY KEY(id)
- );"
+ insert_secret(&already_open_connection, &table, 1, "classified").await;
+ assert_ciphertext_at_rest(&table, 1, "classified").await;
+ }
+
+ #[tokio::test]
+ async fn explicit_transaction_uses_successful_ddl_overlay_before_commit() {
+ let client = connect_for_test(*PROXY).await;
+ let table = table("bug_308_transaction");
+
+ 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();
+
+ 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");
+
+ 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);
+ }
- let _ = client.execute(&sql, &[]).await.unwrap();
+ #[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)
+ );
+
+ assert!(client.simple_query(&batch).await.is_err());
+
+ let exists: bool = postgres
+ .query_one("SELECT to_regclass($1) IS NOT NULL", &[&table])
+ .await
+ .unwrap()
+ .get(0);
+ assert!(!exists);
+ }
+
+ #[tokio::test]
+ async fn compatibility_fallback_tracks_native_ddl_for_a_later_encrypted_alter() {
+ let client = connect_for_test(*PROXY).await;
+ let table = table("bug_308_fallback");
+
+ client
+ .batch_execute(&format!(
+ "CREATE TABLE {table} (id bigint PRIMARY KEY); \
+ INSERT INTO {table} (id) VALUES (1)"
+ ))
+ .await
+ .unwrap();
+ client
+ .execute(
+ &format!("ALTER TABLE {table} ADD COLUMN secret eql_v3_text_search"),
+ &[],
+ )
+ .await
+ .unwrap();
+
+ insert_secret(&client, &table, 2, "after fallback").await;
+ assert_ciphertext_at_rest(&table, 2, "after fallback").await;
+ }
+
+ #[tokio::test]
+ async fn rewritten_simple_query_preserves_native_ddl_and_its_intent() {
+ let client = connect_for_test(*PROXY).await;
+ let encrypted = table("bug_308_rewritten");
+ let staging = table("bug_308_staging");
- let sql = format!("SELECT id FROM table_{id}");
- let rows = client.query(&sql, &[]).await.unwrap();
+ client
+ .execute(&create_encrypted_table(&encrypted), &[])
+ .await
+ .unwrap();
+ client
+ .batch_execute(&format!(
+ "CREATE TABLE {staging} (id bigint PRIMARY KEY); \
+ INSERT INTO {encrypted} (id, secret) VALUES (1, 'preserved batch secret')"
+ ))
+ .await
+ .unwrap();
- assert!(rows.is_empty());
+ let postgres = connect_for_test(get_database_port()).await;
+ let exists: bool = postgres
+ .query_one("SELECT to_regclass($1) IS NOT NULL", &[&staging])
+ .await
+ .unwrap()
+ .get(0);
+ assert!(exists);
+ assert_ciphertext_at_rest(&encrypted, 1, "preserved batch secret").await;
}
}
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/Cargo.toml b/packages/cipherstash-proxy/Cargo.toml
index f694b0f0..a33baf77 100644
--- a/packages/cipherstash-proxy/Cargo.toml
+++ b/packages/cipherstash-proxy/Cargo.toml
@@ -28,7 +28,7 @@ md-5 = "0.10.6"
metrics = "0.24.3"
metrics-exporter-prometheus = "0.17"
moka = { version = "0.12", features = ["future"] }
-pg-proto = "0.11.1"
+pg-proto = "0.12.0"
postgres-protocol = "0.6.7"
postgres-types = { version = "0.2.8", features = ["with-serde_json-1"] }
rand = "0.9"
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..614e9f13
--- /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, reload failure, and safe native
+batches. 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, and both compatibility-fallback and
+rewritten-batch intent bookkeeping. Backend-adapter tests prove that publication failure closes
+the connection before idle readiness.
diff --git a/packages/cipherstash-proxy/src/config/tandem.rs b/packages/cipherstash-proxy/src/config/tandem.rs
index 4329a163..9a331abc 100644
--- a/packages/cipherstash-proxy/src/config/tandem.rs
+++ b/packages/cipherstash-proxy/src/config/tandem.rs
@@ -278,7 +278,7 @@ impl TandemConfig {
/// In order of precedence
/// config if explicitly set
/// RUST_MIN_STACK env var if set
- /// DEBUG_THREAD_STACK_SIZE if log level is Debug or Trace
+ /// DEBUG_THREAD_STACK_SIZE for debug builds or Debug/Trace logging
/// otherwise set to DEFAULT_THREAD_STACK_SIZE (2MiB)
///
pub fn thread_stack_size(&self) -> usize {
@@ -289,7 +289,7 @@ impl TandemConfig {
// If the environment variable is set, use that value
if let Ok(stack_size) = env::var("RUST_MIN_STACK") {
- stack_size
+ return stack_size
.parse()
.inspect_err(|err| {
println!("Could not parse env var RUST_MIN_STACK: {err}");
@@ -298,7 +298,10 @@ impl TandemConfig {
.unwrap_or(DEFAULT_THREAD_STACK_SIZE);
}
- if self.log.level == LogLevel::Debug || self.log.level == LogLevel::Trace {
+ if cfg!(debug_assertions)
+ || self.log.level == LogLevel::Debug
+ || self.log.level == LogLevel::Trace
+ {
return DEBUG_THREAD_STACK_SIZE;
}
@@ -954,4 +957,28 @@ mod tests {
);
});
}
+
+ #[test]
+ fn thread_stack_size_honors_rust_min_stack() {
+ temp_env::with_var("RUST_MIN_STACK", Some("8388608"), || {
+ assert_eq!(
+ TandemConfig::for_testing().thread_stack_size(),
+ 8 * 1024 * 1024
+ );
+ });
+ }
+
+ #[test]
+ fn debug_builds_use_the_larger_thread_stack() {
+ temp_env::with_var("RUST_MIN_STACK", None::<&str>, || {
+ assert_eq!(
+ TandemConfig::for_testing().thread_stack_size(),
+ if cfg!(debug_assertions) {
+ crate::config::DEBUG_THREAD_STACK_SIZE
+ } else {
+ crate::config::DEFAULT_THREAD_STACK_SIZE
+ }
+ );
+ });
+ }
}
diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs
index c1b6ec5f..fa47dfef 100644
--- a/packages/cipherstash-proxy/src/error.rs
+++ b/packages/cipherstash-proxy/src/error.rs
@@ -74,8 +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::Encrypt(EncryptError::InvalidInboundEqlPayload)
+ Error::Mapping(
+ MappingError::UnmappableEncryptedColumn { .. }
+ | MappingError::DependentStatementAfterDdl
+ | MappingError::UnmodelledDdl
+ ) | Error::Encrypt(EncryptError::InvalidInboundEqlPayload)
)
}
}
@@ -100,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/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
index 6d2b3436..0bf57f99 100644
--- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs
+++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
@@ -7,13 +7,16 @@ pub use self::{phase_timing::PhaseTiming, portal::Portal, statement::Statement};
use super::{column_mapper::ColumnMapper, rewrite::Name, Column};
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};
@@ -25,7 +28,7 @@ pub use statement_metadata::StatementMetadata;
use std::{
collections::HashMap,
sync::{
- atomic::{AtomicBool, AtomicU64, Ordering},
+ atomic::{AtomicU64, Ordering},
Arc, LazyLock, RwLock,
},
time::{Duration, Instant},
@@ -53,17 +56,14 @@ 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>>>,
operations: Arc>>,
- schema_changed: Arc,
session_metrics: Arc>>,
- table_resolver: Arc,
upstream_tls_roots: Arc,
unsafe_disable_mapping: bool,
keyset_id: Arc>>,
@@ -153,21 +153,39 @@ 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,
+ upstream_tls_roots,
+ 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,
+ upstream_tls_roots: Arc,
+ encryption: T,
+ reload_sender: ReloadSender,
+ ) -> Context {
+ let schema_middleware = SchemaMiddleware::from_store(schema_store);
Context {
statements: Arc::new(RwLock::new(HashMap::new())),
statement_sessions: Arc::new(RwLock::new(HashMap::new())),
portals: Arc::new(RwLock::new(HashMap::new())),
operations: Arc::new(RwLock::new(HashMap::new())),
- schema_changed: Arc::new(AtomicBool::new(false)),
session_metrics: Arc::new(RwLock::new(HashMap::new())),
- table_resolver: Arc::new(TableResolver::new_editable(schema)),
upstream_tls_roots,
client_id,
config,
- encrypt_config,
- column_mapper,
+ schema_middleware,
encryption,
reload_sender,
unsafe_disable_mapping: false,
@@ -588,20 +606,96 @@ 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;
+ }
+
+ /// Returns whether a schema-changing execution is awaiting a backend outcome.
+ pub fn schema_ddl_in_flight(&self) -> bool {
+ self.schema_middleware.ddl_in_flight()
+ }
+
+ /// 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};`
@@ -833,16 +927,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
@@ -850,28 +954,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
@@ -1019,7 +1126,7 @@ mod tests {
error::Error,
log,
postgresql::{rewrite::Name, Column},
- proxy::{EncryptConfig, EncryptionService, ReloadCommand},
+ proxy::{EncryptConfig, EncryptionService},
TandemConfig,
};
use cipherstash_client::IdentifiedBy;
@@ -1082,70 +1189,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,
- Arc::new(rustls::RootCertStore::empty()),
- 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,
- Arc::new(rustls::RootCertStore::empty()),
- 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());
- }
-
#[tokio::test]
async fn empty_plaintext_batch_does_not_call_encryption_service() {
let context = create_context();
@@ -1157,7 +1200,6 @@ mod tests {
assert_eq!(output.len(), 2);
assert!(output.iter().all(Option::is_none));
}
-
fn statement() -> Statement {
Statement {
param_columns: vec![],
diff --git a/packages/cipherstash-proxy/src/postgresql/driver.rs b/packages/cipherstash-proxy/src/postgresql/driver.rs
index 58648016..da157542 100644
--- a/packages/cipherstash-proxy/src/postgresql/driver.rs
+++ b/packages/cipherstash-proxy/src/postgresql/driver.rs
@@ -160,6 +160,40 @@ where
Err(pg_proto::ForwardError::Middleware(error)) => return Err(error),
Err(error) => return Err(invalid_data(error)),
};
+ if matches!(&forwarded, ForwardedMessage::FrontendExpanded { .. }) {
+ // A DDL Execute is expanded to Execute + Flush so PostgreSQL can
+ // report its outcome before later schema-dependent frontend work.
+ // Keep the single-task intermediary progressing on the backend
+ // leg until that DDL resolves; otherwise a later frontend mapping
+ // call can wait for an outcome this task has not read yet.
+ // Box the whole secondary forwarding state machine so it
+ // does not enlarge the normal frontend driver's stack frame.
+ Box::pin(async {
+ while context.schema_ddl_in_flight() {
+ let backend = session.forward_backend();
+ let drained = match connection_timeout {
+ Some(duration) => tokio::time::timeout(duration, backend)
+ .await
+ .map_err(|_| Error::ConnectionTimeout { duration })?,
+ None => backend.await,
+ };
+ match drained {
+ Ok(
+ BackendForwarding::Forwarded(_)
+ | BackendForwarding::Expanded { .. }
+ | BackendForwarding::Suppressed(_)
+ | BackendForwarding::Held,
+ ) => {}
+ Err(pg_proto::ForwardError::Middleware(error)) => {
+ return Err(error)
+ }
+ Err(error) => return Err(invalid_data(error)),
+ }
+ }
+ Ok(())
+ })
+ .await?;
+ }
if matches!(
forwarded,
ForwardedMessage::Frontend(FrontendMessage::Terminate)
diff --git a/packages/cipherstash-proxy/src/postgresql/middleware/backend.rs b/packages/cipherstash-proxy/src/postgresql/middleware/backend.rs
index a8c9aa36..bff9e078 100644
--- a/packages/cipherstash-proxy/src/postgresql/middleware/backend.rs
+++ b/packages/cipherstash-proxy/src/postgresql/middleware/backend.rs
@@ -137,8 +137,8 @@ impl Backend {
// forwarding ReadyForQuery. This ordering also guarantees that a
// client opening its next connection after ReadyForQuery observes
// the newly loaded schema and encrypt configuration.
- if matches!(&protocol_message, BackendMessage::ReadyForQuery(_)) {
- self.context.reload_schema_if_changed().await;
+ if let BackendMessage::ReadyForQuery(status) = &protocol_message {
+ self.handle_ready_for_query(*status).await?;
}
// CipherStash metadata is operation-keyed even in passthrough mode,
@@ -147,6 +147,7 @@ impl Backend {
BackendMessage::CommandComplete(_)
| BackendMessage::EmptyQueryResponse
| BackendMessage::PortalSuspended => {
+ self.context.schema_execution_succeeded();
if let Some(operation) = operation {
let session = self.context.complete_execution(operation);
self.context.discard_operation(operation);
@@ -154,6 +155,7 @@ impl Backend {
}
}
BackendMessage::ErrorResponse(_) => {
+ self.context.schema_execution_failed();
if let Some(operation) = operation {
let session = self.context.complete_execution(operation);
self.context.discard_operation(operation);
@@ -165,9 +167,7 @@ impl Backend {
self.context.complete_describe(operation);
}
}
- BackendMessage::ReadyForQuery(status) => {
- self.context.set_transaction_status(status);
- }
+ BackendMessage::ReadyForQuery(_) => {}
_ => {}
}
@@ -179,6 +179,7 @@ impl Backend {
BackendMessage::CommandComplete(_)
| BackendMessage::EmptyQueryResponse
| BackendMessage::PortalSuspended => {
+ self.context.schema_execution_succeeded();
if let Some(operation) = operation {
let session = self.context.complete_execution(operation);
self.context.discard_operation(operation);
@@ -186,6 +187,7 @@ impl Backend {
}
}
BackendMessage::ErrorResponse(_) => {
+ self.context.schema_execution_failed();
if let Some(operation) = operation {
let session = self.context.complete_execution(operation);
self.context.discard_operation(operation);
@@ -194,8 +196,7 @@ impl Backend {
}
BackendMessage::ReadyForQuery(status) => {
self.discard_execution = false;
- self.context.set_transaction_status(status);
- self.context.reload_schema_if_changed().await;
+ self.handle_ready_for_query(status).await?;
return Ok(BackendMiddlewareOutput::Forward(
BackendMessage::ReadyForQuery(status),
));
@@ -279,8 +280,10 @@ impl Backend {
self.context.discard_operation(operation);
self.context.finish_session(session);
}
+ self.context.schema_execution_succeeded();
}
BackendMessage::ErrorResponse(ref response) => {
+ self.context.schema_execution_failed();
self.error_response_handler(response);
if let Some(operation) = operation {
@@ -320,12 +323,11 @@ impl Backend {
// Reload is potentially triggered by a FrontEnd Sync message.
// However, the SimpleQuery flow does not use Sync so we check here as well
BackendMessage::ReadyForQuery(status) => {
- self.context.set_transaction_status(status);
debug!(target: PROTOCOL,
client_id = self.context.client_id,
msg = "ReadyForQuery"
);
- self.context.reload_schema_if_changed().await;
+ self.handle_ready_for_query(status).await?;
}
_ => {
@@ -345,6 +347,29 @@ impl Backend {
}
}
+ /// Publishes committed DDL before exposing idle readiness, then updates
+ /// connection-local transaction state from the same authoritative boundary.
+ async fn handle_ready_for_query(
+ &mut self,
+ status: pg_proto::TransactionStatus,
+ ) -> Result<(), Error> {
+ self.context.set_transaction_status(status);
+ if status == pg_proto::TransactionStatus::Idle {
+ self.context.publish_schema_if_changed().await?;
+ }
+ let schema_status = match status {
+ pg_proto::TransactionStatus::Idle => crate::proxy::schema::TransactionStatus::Idle,
+ pg_proto::TransactionStatus::InTransaction => {
+ crate::proxy::schema::TransactionStatus::InTransaction
+ }
+ pg_proto::TransactionStatus::FailedTransaction => {
+ crate::proxy::schema::TransactionStatus::FailedTransaction
+ }
+ };
+ self.context.schema_ready_for_query(schema_status);
+ Ok(())
+ }
+
/// Handles PostgreSQL ErrorResponse messages from the server.
///
/// ErrorResponse messages indicate that an error occurred during SQL execution.
@@ -719,6 +744,7 @@ mod tests {
use super::*;
use crate::config::TandemConfig;
use crate::postgresql::context::KeysetIdentifier;
+ use crate::postgresql::parser::SqlParser;
use crate::proxy::{EncryptConfig, EncryptionService};
use eql_mapper::Schema;
use std::sync::Arc;
@@ -786,7 +812,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"));
@@ -800,7 +826,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)) =
@@ -808,17 +836,13 @@ mod tests {
else {
panic!("expected a database schema reload command");
};
- responder.send(true).expect("reload receiver must be open");
+ responder.send(false).unwrap();
});
let mut backend = Backend::new(context);
let ready = BackendMessage::ReadyForQuery(pg_proto::TransactionStatus::Idle);
- let output = backend.intercept(None, ready.clone()).await.unwrap();
+ let result = backend.intercept(None, ready).await;
reload_task.await.unwrap();
- assert!(matches!(
- output,
- BackendMiddlewareOutput::Forward(message) if message == ready
- ));
- assert!(!backend.context.take_schema_changed());
+ assert!(result.is_err());
}
}
diff --git a/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs b/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
index cb6db2d5..23d72aa7 100644
--- a/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
+++ b/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
@@ -116,6 +116,7 @@ impl Frontend {
if self.failed_extended_batch {
if matches!(protocol_message, FrontendMessage::Sync) {
self.failed_extended_batch = false;
+ self.context.mark_schema_protocol_boundary();
return Ok(FrontendMiddlewareOutput::Forward(protocol_message));
}
self.context.discard_operation(operation);
@@ -123,6 +124,7 @@ impl Frontend {
}
let mut outbound_message = protocol_message.clone();
+ let mut flush_after_forward = false;
match protocol_message {
FrontendMessage::Query(query) => {
@@ -141,6 +143,7 @@ impl Frontend {
self.context
.set_operation_error(operation, response.clone());
self.context.set_execute(operation, Name::new(), session_id);
+ self.context.mark_schema_protocol_boundary();
outbound_message = self.simple_error_query(&response);
}
}
@@ -149,7 +152,7 @@ impl Frontend {
self.describe_handler(operation, describe).await?;
}
FrontendMessage::Execute(execute) => {
- self.execute_handler(operation, execute).await?;
+ flush_after_forward = self.execute_handler(operation, execute).await?;
}
FrontendMessage::Parse(parse) => {
let statement = parse.statement.clone();
@@ -242,6 +245,7 @@ impl Frontend {
client_id = self.context.client_id,
message = ?protocol_message,
);
+ self.context.mark_schema_protocol_boundary();
}
FrontendMessage::Close(close) => {
self.close_handler(close).await?;
@@ -255,7 +259,11 @@ impl Frontend {
}
}
- Ok(FrontendMiddlewareOutput::Forward(outbound_message))
+ if flush_after_forward {
+ Ok(FrontendMiddlewareOutput::ForwardThenFlush(outbound_message))
+ } else {
+ Ok(FrontendMiddlewareOutput::Forward(outbound_message))
+ }
}
async fn describe_handler(
@@ -281,11 +289,12 @@ impl Frontend {
&mut self,
operation: pg_proto::OperationId,
execute: Execute,
- ) -> Result<(), Error> {
+ ) -> Result {
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(operation, execute.portal.to_owned());
- Ok(())
+ Ok(executes_ddl)
}
/// Handles PostgreSQL Query messages (simple query protocol).
@@ -338,7 +347,21 @@ impl Frontend {
// Simple Query may contain many statements
let query_text = String::from_utf8_lossy(&query).into_owned();
let parsed_statements = SqlParser::parse_statements(&query_text)?;
- 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,
@@ -362,15 +385,15 @@ impl Frontend {
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;
}
@@ -385,6 +408,12 @@ impl Frontend {
if self.context.mapping_errors_enabled() || err.must_fail_closed() {
return Err(err);
} else {
+ self.record_simple_schema_execution(
+ operation,
+ session_id,
+ Portal::passthrough(Some(session_id)),
+ &parsed_statements,
+ );
return Ok(None);
};
}
@@ -397,6 +426,7 @@ impl Frontend {
msg = "Encryptable Statement",
);
+ let mut transformed = false;
if typed_statement.requires_transform() {
// Record parse duration before encryption work starts
if !parse_duration_recorded {
@@ -413,9 +443,8 @@ impl Frontend {
)
.await?;
- if let Some(transformed_statement) = self
- .transform_statement(&typed_statement, &encrypted_literals)
- .await?
+ if let Some(transformed_statement) =
+ self.transform_statement(&typed_statement, &encrypted_literals)?
{
debug!(target: MAPPER,
client_id = self.context.client_id,
@@ -424,11 +453,16 @@ impl Frontend {
// 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
@@ -443,7 +477,7 @@ impl Frontend {
msg = "Passthrough Statement"
);
counter!(STATEMENTS_PASSTHROUGH_TOTAL).increment(1);
- transformed_statements.push(statement.clone());
+ forwarded_statements.push(statement.clone());
}
};
}
@@ -473,12 +507,10 @@ impl Frontend {
m.set_query_fingerprint(&query_text);
});
- self.context.add_portal(Name::new(), portal);
- self.context
- .set_execute(operation, Name::new(), Some(session_id));
+ self.record_simple_schema_execution(operation, session_id, portal, &forwarded_statements);
if encrypted {
- let transformed_statement = transformed_statements
+ let transformed_statement = forwarded_statements
.iter()
.map(|s| s.to_string())
.collect::>()
@@ -515,6 +547,20 @@ impl Frontend {
}
}
+ /// Records the portal and schema intents for the statements actually sent to PostgreSQL.
+ fn record_simple_schema_execution(
+ &mut self,
+ operation: pg_proto::OperationId,
+ session_id: SessionId,
+ portal: Portal,
+ statements: &[ast::Statement],
+ ) {
+ self.context.add_portal(Name::new(), portal);
+ self.context
+ .set_execute(operation, Name::new(), 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
@@ -624,7 +670,7 @@ impl Frontend {
/// - rewrites any encrypted literal values
/// - wraps any nodes in appropriate EQL function
///
- async fn transform_statement(
+ fn transform_statement(
&mut self,
typed_statement: &TypeCheckedStatement<'_>,
encrypted_literals: &Vec