Skip to main content

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}