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