9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
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"));
|
|
}
|
|
}
|