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    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}