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}