mz_persist/postgres.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Implementation of [Consensus] backed by Postgres.
11
12use std::fmt::Formatter;
13use std::str::FromStr;
14use std::sync::Arc;
15use std::time::Duration;
16
17use anyhow::anyhow;
18use async_stream::try_stream;
19use async_trait::async_trait;
20use bytes::Bytes;
21use deadpool_postgres::PoolError;
22use deadpool_postgres::tokio_postgres::Config;
23use deadpool_postgres::tokio_postgres::types::{FromSql, IsNull, ToSql, Type, to_sql_checked};
24use futures_util::StreamExt;
25use mz_dyncfg::ConfigSet;
26use mz_ore::cast::CastFrom;
27use mz_ore::metrics::MetricsRegistry;
28use mz_ore::url::SensitiveUrl;
29use mz_postgres_client::metrics::PostgresClientMetrics;
30use mz_postgres_client::{
31 Connection, IsolationLevel, PostgresClient, PostgresClientConfig, PostgresClientKnobs,
32};
33use postgres_protocol::escape::escape_identifier;
34use tokio_postgres::error::SqlState;
35use tokio_postgres::{Row, Statement};
36use tracing::{info, warn};
37
38use crate::error::Error;
39use crate::location::{CaSResult, Consensus, ExternalError, ResultStream, SeqNo, VersionedData};
40
41/// Flag to run PostgreSQL consensus connections under READ COMMITTED isolation instead of SERIALIZABLE.
42///
43/// The query family used on vanilla Postgres is designed to be linearizable under READ COMMITTED
44/// isolation, and therefore is also linearizable under SERIALIZABLE, so this flag can be flipped
45/// freely. The flag only exists to make the upgrade path from older versions safe since multiple
46/// query versions co-exist during a 0dt upgrade. See the note on the default value for more
47/// information.
48///
49/// This flag should stay off on CockroachDB. The system will refuse to issue consensus queries if
50/// the flag is enabled on CockroachDB.
51pub const PG_CONSENSUS_READ_COMMITTED: mz_dyncfg::Config<bool> = mz_dyncfg::Config::new(
52 "persist_pg_consensus_read_committed",
53 // NOTE: The default value of this flag *MUST* remain *false* for as long as there are users
54 // running Materialize versions <=v26.32. Environments running one of these versions will be
55 // issuing consensus queries that are only correct in SERIALIZABLE isolation. Therefore this
56 // flag must default to off so that during the 0dt deployment that takes an environment from
57 // <=v26.32 to >=v26.33 the consensus queries of both families linearize together.
58 false,
59 "Run consensus connections under READ COMMITTED isolation instead of SERIALIZABLE when targetting
60 PostgreSQL backends. This flag must be off when targetting CockroachDB.",
61 mz_dyncfg::ParameterScope::Environment,
62);
63
64const SCHEMA: &str = "
65CREATE TABLE IF NOT EXISTS consensus (
66 shard text NOT NULL,
67 sequence_number bigint NOT NULL,
68 data bytea NOT NULL,
69 PRIMARY KEY(shard, sequence_number)
70)
71";
72
73// These `sql_stats_automatic_collection_enabled` are for the cost-based
74// optimizer but all the queries against this table are single-table and very
75// carefully tuned to hit the primary index, so the cost-based optimizer doesn't
76// really get us anything. OTOH, the background jobs that crdb creates to
77// collect these stats fill up the jobs table (slowing down all sorts of
78// things).
79const CRDB_SCHEMA_OPTIONS: &str = "WITH (sql_stats_automatic_collection_enabled = false)";
80// The `consensus` table creates and deletes rows at a high frequency, generating many
81// tombstoned rows. If Cockroach's GC interval is set high (the default is 25h) and
82// these tombstones accumulate, scanning over the table will take increasingly and
83// prohibitively long.
84//
85// See: https://github.com/MaterializeInc/database-issues/issues/4001
86// See: https://www.cockroachlabs.com/docs/stable/configure-zone.html#variables
87const CRDB_CONFIGURE_ZONE: &str = "ALTER TABLE consensus CONFIGURE ZONE USING gc.ttlseconds = 600";
88
89/// NOTE: `mz-persist` intentionally does not depend on `mz-postgres-util`.
90/// These helpers are the only direct driver-call boundary in this module.
91async fn pg_batch_execute(client: &Connection, query: &str) -> Result<(), tokio_postgres::Error> {
92 #[allow(clippy::disallowed_methods)]
93 client.batch_execute(query).await
94}
95
96async fn pg_query_prepared(
97 client: &Connection,
98 statement: &Statement,
99 params: &[&(dyn ToSql + Sync)],
100) -> Result<Vec<Row>, tokio_postgres::Error> {
101 #[allow(clippy::disallowed_methods)]
102 client.query(statement, params).await
103}
104
105async fn pg_query_opt_prepared(
106 client: &Connection,
107 statement: &Statement,
108 params: &[&(dyn ToSql + Sync)],
109) -> Result<Option<Row>, tokio_postgres::Error> {
110 #[allow(clippy::disallowed_methods)]
111 client.query_opt(statement, params).await
112}
113
114async fn pg_execute_prepared(
115 client: &Connection,
116 statement: &Statement,
117 params: &[&(dyn ToSql + Sync)],
118) -> Result<u64, tokio_postgres::Error> {
119 #[allow(clippy::disallowed_methods)]
120 client.execute(statement, params).await
121}
122
123async fn pg_txn_execute_prepared(
124 txn: &deadpool_postgres::Transaction<'_>,
125 statement: &Statement,
126 params: &[&(dyn ToSql + Sync)],
127) -> Result<u64, tokio_postgres::Error> {
128 #[allow(clippy::disallowed_methods)]
129 txn.execute(statement, params).await
130}
131
132async fn pg_txn_query_one_prepared(
133 txn: &deadpool_postgres::Transaction<'_>,
134 statement: &Statement,
135 params: &[&(dyn ToSql + Sync)],
136) -> Result<Row, tokio_postgres::Error> {
137 #[allow(clippy::disallowed_methods)]
138 txn.query_one(statement, params).await
139}
140
141impl ToSql for SeqNo {
142 fn to_sql(
143 &self,
144 ty: &Type,
145 w: &mut bytes::BytesMut,
146 ) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
147 // We can only represent sequence numbers in the range [0, i64::MAX].
148 let value = i64::try_from(self.0)?;
149 <i64 as ToSql>::to_sql(&value, ty, w)
150 }
151
152 fn accepts(ty: &Type) -> bool {
153 <i64 as ToSql>::accepts(ty)
154 }
155
156 to_sql_checked!();
157}
158
159impl<'a> FromSql<'a> for SeqNo {
160 fn from_sql(
161 ty: &Type,
162 raw: &'a [u8],
163 ) -> Result<SeqNo, Box<dyn std::error::Error + Sync + Send>> {
164 let sequence_number = <i64 as FromSql>::from_sql(ty, raw)?;
165
166 // Sanity check that the sequence number we received falls in the
167 // [0, i64::MAX] range.
168 let sequence_number = u64::try_from(sequence_number)?;
169 Ok(SeqNo(sequence_number))
170 }
171
172 fn accepts(ty: &Type) -> bool {
173 <i64 as FromSql>::accepts(ty)
174 }
175}
176
177/// Configuration to connect to a Postgres backed implementation of [Consensus].
178#[derive(Clone, Debug)]
179pub struct PostgresConsensusConfig {
180 url: SensitiveUrl,
181 knobs: Arc<dyn PostgresClientKnobs>,
182 metrics: PostgresClientMetrics,
183 dyncfg: Arc<ConfigSet>,
184}
185
186impl PostgresConsensusConfig {
187 const EXTERNAL_TESTS_POSTGRES_URL: &'static str =
188 "MZ_PERSIST_EXTERNAL_STORAGE_TEST_POSTGRES_URL";
189
190 /// Returns a new [PostgresConsensusConfig] for use in production.
191 pub fn new(
192 url: &SensitiveUrl,
193 knobs: Box<dyn PostgresClientKnobs>,
194 metrics: PostgresClientMetrics,
195 dyncfg: Arc<ConfigSet>,
196 ) -> Result<Self, Error> {
197 Ok(PostgresConsensusConfig {
198 url: url.clone(),
199 knobs: Arc::from(knobs),
200 metrics,
201 dyncfg,
202 })
203 }
204
205 /// Returns a new [PostgresConsensusConfig] for use in unit tests.
206 ///
207 /// By default, persist tests that use external storage (like Postgres) are
208 /// no-ops so that `cargo test` works on new environments without any
209 /// configuration. To activate the tests for [PostgresConsensus] set the
210 /// `MZ_PERSIST_EXTERNAL_STORAGE_TEST_POSTGRES_URL` environment variable
211 /// with a valid connection url [1].
212 ///
213 /// [1]: https://docs.rs/tokio-postgres/latest/tokio_postgres/config/struct.Config.html#url
214 pub fn new_for_test() -> Result<Option<Self>, Error> {
215 let url = match std::env::var(Self::EXTERNAL_TESTS_POSTGRES_URL) {
216 Ok(url) => SensitiveUrl::from_str(&url).map_err(|e| e.to_string())?,
217 Err(_) => {
218 if mz_ore::env::is_var_truthy("CI") {
219 panic!("CI is supposed to run this test but something has gone wrong!");
220 }
221 return Ok(None);
222 }
223 };
224
225 struct TestConsensusKnobs;
226 impl std::fmt::Debug for TestConsensusKnobs {
227 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("TestConsensusKnobs").finish_non_exhaustive()
229 }
230 }
231 impl PostgresClientKnobs for TestConsensusKnobs {
232 fn connection_pool_max_size(&self) -> usize {
233 2
234 }
235
236 fn connection_pool_max_wait(&self) -> Option<Duration> {
237 Some(Duration::from_secs(1))
238 }
239
240 fn connection_pool_ttl(&self) -> Duration {
241 Duration::MAX
242 }
243 fn connection_pool_ttl_stagger(&self) -> Duration {
244 Duration::MAX
245 }
246 fn connect_timeout(&self) -> Duration {
247 Duration::MAX
248 }
249 fn tcp_user_timeout(&self) -> Duration {
250 Duration::ZERO
251 }
252
253 fn keepalives_idle(&self) -> Duration {
254 Duration::from_secs(10)
255 }
256
257 fn keepalives_interval(&self) -> Duration {
258 Duration::from_secs(5)
259 }
260
261 fn keepalives_retries(&self) -> u32 {
262 5
263 }
264
265 fn statement_timeout(&self) -> Duration {
266 Duration::ZERO
267 }
268 }
269
270 let dyncfg = ConfigSet::default().add(&PG_CONSENSUS_READ_COMMITTED);
271 let config = PostgresConsensusConfig::new(
272 &url,
273 Box::new(TestConsensusKnobs),
274 PostgresClientMetrics::new(&MetricsRegistry::new(), "mz_persist"),
275 Arc::new(dyncfg),
276 )?;
277 Ok(Some(config))
278 }
279}
280
281/// What flavor of Postgres are we connected to for consensus.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum PostgresMode {
284 /// CockroachDB, used in our cloud offering.
285 CockroachDB,
286 /// Vanilla Postgres, the default for our self-hosted offering.
287 Postgres,
288}
289
290/// Implementation of [Consensus] over a Postgres database.
291pub struct PostgresConsensus {
292 postgres_client: PostgresClient,
293 mode: PostgresMode,
294}
295
296impl std::fmt::Debug for PostgresConsensus {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 f.debug_struct("PostgresConsensus").finish_non_exhaustive()
299 }
300}
301
302impl PostgresConsensus {
303 /// Open a Postgres [Consensus] instance with `config`, for the collection
304 /// named `shard`.
305 pub async fn open(config: PostgresConsensusConfig) -> Result<Self, ExternalError> {
306 // don't need to unredact here because we just want to pull out the username
307 let pg_config: Config = config.url.to_string().parse()?;
308 let role = pg_config.get_user().expect("failed to get PostgreSQL user");
309 let create_schema = format!(
310 "CREATE SCHEMA IF NOT EXISTS consensus AUTHORIZATION {}",
311 escape_identifier(role),
312 );
313
314 let dyncfg = Arc::clone(&config.dyncfg);
315
316 // The resolver runs per connection, so a flag change takes effect as the pool cycles
317 // connections. It unconditionally follows the flag: it does not know the backend. The
318 // backend-specific safety check lives in `get_connection`, which asserts that CockroachDB
319 // connections are SERIALIZABLE. The connection carries the level it was created with, so
320 // that assertion is exact rather than a guess about what the resolver returned.
321 let client_config = PostgresClientConfig::new(config.url, config.knobs, config.metrics)
322 .with_isolation(Arc::new(move || {
323 if PG_CONSENSUS_READ_COMMITTED.get(&dyncfg) {
324 IsolationLevel::ReadCommitted
325 } else {
326 IsolationLevel::Serializable
327 }
328 }));
329 let postgres_client = PostgresClient::open(client_config)?;
330
331 let client = postgres_client.get_connection().await?;
332
333 let mode = match pg_batch_execute(
334 &client,
335 &format!(
336 "{}; {}{}; {};",
337 create_schema, SCHEMA, CRDB_SCHEMA_OPTIONS, CRDB_CONFIGURE_ZONE,
338 ),
339 )
340 .await
341 {
342 Ok(()) => PostgresMode::CockroachDB,
343 Err(e) if e.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE) => {
344 warn!(
345 "unable to ALTER TABLE consensus, this is expected and OK when connecting with a read-only user"
346 );
347 PostgresMode::CockroachDB
348 }
349 // Vanilla Postgres doesn't support the Cockroach zone configuration
350 // that we attempted, so we use that to determine what mode we're in.
351 Err(e)
352 if e.code() == Some(&SqlState::INVALID_PARAMETER_VALUE)
353 || e.code() == Some(&SqlState::SYNTAX_ERROR) =>
354 {
355 info!(
356 "unable to initiate consensus with CRDB params, this is expected and OK when running against Postgres: {:?}",
357 e
358 );
359 PostgresMode::Postgres
360 }
361 Err(e) => return Err(e.into()),
362 };
363
364 match mode {
365 PostgresMode::CockroachDB => {}
366 PostgresMode::Postgres => {
367 pg_batch_execute(&client, &format!("{}; {};", create_schema, SCHEMA)).await?;
368 }
369 }
370
371 Ok(PostgresConsensus {
372 postgres_client,
373 mode,
374 })
375 }
376
377 /// Drops and recreates the `consensus` table in Postgres
378 ///
379 /// ONLY FOR TESTING
380 pub async fn drop_and_recreate(&self) -> Result<(), ExternalError> {
381 // this could be a TRUNCATE if we're confident the db won't reuse any state
382 let client = self.get_connection().await?;
383 pg_batch_execute(&client, "DROP TABLE consensus").await?;
384 let crdb_mode = match pg_batch_execute(
385 &client,
386 &format!("{}{}; {}", SCHEMA, CRDB_SCHEMA_OPTIONS, CRDB_CONFIGURE_ZONE,),
387 )
388 .await
389 {
390 Ok(()) => true,
391 Err(e) if e.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE) => {
392 warn!(
393 "unable to ALTER TABLE consensus, this is expected and OK when connecting with a read-only user"
394 );
395 true
396 }
397 Err(e)
398 if e.code() == Some(&SqlState::INVALID_PARAMETER_VALUE)
399 || e.code() == Some(&SqlState::SYNTAX_ERROR) =>
400 {
401 info!(
402 "unable to initiate consensus with CRDB params, this is expected and OK when running against Postgres: {:?}",
403 e
404 );
405 false
406 }
407 Err(e) => return Err(e.into()),
408 };
409
410 if !crdb_mode {
411 pg_batch_execute(&client, SCHEMA).await?;
412 }
413 Ok(())
414 }
415
416 async fn get_connection(&self) -> Result<Connection, PoolError> {
417 let conn = self.postgres_client.get_connection().await?;
418 // On CockroachDB we always run the lockless `CRDB_*` queries, which are only linearizable
419 // under SERIALIZABLE. The connection records the level it was created with, so we assert on
420 // that exact level rather than trusting the isolation flag to be off. The `POSTGRES_*`
421 // queries used on vanilla Postgres are correct under any isolation, so that backend needs
422 // no check.
423 if self.mode == PostgresMode::CockroachDB {
424 assert_eq!(
425 conn.isolation_level(),
426 IsolationLevel::Serializable,
427 "consensus on CockroachDB requires SERIALIZABLE isolation, refusing to run \
428 CRDB_* queries under {:?}",
429 conn.isolation_level(),
430 );
431 }
432 Ok(conn)
433 }
434}
435
436#[async_trait]
437impl Consensus for PostgresConsensus {
438 fn list_keys(&self) -> ResultStream<'_, String> {
439 let q = "SELECT DISTINCT shard FROM consensus";
440
441 Box::pin(try_stream! {
442 // NB: it's important that we hang on to this client for the lifetime of the stream,
443 // to avoid returning it to the pool prematurely.
444 let client = self.get_connection().await?;
445 let statement = client.prepare_cached(q).await?;
446 let params: &[String] = &[];
447 let mut rows = Box::pin(client.query_raw(&statement, params).await?);
448 while let Some(row) = rows.next().await {
449 let shard: String = row?.try_get("shard")?;
450 yield shard;
451 }
452 })
453 }
454
455 async fn head(&self, key: &str) -> Result<Option<VersionedData>, ExternalError> {
456 let q = "SELECT sequence_number, data FROM consensus
457 WHERE shard = $1 ORDER BY sequence_number DESC LIMIT 1";
458 let row = {
459 let client = self.get_connection().await?;
460 let statement = client.prepare_cached(q).await?;
461 pg_query_opt_prepared(&client, &statement, &[&key]).await?
462 };
463 let row = match row {
464 None => return Ok(None),
465 Some(row) => row,
466 };
467
468 let seqno: SeqNo = row.try_get("sequence_number")?;
469
470 let data: Vec<u8> = row.try_get("data")?;
471 Ok(Some(VersionedData {
472 seqno,
473 data: Bytes::from(data),
474 }))
475 }
476
477 async fn compare_and_set(
478 &self,
479 key: &str,
480 new: VersionedData,
481 ) -> Result<CaSResult, ExternalError> {
482 let expected = new.seqno.previous();
483
484 let result = match expected {
485 Some(expected) => {
486 /// This query has been written to execute within a single
487 /// network round-trip. The insert performance has been tuned
488 /// against CockroachDB, ensuring it goes through the fast-path
489 /// 1-phase commit of CRDB. Any changes to this query should
490 /// confirm an EXPLAIN ANALYZE (VERBOSE) query plan contains
491 /// `auto commit`
492 static CRDB_CAS_QUERY: &str = "
493 INSERT INTO consensus (shard, sequence_number, data)
494 SELECT $1, $2, $3
495 WHERE (SELECT sequence_number FROM consensus
496 WHERE shard = $1
497 ORDER BY sequence_number DESC LIMIT 1) = $4;
498 ";
499
500 // ## Correctness argument
501 //
502 // The Postgres tuned queries are designed to be correct under READ COMMITTED
503 // isolation. In that mode each operation sees its own snapshot of the database and
504 // special care is needed to ensure that the observable behavior is linearizable.
505 //
506 // The whole argument rests on one invariant: the live sequence numbers `>= 0` form
507 // a contiguous range with no gaps, whose maximum is the head. Appends only ever
508 // extend the head by one and truncation only ever removes a prefix, preserving
509 // contiguity. (The `-1` sentinel written at init sits below all appends and never
510 // participates in any test here, so we ignore it.) The cases below rely on the
511 // following equivalence:
512 //
513 // `seqno` is the head iff `seqno` is present and `seqno+1` is absent.
514 //
515 // A client performs CaS operations at a seqno one above the seqno it has already
516 // observed, unless it is initializing the shard for the first time, in which case it
517 // does a plain insert. The two scenarios are analyzed separately:
518 //
519 // 1. CaS for `expected_seqno+1` issued after `expected_seqno` was observed
520 //
521 // Because `expected_seqno` was observed, the consensus table must have contained a
522 // row with it at some point, the `expected_row`.
523 //
524 // The first operation of the CaS query is to find `expected_row` and lock it with
525 // a `FOR KEY SHARE` lock. The `expected_row` may or may not still exist, depending on
526 // how outdated the client is. If `expected_seqno` is the current head the row is
527 // guaranteed to exist, since truncation never deletes the head. This gives two cases:
528 //
529 // 1.1. `expected_row` exists
530 //
531 // The INSERT is the linearization point of this CaS, and it commits a row iff
532 // `expected_seqno` is the head at the instant the INSERT runs. The held lock keeps
533 // `expected_seqno` present at that instant (see 1.1.3). By contiguity it is then
534 // the head iff `expected_seqno+1` is absent. This is exactly what the PRIMARY KEY
535 // tests as the INSERT tries to write `expected_seqno+1` (`$2`). So the INSERT
536 // writes one row and the call returns Committed (advancing the head to
537 // `expected_seqno+1`, the range still contiguous) precisely when `expected_seqno`
538 // was the head; otherwise the PK raises `unique_violation` and the call returns
539 // ExpectationMismatch with the table unchanged. The remaining obligation is that
540 // operations interleaving between the lock and the INSERT cannot break this. There
541 // are three cases:
542 //
543 // 1.1.1 Another CaS has taken its own `expected_row_2` lock but not inserted yet.
544 //
545 // `FOR KEY SHARE` is shared, so the two locks neither block each other nor wait:
546 // locks never serialize appenders, the PRIMARY KEY does. Having only locked, the
547 // other CaS has not changed the table, so it does not affect what this INSERT
548 // observes. If it is racing for the same head (`expected_row_2 == expected_row`) both
549 // appenders attempt to INSERT the same `expected_seqno+1`, and the PK admits exactly
550 // one. Whichever INSERT commits first is linearized first and becomes the head; the
551 // other then finds `expected_seqno+1` present and is rejected — case 1.1.2 from its
552 // side.
553 //
554 // 1.1.2 Another CaS has performed its `expected_seqno+1` insertion.
555 //
556 // By contiguity an append always targets head+1, so the only insertion that can
557 // affect this one is `expected_seqno+1` itself: nothing above it can be added while
558 // `expected_seqno+1` is still absent. If such an insert commits first, then
559 // `expected_seqno` is no longer the head, and this INSERT now finds `expected_seqno+1`
560 // present and is rejected by the PK → ExpectationMismatch. That is correct: this CaS
561 // is linearized after the other appender, and at that point it must fail.
562 //
563 // 1.1.3 A truncation has happened.
564 //
565 // A truncation deletes a prefix `[0, cut)` and never the head. Its DELETE takes a
566 // `FOR UPDATE`-strength row lock on each row it removes, and that conflicts with the
567 // `FOR KEY SHARE` held on `expected_seqno`. So no committed truncation can have
568 // removed `expected_seqno` while that lock is held: any truncation that commits in
569 // this window has `cut <= expected_seqno` and deletes only rows strictly below
570 // `expected_seqno`. That leaves `expected_seqno` present and the presence/absence of
571 // `expected_seqno+1` untouched, so it does not change the INSERT's outcome, and
572 // removing a low prefix keeps the range contiguous. This is also why, once locked,
573 // `expected_row` is still present at INSERT time, closing the "lock found a row, then
574 // it was GC'd before the INSERT" hole.
575 //
576 // 1.2. `expected_row` does not exist
577 //
578 // The CTE is empty, the INSERT touches zero rows, and the call returns
579 // ExpectationMismatch without modifying the table. This is correct: `expected_seqno`
580 // was observed, so it was present once, and the only way a present seqno later
581 // becomes absent is truncation (inserts never delete). A truncation that removed
582 // `expected_seqno` advanced the head strictly above it (it removes a prefix and keeps
583 // the head), so `expected_seqno` is not the head and the CaS must fail. This operation
584 // linearizes at its locking SELECT, where `expected_seqno` is already gone.
585 //
586 // 2. CaS that initializes the shard, issued with `expected` = None
587 //
588 // The init path (see the `None` arm) inserts a `-1` sentinel and the first row at
589 // seqno 0, then commits only if `max(sequence_number)` is 0. It commits (head
590 // becomes 0) exactly when the shard was empty: a shard that still holds seqno 0
591 // fails the insert's PK, and a shard with a head above 0 either fails the PK (if
592 // seqno 0 is present) or, if seqno 0 was truncated away, inserts into the gap and
593 // is caught by the `max > 0` check and rolled back. This is correct because an
594 // initialized shard always retains a live row (truncation never removes the head).
595 // Concurrent first-time inits serialize on the seqno-0 PK lock, so exactly one wins.
596 static POSTGRES_CAS_QUERY: &str = "
597 WITH expected_row AS (
598 SELECT sequence_number FROM consensus
599 WHERE shard = $1 AND sequence_number = $4
600 FOR KEY SHARE
601 )
602 INSERT INTO consensus (shard, sequence_number, data)
603 SELECT $1, $2, $3
604 FROM expected_row;
605 ";
606
607 let q = match self.mode {
608 PostgresMode::CockroachDB => CRDB_CAS_QUERY,
609 PostgresMode::Postgres => POSTGRES_CAS_QUERY,
610 };
611 let client = self.get_connection().await?;
612 let statement = client.prepare_cached(q).await?;
613 pg_execute_prepared(
614 &client,
615 &statement,
616 &[&key, &new.seqno, &new.data.as_ref(), &expected],
617 )
618 .await
619 }
620 None => {
621 match self.mode {
622 PostgresMode::Postgres => {
623 // SUBTLE: This query is designed to be linearizable with respect to:
624 // * the other POSTGRES queries (cas/truncate) present in this file under
625 // READ COMMITTED isolation
626 // * the CRDB and POSTGRES queries existed in Materialize versions <=v26.30
627 // that run under SERIALIZABLE isolation
628 // * the POSTGRES queries that were introduced in
629 // d6dff42fd69d1a87edc4ae99e7ea1364201830dc, exist in versions v26.31 and
630 // v26.32, and run under READ COMMITTED isolation
631 //
632 // # Correctness with respect to current version
633 //
634 // The POSTGRES queries in the current version are designed to be
635 // linearizable under READ COMMITTED, therefore the correctness argument is
636 // identical under SERIALIZABLE which is a strictly stronger isolation
637 // level.
638 //
639 // ## Concurrent initialization
640 //
641 // The first query to insert seqno 0 into the shard will take the primary
642 // key lock for that row. Any concurrent clients that attempt to also
643 // insert seqno 0 will have their INSERT statement block on the PK lock
644 // until the first query commits, forcing them to seriale after the winning
645 // query commits at which point they will receive a PK violation error and
646 // only the first client will win.
647 //
648 // ## Stale initlization
649 //
650 // If stale client attempts to insert seqno 0 in an
651 // already initialized shard then:
652 // * if seqno 0 has not been truncated the insert fails with a PK violation
653 // * if seqno 0 has been truncated the insert succeeds but the subsequent
654 // read of the max seqno will return the current head and the tx will rollback.
655 //
656 // Note that the sentinel seqno of -1 does not participate and is not
657 // needed for this correctness argument. It only exists to cover the
658 // co-existence with v26.31 queries (see below).
659 //
660 // # Correctness with respect to v26.30 (CRDB/POSTGRES family,
661 // SERIALIZABLE) and v26.31/v26.32 (CRDB family, SERIALIZABLE)
662 //
663 // This query linearizes correctly with queries from v26.30 only in
664 // SERIALIZABLE mode. This is why the `persist_pg_consensus_read_committed`
665 // must default to off (see note in flag definition) so that during the
666 // co-existence period of 0dt upgrades both systems participate in the
667 // SERIALIZABLE conflict resolution.
668 //
669 // The correctness argument is that in the absence of concurrent mutations
670 // (i.e in SERIALIZABLE) this query only succeeds if the shard is
671 // uninitialized. Therefore only one client will succeed.
672 //
673 // # Correctness with respect to v26.31/v26.32 (POSTGRES family, READ COMMITTED)
674 //
675 // Versions v26.31 and v26.32 run in READ COMMITTED isolation and
676 // initialize a shard by blindly inserting seqno -1 and seqno 0 into the
677 // shard. By never truncating seqno -1 it ensures that stale initialiations
678 // hit PK violations on seqno -1 even though seqno 0 has been removed.
679 //
680 // For this reason this version must also insert the sentinel seqno -1 in
681 // as part of its initialization and avoid truncating it during truncation.
682 // Without this treatment a stale shard initialization from a version
683 // v26.31 client against a shard that had been initialized, written to, and
684 // truncated by a v26.33 client would succeed, and the shard state would be
685 // corrupted since the seqnos would not be contiguous anymore.
686 //
687 // When versions v26.31/v26.32 have become old enough that we believe no
688 // one will run them again we can remove the sentinel seqno handling from
689 // the initialization and truncation queries to simplify them.
690 static POSTGRES_INIT_INSERT: &str =
691 "INSERT INTO consensus (shard, sequence_number, data)
692 VALUES ($1, -1, ''), ($1, $2, $3)";
693 static POSTGRES_INIT_MAX: &str =
694 "SELECT max(sequence_number) FROM consensus WHERE shard = $1";
695 let mut client = self.get_connection().await?;
696 let txn = client.transaction().await?;
697 let insert = txn.prepare_cached(POSTGRES_INIT_INSERT).await?;
698 match pg_txn_execute_prepared(
699 &txn,
700 &insert,
701 &[&key, &new.seqno, &new.data.as_ref()],
702 )
703 .await
704 {
705 Ok(_) => {
706 let max_stmt = txn.prepare_cached(POSTGRES_INIT_MAX).await?;
707 let row =
708 pg_txn_query_one_prepared(&txn, &max_stmt, &[&key]).await?;
709 let max: SeqNo = row.try_get(0)?;
710 if max == SeqNo::minimum() {
711 txn.commit().await?;
712 Ok(1)
713 } else {
714 txn.rollback().await?;
715 Ok(0)
716 }
717 }
718 // The insert failed (e.g. `unique_violation` because seqno 0 already
719 // exists). Roll back and let the caller map the error below.
720 Err(e) => {
721 let _ = txn.rollback().await;
722 Err(e)
723 }
724 }
725 }
726 PostgresMode::CockroachDB => {
727 static CRDB_INIT_QUERY: &str =
728 "INSERT INTO consensus SELECT $1, $2, $3 WHERE
729 NOT EXISTS (
730 SELECT * FROM consensus WHERE shard = $1
731 )";
732 let client = self.get_connection().await?;
733 let statement = client.prepare_cached(CRDB_INIT_QUERY).await?;
734 pg_execute_prepared(
735 &client,
736 &statement,
737 &[&key, &new.seqno, &new.data.as_ref()],
738 )
739 .await
740 }
741 }
742 }
743 };
744
745 match result {
746 Ok(n) if n >= 1 => Ok(CaSResult::Committed),
747 Ok(_) => Ok(CaSResult::ExpectationMismatch),
748 Err(e) if e.code() == Some(&SqlState::UNIQUE_VIOLATION) => {
749 Ok(CaSResult::ExpectationMismatch)
750 }
751 Err(e) => Err(e.into()),
752 }
753 }
754
755 async fn scan(
756 &self,
757 key: &str,
758 from: SeqNo,
759 limit: usize,
760 ) -> Result<Vec<VersionedData>, ExternalError> {
761 let q = "SELECT sequence_number, data FROM consensus
762 WHERE shard = $1 AND sequence_number >= $2
763 ORDER BY sequence_number ASC LIMIT $3";
764 let Ok(limit) = i64::try_from(limit) else {
765 return Err(ExternalError::from(anyhow!(
766 "limit must be [0, i64::MAX]. was: {:?}",
767 limit
768 )));
769 };
770 let rows = {
771 let client = self.get_connection().await?;
772 let statement = client.prepare_cached(q).await?;
773 pg_query_prepared(&client, &statement, &[&key, &from, &limit]).await?
774 };
775 let mut results = Vec::with_capacity(rows.len());
776
777 for row in rows {
778 let seqno: SeqNo = row.try_get("sequence_number")?;
779 let data: Vec<u8> = row.try_get("data")?;
780 results.push(VersionedData {
781 seqno,
782 data: Bytes::from(data),
783 });
784 }
785 Ok(results)
786 }
787
788 async fn truncate(&self, key: &str, seqno: SeqNo) -> Result<Option<usize>, ExternalError> {
789 // The `sequence_number >= 0` clause preserves the seqno `-1` sentinel that the
790 // initialization from v26.31/v26.32 clients writes. The sentinel is a truncation-proof
791 // "already initialized" marker (relied on by v26.31/v26.32 and preserved for it during a
792 // rolling deploy). The clause is a no-op for shards that have no sentinel, since all of
793 // their seqnos are already >= 0.
794 static TRUNCATE_QUERY: &str = "
795 DELETE FROM consensus
796 WHERE shard = $1 AND sequence_number >= 0 AND sequence_number < $2 AND
797 EXISTS (
798 SELECT * FROM consensus WHERE shard = $1 AND sequence_number >= $2
799 )
800 ";
801
802 let result = {
803 let client = self.get_connection().await?;
804 let statement = client.prepare_cached(TRUNCATE_QUERY).await?;
805 pg_execute_prepared(&client, &statement, &[&key, &seqno]).await?
806 };
807 if result == 0 {
808 // We weren't able to successfully truncate any rows inspect head to
809 // determine whether the request was valid and there were no records in
810 // the provided range, or the request was invalid because it would have
811 // also deleted head.
812
813 // It's safe to call head in a subsequent transaction rather than doing
814 // so directly in the same transaction because, once a given (seqno, data)
815 // pair exists for our shard, we enforce the invariants that
816 // 1. Our shard will always have _some_ data mapped to it.
817 // 2. All operations that modify the (seqno, data) can only increase
818 // the sequence number.
819 let current = self.head(key).await?;
820 if current.map_or(true, |data| data.seqno < seqno) {
821 return Err(ExternalError::from(anyhow!(
822 "upper bound too high for truncate: {:?}",
823 seqno
824 )));
825 }
826 }
827
828 Ok(Some(usize::cast_from(result)))
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use mz_dyncfg::ConfigUpdates;
835 use mz_ore::assert_err;
836 use tracing::info;
837 use uuid::Uuid;
838
839 use crate::location::tests::consensus_impl_test;
840
841 use super::*;
842
843 #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
844 #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_client_method` on OS `linux`
845 async fn postgres_consensus() -> Result<(), ExternalError> {
846 let config = match PostgresConsensusConfig::new_for_test()? {
847 Some(config) => config,
848 None => {
849 info!(
850 "{} env not set: skipping test that uses external service",
851 PostgresConsensusConfig::EXTERNAL_TESTS_POSTGRES_URL
852 );
853 return Ok(());
854 }
855 };
856
857 consensus_impl_test(|| PostgresConsensus::open(config.clone())).await?;
858
859 // On a Postgres backend the default run above already exercises the tuned queries under
860 // SERIALIZABLE. Re-run the contract with READ COMMITTED to also cover that isolation (on a
861 // CockroachDB backend the flag is a no-op and this just re-runs the default queries).
862 // `consensus_impl_test` asserts on `list_keys`, so it needs a clean table: the previous run
863 // leaves shards behind, so drop and recreate the table before running it again.
864 let read_committed_config =
865 PostgresConsensusConfig::new_for_test()?.expect("postgres url was set above");
866 {
867 let mut updates = ConfigUpdates::default();
868 updates.add(&PG_CONSENSUS_READ_COMMITTED, true);
869 updates.apply(&read_committed_config.dyncfg);
870 }
871 PostgresConsensus::open(read_committed_config.clone())
872 .await?
873 .drop_and_recreate()
874 .await?;
875 consensus_impl_test(|| PostgresConsensus::open(read_committed_config.clone())).await?;
876
877 // and now verify the implementation-specific `drop_and_recreate` works as intended
878 let consensus = PostgresConsensus::open(config.clone()).await?;
879 let key = Uuid::new_v4().to_string();
880 let state = VersionedData {
881 seqno: SeqNo(0),
882 data: Bytes::from("abc"),
883 };
884
885 assert_eq!(
886 consensus.compare_and_set(&key, state.clone()).await,
887 Ok(CaSResult::Committed),
888 );
889
890 assert_eq!(consensus.head(&key).await, Ok(Some(state.clone())));
891
892 consensus.drop_and_recreate().await?;
893
894 assert_eq!(consensus.head(&key).await, Ok(None));
895
896 // This should be a separate postgres_consensus_blocking test, but nextest makes it
897 // difficult since we can't specify that both tests touch the consensus table and thus
898 // interfere with each other.
899 let config = match PostgresConsensusConfig::new_for_test()? {
900 Some(config) => config,
901 None => {
902 info!(
903 "{} env not set: skipping test that uses external service",
904 PostgresConsensusConfig::EXTERNAL_TESTS_POSTGRES_URL
905 );
906 return Ok(());
907 }
908 };
909
910 let consensus: PostgresConsensus = PostgresConsensus::open(config.clone()).await?;
911 // Max size in test is 2... let's saturate the pool.
912 let _conn1 = consensus.get_connection().await?;
913 let _conn2 = consensus.get_connection().await?;
914
915 // And finally, we should see the next connect time out.
916 let conn3 = consensus.get_connection().await;
917
918 assert_err!(conn3);
919
920 Ok(())
921 }
922}