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