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