Skip to content

Commit de7dcb1

Browse files
jhfclaude
andcommitted
Fix Rust bugs, add audit comments, deduplicate qi(), and document CTE query
Bug fixes: - parse_temporal_numeric now errors on unparseable strings instead of silently returning 0.0, preventing corrupted bounds from propagating. - NULL valid_from/valid_until boundaries now raise pgrx::error! instead of silently becoming empty strings in both target and source readers. Audit comments: - lib.rs: Safety analysis of thread_local! per-connection caching (memory ownership, cross-transaction safety, SPI data extraction). - reader.rs: Prepared statement caching via SPI_keepplan (lifecycle, automatic replanning, key format). - executor_cache.rs: Executor introspection cache (invalidation via source_cols_hash, growth bounds, no row data cached). Refactor: - Extract shared qi() function into native/src/util.rs, imported by both reader.rs and executor_cache.rs. - Add comprehensive doc comment to build_column_list_cte_query explaining each CTE and the 6 output SQL fragments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5e72143 commit de7dcb1

6 files changed

Lines changed: 127 additions & 27 deletions

File tree

expected/063_temporal_merge_planner_cache.out

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3332,11 +3332,11 @@ NOTICE: Explaining:
33323332
target_row.stable_pk_payload as discovered_stable_pk_payload,
33333333
target_row.es_id AS discovered_id_1, target_row.lu_id AS discovered_id_2, target_row.type AS discovered_id_3 /* v_propagated_id_cols_list */
33343334
FROM source_with_eclipsed_flag source_row
3335-
LEFT JOIN target_rows target_row ON (source_row.type = target_row.type AND (source_row.lu_id = target_row.lu_id OR (source_row.lu_id IS NULL AND target_row.lu_id IS NULL)) AND (source_row.es_id = target_row.es_id OR (source_row.es_id IS NULL AND target_row.es_id IS NULL)) /* v_source_rows_exists_join_expr */)
3335+
LEFT JOIN target_rows target_row ON ((source_row.es_id = target_row.es_id OR (source_row.es_id IS NULL AND target_row.es_id IS NULL)) AND (source_row.lu_id = target_row.lu_id OR (source_row.lu_id IS NULL AND target_row.lu_id IS NULL)) AND source_row.type = target_row.type /* v_source_rows_exists_join_expr */)
33363336

33373337
NOTICE: Hash Left Join (actual rows=2.00 loops=1)
33383338
NOTICE: Hash Cond: (source_row.type = target_row.type)
3339-
NOTICE: Join Filter: (((source_row.lu_id = target_row.lu_id) OR ((source_row.lu_id IS NULL) AND (target_row.lu_id IS NULL))) AND ((source_row.es_id = target_row.es_id) OR ((source_row.es_id IS NULL) AND (target_row.es_id IS NULL))))
3339+
NOTICE: Join Filter: (((source_row.es_id = target_row.es_id) OR ((source_row.es_id IS NULL) AND (target_row.es_id IS NULL))) AND ((source_row.lu_id = target_row.lu_id) OR ((source_row.lu_id IS NULL) AND (target_row.lu_id IS NULL))))
33403340
NOTICE: Buffers: local hit=2
33413341
NOTICE: -> Seq Scan on source_with_eclipsed_flag source_row (actual rows=2.00 loops=1)
33423342
NOTICE: Buffers: local hit=1

native/src/executor_cache.rs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use std::hash::{Hash, Hasher};
55

66
use pgrx::prelude::*;
77

8+
use crate::util::qi;
9+
810
/// Cached executor introspection state.
911
/// Contains all metadata and SQL fragments needed by the PL/pgSQL executor,
1012
/// replacing ~570 lines of per-call introspection + CTE logic.
@@ -42,6 +44,24 @@ pub struct ExecutorCachedState {
4244
pub source_cols_hash: u64,
4345
}
4446

47+
// Executor introspection cache — schema metadata for SQL fragment generation.
48+
//
49+
// Stores pre-computed SQL fragments (JOIN clauses, column lists, SET expressions)
50+
// derived from pg_attribute, sql_saga.era, and sql_saga.unique_keys catalog queries.
51+
// Contains NO row data, transaction state, or snapshot information — purely structural
52+
// metadata that changes only when DDL alters the table schema.
53+
//
54+
// Invalidation: Every call computes source_cols_hash by querying pg_attribute for
55+
// column names and types, then compares against the cached hash. Schema changes
56+
// (ALTER TABLE ADD/DROP COLUMN) produce a different hash → cache miss → full
57+
// re-introspection. Target table DDL is NOT independently hashed, but would cause
58+
// the executor's prepared statements to fail at execution time (column count/type
59+
// mismatch), surfacing as a clear PostgreSQL ERROR rather than silent corruption.
60+
//
61+
// Growth: One entry per distinct (target_table_oid, mode, identity_columns,
62+
// source_cols_hash) combination. Bounded by the number of target tables in the
63+
// application. Explicit reset available via temporal_merge_native_cache_reset().
64+
// No LRU eviction — acceptable for the batch ETL use case (small number of tables).
4565
thread_local! {
4666
/// Multi-entry cache keyed by config hash.
4767
pub static EXECUTOR_CACHE: RefCell<HashMap<u64, ExecutorCachedState>> = RefCell::new(HashMap::new());
@@ -90,11 +110,6 @@ fn hash_source_cols(client: &pgrx::spi::SpiClient, source_oid: u32) -> u64 {
90110
h.finish()
91111
}
92112

93-
/// Helper: quote identifier (double-quote, escaping inner double-quotes).
94-
fn qi(name: &str) -> String {
95-
format!("\"{}\"", name.replace('"', "\"\""))
96-
}
97-
98113
/// Perform all executor introspection in a single SPI connection and return
99114
/// the cached state. On cache hit, returns immediately with zero SPI calls.
100115
#[pg_extern]
@@ -609,8 +624,30 @@ fn run_executor_introspection(
609624
})
610625
}
611626

612-
/// Build the SQL query that replaces the column list CTE (lines 513-640).
613-
/// This is executed once on cache miss; the results are cached.
627+
/// Build the SQL query that replaces the column list CTE (lines 513-640 of the PL/pgSQL executor).
628+
/// Executed once on cache miss; results are cached in ExecutorCachedState.
629+
///
630+
/// CTEs:
631+
/// - **target_cols**: All non-dropped columns from the target table (pg_attribute).
632+
/// - **common_data_cols**: Data columns, excluding range/temporal, identity, lookup, PK,
633+
/// GENERATED ALWAYS, and serial (nextval) columns. These are the "payload" columns
634+
/// that appear in UPDATE SET clauses.
635+
/// - **all_available_cols**: Union of common_data_cols + lookup + identity + PK columns.
636+
/// This is the superset from which INSERT column lists are derived.
637+
/// - **cols_for_insert**: Columns for normal INSERT — all_available_cols minus
638+
/// insert-defaulted columns (GENERATED ALWAYS, synchronized temporal cols), but
639+
/// always keeping identity/lookup/PK columns even if they are defaulted.
640+
/// - **cols_for_founding_insert**: Columns for founding INSERT — all_available_cols minus
641+
/// founding-defaulted columns (GENERATED ALWAYS + IDENTITY BY DEFAULT), producing a
642+
/// more restrictive list that lets PostgreSQL generate serial IDs for new entities.
643+
///
644+
/// The final SELECT returns 6 string fragments (any may be NULL if no columns qualify):
645+
/// 1. **update_set_clause**: `col = CASE WHEN p.data ? 'col' THEN (p.data->>'col')::type ELSE t.col END, ...`
646+
/// 2. **all_cols_ident**: Comma-separated quoted column names for INSERT target list.
647+
/// 3. **all_cols_select**: Comma-separated expressions reading from `jpr_all` record with COALESCE for NOT NULL defaulted.
648+
/// 4. **all_cols_from_jsonb**: Comma-separated `(s.full_data->>'col')::type` expressions for INSERT from JSONB.
649+
/// 5. **founding_all_cols_ident**: Column names for founding INSERT target list.
650+
/// 6. **founding_all_cols_from_jsonb**: `(s.full_data->>'col')::type` expressions for founding INSERT from JSONB.
614651
fn build_column_list_cte_query(
615652
target_oid: u32,
616653
identity_columns: &[String],

native/src/lib.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,35 @@ mod introspect;
1313
mod reader;
1414
mod sweep;
1515
mod types;
16+
mod util;
1617

1718
use types::{CachedState, DeleteMode, MergeMode, PlanRow};
1819

20+
// SAFETY ANALYSIS — Per-connection caching via thread_local!
21+
//
22+
// PostgreSQL uses a process-per-connection model (one OS process per backend,
23+
// single-threaded). Rust's thread_local! maps to per-thread storage, making it
24+
// effectively per-connection. Each backend gets independent cache instances with
25+
// no possibility of cross-connection data races.
26+
// See: PostgreSQL docs §20.3 "Resource Consumption" — max_connections creates
27+
// separate server processes; src/backend/postmaster/postmaster.c:BackendStartup().
28+
//
29+
// Memory ownership: All cached data (String, Vec, HashMap) uses Rust's system
30+
// allocator, NOT PostgreSQL's palloc. This means cached data is NOT subject to
31+
// memory context resets. Rust's ownership system guarantees deallocation on Drop.
32+
// pgrx 0.16.x does not route standard Rust collections through palloc.
33+
// See: pgrx source — palloc is only used for PgBox/PgMemoryContexts, not std types.
34+
//
35+
// Cross-transaction safety: Caches store only schema-derived metadata (column names,
36+
// SQL templates, type information). No row data, transaction IDs, snapshot info, or
37+
// visibility information is cached. Schema metadata is stable across transactions
38+
// unless DDL occurs, which is detected by source_cols_hash comparison on every call.
39+
//
40+
// SPI data extraction: Every Spi::connect() closure extracts data as Rust-owned
41+
// String/Vec/i64 values. pgrx's row.get::<String>() internally calls text_to_cstring
42+
// and creates a Rust String — the SPI memory context can be safely freed after the
43+
// closure returns without invalidating any cached data.
44+
// See: pgrx source — spi/client.rs get::<String>() → FromDatum → String::from().
1945
thread_local! {
2046
/// Multi-entry cache keyed by config cache_key (target_table + mode + columns).
2147
/// Holds one CachedState per distinct temporal_merge configuration, enabling

native/src/reader.rs

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,30 @@ use std::collections::HashMap;
44
use pgrx::prelude::*;
55

66
use crate::types::{CachedState, ColCategory, ColMapping, FilterParam, PlannerContext, SourceRow, TargetRow};
7-
7+
use crate::util::qi;
8+
9+
// Prepared statement caching — per-connection lifetime via SPI_keepplan.
10+
//
11+
// pgrx's PreparedStatement::keep() calls SPI_keepplan() (PostgreSQL src/backend/
12+
// executor/spi.c), which copies the plan from SPI's transient memory context into
13+
// CacheMemoryContext (a long-lived, top-level context). This makes plans survive:
14+
// - SPI connection close/reopen (Spi::connect scope exit)
15+
// - Transaction commit/rollback boundaries
16+
// - Multiple temporal_merge calls within one session
17+
// Plans are freed only when the backend process exits.
18+
//
19+
// Automatic replanning: When the source temp view is recreated via CREATE OR REPLACE,
20+
// PostgreSQL's relcache invalidation (src/utils/cache/relcache.c:RelationCacheInvalidate)
21+
// detects the OID change and marks cached plans as invalid. The next SPI_execute_plan()
22+
// call transparently replans. This is why we don't need manual invalidation for
23+
// source view changes.
24+
// See: PostgreSQL docs §54.4 "SPI_keepplan" and src/utils/cache/plancache.c.
25+
//
26+
// Key format: Plans are keyed by the full SQL string. Different target tables or
27+
// source views produce different SQL, so each gets its own cached plan. Stale entries
28+
// (from schema changes that produced different SQL) become unreachable HashMap entries.
29+
// The number of entries is bounded by the number of distinct (table, view) pairs used
30+
// in the session — typically 1-10 for batch ETL workloads.
831
thread_local! {
932
/// Multi-entry cache keyed by target SQL template (one per target table config).
1033
static TARGET_READ_STMTS: RefCell<HashMap<String, pgrx::spi::OwnedPreparedStatement>> = RefCell::new(HashMap::new());
@@ -349,12 +372,16 @@ pub fn read_target_rows_with_sql(
349372
for row in table {
350373
let valid_from: String = row
351374
.get::<String>(1)
352-
.unwrap_or(Some(String::new()))
353-
.unwrap_or_default();
375+
.unwrap_or(None)
376+
.unwrap_or_else(|| {
377+
pgrx::error!("sql_saga: NULL temporal boundary (valid_from) in target row");
378+
});
354379
let valid_until: String = row
355380
.get::<String>(2)
356-
.unwrap_or(Some(String::new()))
357-
.unwrap_or_default();
381+
.unwrap_or(None)
382+
.unwrap_or_else(|| {
383+
pgrx::error!("sql_saga: NULL temporal boundary (valid_until) in target row");
384+
});
358385

359386
let (identity_keys, lookup_keys, data_payload, ephemeral_payload, pk_payload) =
360387
read_target_ordinals(&row, layout);
@@ -427,12 +454,16 @@ pub fn read_source_rows_cached(
427454
.unwrap_or_default();
428455
let valid_from: String = row
429456
.get::<String>(3)
430-
.unwrap_or(Some(String::new()))
431-
.unwrap_or_default();
457+
.unwrap_or(None)
458+
.unwrap_or_else(|| {
459+
pgrx::error!("sql_saga: NULL temporal boundary (valid_from) in source row");
460+
});
432461
let valid_until: String = row
433462
.get::<String>(4)
434-
.unwrap_or(Some(String::new()))
435-
.unwrap_or_default();
463+
.unwrap_or(None)
464+
.unwrap_or_else(|| {
465+
pgrx::error!("sql_saga: NULL temporal boundary (valid_until) in source row");
466+
});
436467

437468
// Read individual columns by ordinal — no JSON parsing
438469
let (identity_keys, lookup_keys, data_payload, ephemeral_payload,
@@ -774,12 +805,16 @@ pub fn read_target_rows_parameterized(
774805
for row in table {
775806
let valid_from: String = row
776807
.get::<String>(1)
777-
.unwrap_or(Some(String::new()))
778-
.unwrap_or_default();
808+
.unwrap_or(None)
809+
.unwrap_or_else(|| {
810+
pgrx::error!("sql_saga: NULL temporal boundary (valid_from) in target row");
811+
});
779812
let valid_until: String = row
780813
.get::<String>(2)
781-
.unwrap_or(Some(String::new()))
782-
.unwrap_or_default();
814+
.unwrap_or(None)
815+
.unwrap_or_else(|| {
816+
pgrx::error!("sql_saga: NULL temporal boundary (valid_until) in target row");
817+
});
783818

784819
let (identity_keys, lookup_keys, data_payload, ephemeral_payload, pk_payload) =
785820
read_target_ordinals(&row, layout);
@@ -993,10 +1028,6 @@ fn build_target_filter(
9931028

9941029
// ── Helpers ──
9951030

996-
fn qi(name: &str) -> String {
997-
format!("\"{}\"", name.replace('"', "\"\""))
998-
}
999-
10001031
fn build_until_expr(
10011032
alias: &str,
10021033
has_range: bool,

native/src/types.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ fn parse_temporal_numeric(s: &str) -> f64 {
257257
match s {
258258
"infinity" => f64::INFINITY,
259259
"-infinity" => f64::NEG_INFINITY,
260-
_ => s.parse::<f64>().unwrap_or(0.0),
260+
_ => s.parse::<f64>().unwrap_or_else(|_| {
261+
pgrx::error!("sql_saga: cannot parse temporal numeric bound: {:?}", s);
262+
}),
261263
}
262264
}
263265

native/src/util.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/// Helper: quote identifier (double-quote, escaping inner double-quotes).
2+
pub fn qi(name: &str) -> String {
3+
format!("\"{}\"", name.replace('"', "\"\""))
4+
}

0 commit comments

Comments
 (0)