mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
+1








14fba21e57
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
183 lines
6.0 KiB
Rust
183 lines
6.0 KiB
Rust
//! Monthly partition manager for `events` and `delivery_log`.
|
|
//!
|
|
//! Call `ensure_future_partitions` on startup and monthly via cron.
|
|
|
|
use chrono::{Datelike, TimeZone, Utc};
|
|
use sqlx::{PgPool, Row};
|
|
use tracing::info;
|
|
|
|
use crate::error::{DbError, Result};
|
|
|
|
/// Tables that may be partition-managed. Allowlist prevents DDL injection.
|
|
const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"];
|
|
|
|
/// Ensures monthly partition tables exist for the next `months_ahead` months.
|
|
pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Result<()> {
|
|
let now = Utc::now();
|
|
|
|
for i in 0..=(months_ahead as i32) {
|
|
let year = now.year();
|
|
let month = now.month() as i32 + i;
|
|
let (target_year, target_month) = if month > 12 {
|
|
(year + (month - 1) / 12, ((month - 1) % 12 + 1) as u32)
|
|
} else {
|
|
(year, month as u32)
|
|
};
|
|
|
|
let (end_year, end_month) = if target_month == 12 {
|
|
(target_year + 1, 1u32)
|
|
} else {
|
|
(target_year, target_month + 1)
|
|
};
|
|
|
|
let start = Utc
|
|
.with_ymd_and_hms(target_year, target_month, 1, 0, 0, 0)
|
|
.single()
|
|
.ok_or_else(|| {
|
|
DbError::InvalidData(format!("invalid date: {target_year}-{target_month:02}-01"))
|
|
})?;
|
|
let end = Utc
|
|
.with_ymd_and_hms(end_year, end_month, 1, 0, 0, 0)
|
|
.single()
|
|
.ok_or_else(|| {
|
|
DbError::InvalidData(format!("invalid date: {end_year}-{end_month:02}-01"))
|
|
})?;
|
|
|
|
let suffix = format!("{:04}_{:02}", target_year, target_month);
|
|
let start_str = start.format("%Y-%m-%d").to_string();
|
|
let end_str = end.format("%Y-%m-%d").to_string();
|
|
|
|
for table in PARTITIONED_TABLES {
|
|
ensure_partition(pool, table, &start_str, &end_str, &suffix).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that a partition suffix is digits and underscores only.
|
|
fn validate_partition_suffix(suffix: &str) -> bool {
|
|
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit() || c == '_')
|
|
}
|
|
|
|
/// Validate that a date string matches YYYY-MM-DD format.
|
|
fn validate_date_str(s: &str) -> bool {
|
|
let bytes = s.as_bytes();
|
|
bytes.len() == 10
|
|
&& bytes[4] == b'-'
|
|
&& bytes[7] == b'-'
|
|
&& bytes[..4].iter().all(|b| b.is_ascii_digit())
|
|
&& bytes[5..7].iter().all(|b| b.is_ascii_digit())
|
|
&& bytes[8..].iter().all(|b| b.is_ascii_digit())
|
|
}
|
|
|
|
async fn ensure_partition(
|
|
pool: &PgPool,
|
|
table_name: &str,
|
|
start_date_str: &str,
|
|
end_date_str: &str,
|
|
suffix: &str,
|
|
) -> Result<()> {
|
|
// Allowlist check -- parameterized queries cannot be used for DDL identifiers.
|
|
if !PARTITIONED_TABLES.contains(&table_name) {
|
|
return Err(DbError::InvalidData(format!(
|
|
"table not in partition allowlist: {table_name:?}"
|
|
)));
|
|
}
|
|
if !validate_partition_suffix(suffix) {
|
|
return Err(DbError::InvalidData(format!(
|
|
"partition suffix contains invalid characters: {suffix:?}"
|
|
)));
|
|
}
|
|
if !validate_date_str(start_date_str) {
|
|
return Err(DbError::InvalidData(format!(
|
|
"start_date_str is not YYYY-MM-DD: {start_date_str:?}"
|
|
)));
|
|
}
|
|
if !validate_date_str(end_date_str) {
|
|
return Err(DbError::InvalidData(format!(
|
|
"end_date_str is not YYYY-MM-DD: {end_date_str:?}"
|
|
)));
|
|
}
|
|
|
|
let partition_name = format!("{table_name}_p{suffix}");
|
|
|
|
let row = sqlx::query(
|
|
r#"
|
|
SELECT COUNT(*) as cnt
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = current_schema()
|
|
AND c.relname = $1
|
|
AND c.relispartition = true
|
|
"#,
|
|
)
|
|
.bind(&partition_name)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
|
|
let cnt: i64 = row.try_get("cnt")?;
|
|
if cnt > 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
// DDL identifiers cannot be parameterized -- all inputs are validated above.
|
|
let sql = format!(
|
|
"CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name} \
|
|
FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')"
|
|
);
|
|
|
|
match sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
|
|
Ok(_) => {
|
|
info!("added partition {partition_name}");
|
|
Ok(())
|
|
}
|
|
Err(sqlx::Error::Database(db_err))
|
|
if db_err.code().as_deref() == Some("42P17")
|
|
&& db_err.message().contains("would overlap partition") =>
|
|
{
|
|
// Fresh schemas include a right-edge catch-all partition (`*_p_future`).
|
|
// If it already covers this month, the table is still safe for writes;
|
|
// treat the overlap as "ensured" rather than failing startup.
|
|
info!(
|
|
partition_name,
|
|
"partition range already covered by an existing partition"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn suffix_validation() {
|
|
assert!(validate_partition_suffix("2026_03"));
|
|
assert!(validate_partition_suffix("9999_12"));
|
|
assert!(!validate_partition_suffix(""));
|
|
assert!(!validate_partition_suffix("2026-03"));
|
|
assert!(!validate_partition_suffix("2026_03; DROP TABLE events--"));
|
|
}
|
|
|
|
#[test]
|
|
fn date_str_validation() {
|
|
assert!(validate_date_str("2026-03-01"));
|
|
assert!(validate_date_str("9999-12-31"));
|
|
assert!(!validate_date_str("2026-3-01"));
|
|
assert!(!validate_date_str("2026/03/01"));
|
|
assert!(!validate_date_str("20260301"));
|
|
assert!(!validate_date_str("2026-03-01; DROP TABLE events--"));
|
|
}
|
|
|
|
#[test]
|
|
fn table_allowlist() {
|
|
assert!(PARTITIONED_TABLES.contains(&"events"));
|
|
assert!(PARTITIONED_TABLES.contains(&"delivery_log"));
|
|
assert!(!PARTITIONED_TABLES.contains(&"api_tokens"));
|
|
assert!(!PARTITIONED_TABLES.contains(&"users"));
|
|
}
|
|
}
|