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}