Skip to content

Commit 169682f

Browse files
committed
feat: collect old published snapshots by reference
sqb server --snapshot-retention-count N (default 10) keeps the newest N published snapshots per location and target. An older snapshot is deleted only when no run, job, trigger, trigger change, location request or latest trigger progress fact references it; superseded progress facts, node definitions, SQL text and documents that nothing else shares go with it. Collection runs periodically in small batches and is safe to repeat.
1 parent 0220cb2 commit 169682f

21 files changed

Lines changed: 473 additions & 33 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"description": "Twelve publications with distinct SQL; a run pins the first. Collection keeps the newest ten plus the pinned one, then a one-snapshot horizon keeps only the newest and the pinned one.",
3+
"publications": 12,
4+
"keep": 10,
5+
"expected_collected": 1,
6+
"shorter_keep": 1,
7+
"expected_after_shorter": [1, 12]
8+
}

‎crates/sqlbuild-cli/src/argv/command_table.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,17 @@
11651165
"help": "days of security audit records to keep; older records are deleted once every configured audit webhook has received them and no retained run's retry needs them (default: 400)",
11661166
"group": 1
11671167
},
1168+
{
1169+
"kind": "store",
1170+
"options": [
1171+
"--snapshot-retention-count"
1172+
],
1173+
"dest": "control_snapshot_retention_count",
1174+
"default": 10,
1175+
"type": "int",
1176+
"help": "published snapshots to keep per location and target; older snapshots are deleted unless a run, trigger, job or other retained record still references them (default: 10)",
1177+
"group": 1
1178+
},
11681179
{
11691180
"kind": "store",
11701181
"options": ["--trusted-proxy-config"],

‎crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use sqlbuild_orchestration::credentials::models::{AuditRetention, CredentialScop
1010
use sqlbuild_orchestration::declarations::models::TriggerChange;
1111
use sqlbuild_orchestration::platform::models::{LocationWork, LocationWorkRequest};
1212
use sqlbuild_orchestration::runs::models::{JobRunRequest, RerunRequest, RunWindow};
13+
use sqlbuild_orchestration::snapshots::models::SnapshotRetention;
1314
use sqlbuild_orchestration::store::errors::ControlError;
1415
use sqlbuild_orchestration::warehouse_metadata::models::StatisticsRetention;
1516
use uuid::Uuid;
@@ -264,25 +265,38 @@ pub(crate) fn control_address(
264265
/// API settings are validated together before any store is opened.
265266
pub(crate) fn control_api_settings(
266267
namespace: &Namespace,
267-
) -> Result<(ApiAuthentication, (StatisticsRetention, AuditRetention)), ControlError> {
268-
let statistics = match retention_days(namespace, "statistics")? {
268+
) -> Result<
269+
(
270+
ApiAuthentication,
271+
(StatisticsRetention, AuditRetention, SnapshotRetention),
272+
),
273+
ControlError,
274+
> {
275+
let statistics = match retention_setting(namespace, "statistics-retention-days")? {
269276
Some(days) => StatisticsRetention::days(days)?,
270277
None => StatisticsRetention::default(),
271278
};
272-
let audit = match retention_days(namespace, "audit")? {
279+
let audit = match retention_setting(namespace, "audit-retention-days")? {
273280
Some(days) => AuditRetention::days(days)?,
274281
None => AuditRetention::default(),
275282
};
276-
Ok((control_authentication(namespace)?, (statistics, audit)))
283+
let snapshots = match retention_setting(namespace, "snapshot-retention-count")? {
284+
Some(count) => SnapshotRetention::count(count)?,
285+
None => SnapshotRetention::default(),
286+
};
287+
Ok((
288+
control_authentication(namespace)?,
289+
(statistics, audit, snapshots),
290+
))
277291
}
278292

279293
/// Zero and out-of-range values name the flag before any type-specific check runs.
280-
fn retention_days(namespace: &Namespace, kind: &str) -> Result<Option<u16>, ControlError> {
281-
match namespace.get(&format!("control_{kind}_retention_days")) {
294+
fn retention_setting(namespace: &Namespace, flag: &str) -> Result<Option<u16>, ControlError> {
295+
match namespace.get(&format!("control_{}", flag.replace('-', "_"))) {
282296
Some(Value::Int(value)) => match value.parse::<u16>() {
283-
Ok(days) if days > 0 => Ok(Some(days)),
297+
Ok(setting) if setting > 0 => Ok(Some(setting)),
284298
_ => Err(ControlError::Invalid(format!(
285-
"--{kind}-retention-days must be between 1 and 65535"
299+
"--{flag} must be between 1 and 65535"
286300
))),
287301
},
288302
_ => Ok(None),

‎crates/sqlbuild-cli/src/native_commands/main/_run_control.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub(crate) async fn run_control(
2727
standalone: bool,
2828
) -> Result<(), ControlError> {
2929
let address = control_address(namespace, standalone)?;
30-
let (authentication, (statistics, audit)) = control_api_settings(namespace)?;
30+
let (authentication, (statistics, audit, snapshots)) = control_api_settings(namespace)?;
3131
let store = open_control_store(namespace).await?;
3232
let (workers, images) = open_control_workers(namespace)?;
3333
let (stop, shutdown) = watch::channel(false);
@@ -43,7 +43,7 @@ pub(crate) async fn run_control(
4343
tasks.spawn(serve_api(
4444
store.clone(),
4545
listener,
46-
(authentication, images, statistics),
46+
(authentication, images, (statistics, snapshots)),
4747
(
4848
move || {
4949
drop(write_output(&[(

‎crates/sqlbuild-orchestration/src/api/main/serve_api.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use crate::api::_helpers::authentication::{bearer::local_request, configure::con
77
use crate::api::_helpers::resources::{changes, router::router};
88
use crate::api::main::openapi_document::openapi_document;
99
use crate::api::models::{ApiAuthentication, ChangeFeed, DevelopmentScope, DockerImageSource};
10+
use crate::snapshots::main::run_snapshot_retention::run_snapshot_retention;
11+
use crate::snapshots::models::SnapshotRetention;
1012
use crate::store::errors::ControlError;
1113
use crate::store::main::migrate_store::migrate_store;
1214
use crate::store::main::subscribe_changes::subscribe_changes;
@@ -17,10 +19,10 @@ use crate::warehouse_metadata::models::StatisticsRetention;
1719
pub async fn serve_api(
1820
store: ControlStore,
1921
listener: tokio::net::TcpListener,
20-
(authentication, images, retention): (
22+
(authentication, images, (retention, snapshots)): (
2123
ApiAuthentication,
2224
Vec<DockerImageSource>,
23-
StatisticsRetention,
25+
(StatisticsRetention, SnapshotRetention),
2426
),
2527
(ready, shutdown): (
2628
impl FnOnce() + Send + 'static,
@@ -60,6 +62,7 @@ pub async fn serve_api(
6062
result = async { server.await } => { result?; Ok(()) },
6163
result = changes::relay(store.clone(), listener_changes, sender) => result,
6264
result = run_statistics_retention(&store, retention) => result,
65+
result = run_snapshot_retention(&store, snapshots) => result,
6366
}
6467
}
6568

‎crates/sqlbuild-orchestration/src/snapshots/_helpers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pub(crate) mod content;
22
pub(crate) mod preparation;
33
pub(crate) mod publication;
44
pub(crate) mod reading;
5+
pub(crate) mod retention;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! Snapshot expiry is by reference: anything a retained row points at stays, and content follows.
2+
3+
use uuid::Uuid;
4+
5+
use crate::snapshots::constants::{CONTENT_HASH_CHUNK, SNAPSHOT_RETENTION_BATCH};
6+
use crate::snapshots::models::SnapshotRetention;
7+
use crate::store::errors::ControlError;
8+
use crate::store::main::{begin::begin, commit::commit, query::query};
9+
use crate::store::models::{ControlStore, ControlTransaction, Parameter, TransactionMode};
10+
11+
/// Beyond the newest generations, any retained reference keeps a snapshot; superseded progress goes.
12+
const EXPIRED_SNAPSHOTS: &str = "SELECT s.snapshot_id FROM sqb_snapshots s \
13+
WHERE (SELECT COUNT(*) FROM sqb_snapshots newer WHERE newer.location_id=s.location_id \
14+
AND newer.target=s.target AND newer.generation>s.generation)>=$1 \
15+
AND NOT EXISTS (SELECT 1 FROM sqb_current_snapshots c WHERE c.snapshot_id=s.snapshot_id) \
16+
AND NOT EXISTS (SELECT 1 FROM sqb_runs r WHERE r.snapshot_id=s.snapshot_id) \
17+
AND NOT EXISTS (SELECT 1 FROM sqb_jobs j WHERE j.snapshot_id=s.snapshot_id) \
18+
AND NOT EXISTS (SELECT 1 FROM sqb_triggers t WHERE t.snapshot_id=s.snapshot_id \
19+
OR t.removed_in_snapshot_id=s.snapshot_id) \
20+
AND NOT EXISTS (SELECT 1 FROM sqb_trigger_state_events e WHERE e.snapshot_id=s.snapshot_id) \
21+
AND NOT EXISTS (SELECT 1 FROM sqb_location_work w WHERE w.snapshot_id=s.snapshot_id) \
22+
AND NOT EXISTS (SELECT 1 FROM sqb_trigger_progress p WHERE p.snapshot_id=s.snapshot_id \
23+
AND NOT EXISTS (SELECT 1 FROM sqb_trigger_progress newer WHERE newer.trigger_id=p.trigger_id \
24+
AND newer.storage_id>p.storage_id)) \
25+
ORDER BY s.published_at, s.snapshot_id LIMIT $2";
26+
27+
/// Content-addressed bodies of the expiring snapshots, removed once nothing else references them.
28+
const CONTENT: [((&str, &str), &str, &str); 3] = [
29+
(
30+
("sqb_snapshot_nodes", "node_hash"),
31+
"sqb_node_definitions",
32+
"",
33+
),
34+
(
35+
("sqb_snapshot_sources", "text_hash"),
36+
"sqb_source_texts",
37+
" AND NOT EXISTS (SELECT 1 FROM sqb_warehouse_fingerprints f \
38+
WHERE f.definition_text_hash=sqb_source_texts.text_hash)",
39+
),
40+
(
41+
("sqb_snapshot_documents", "document_hash"),
42+
"sqb_documents",
43+
"",
44+
),
45+
];
46+
47+
/// Batches commit independently; a repeated or interrupted pass finds only what is still expired.
48+
pub(crate) async fn prune_snapshots(
49+
store: &ControlStore,
50+
retention: SnapshotRetention,
51+
) -> Result<usize, ControlError> {
52+
let mut deleted = 0;
53+
loop {
54+
let transaction = begin(store, TransactionMode::Write).await?;
55+
let (transaction, rows) = query(
56+
transaction,
57+
EXPIRED_SNAPSHOTS,
58+
vec![
59+
Parameter::Integer(retention.kept()),
60+
Parameter::Integer(SNAPSHOT_RETENTION_BATCH as i64),
61+
],
62+
)
63+
.await?;
64+
let mut expired: Vec<Uuid> = Vec::with_capacity(rows.len());
65+
for row in &rows {
66+
expired.push(
67+
serde_json::from_value(row["snapshot_id"].clone())
68+
.map_err(|error| ControlError::Schema(error.to_string()))?,
69+
);
70+
}
71+
let transaction = delete_snapshots(transaction, &expired).await?;
72+
commit(transaction).await?;
73+
deleted += expired.len();
74+
if expired.len() < SNAPSHOT_RETENTION_BATCH {
75+
return Ok(deleted);
76+
}
77+
}
78+
}
79+
80+
async fn delete_snapshots(
81+
mut transaction: ControlTransaction,
82+
expired: &[Uuid],
83+
) -> Result<ControlTransaction, ControlError> {
84+
if expired.is_empty() {
85+
return Ok(transaction);
86+
}
87+
let placeholders = (1..=expired.len())
88+
.map(|index| format!("${index}"))
89+
.collect::<Vec<_>>()
90+
.join(",");
91+
let ids: Vec<Parameter> = expired.iter().copied().map(Parameter::Id).collect();
92+
let mut hashes: Vec<Vec<String>> = Vec::with_capacity(CONTENT.len());
93+
for ((table, column), _, _) in CONTENT {
94+
let rows;
95+
(transaction, rows) = query(
96+
transaction,
97+
&format!("SELECT DISTINCT {column} FROM {table} WHERE snapshot_id IN ({placeholders})"),
98+
ids.clone(),
99+
)
100+
.await?;
101+
hashes.push(
102+
rows.iter()
103+
.filter_map(|row| row[column].as_str().map(str::to_owned))
104+
.collect(),
105+
);
106+
}
107+
for table in [
108+
"sqb_trigger_progress",
109+
"sqb_snapshot_sources",
110+
"sqb_snapshot_nodes",
111+
"sqb_snapshot_documents",
112+
"sqb_snapshot_changes",
113+
"sqb_snapshots",
114+
] {
115+
(transaction, _) = query(
116+
transaction,
117+
&format!("DELETE FROM {table} WHERE snapshot_id IN ({placeholders})"),
118+
ids.clone(),
119+
)
120+
.await?;
121+
}
122+
for (((table, column), content, elsewhere), hashes) in CONTENT.iter().zip(hashes) {
123+
for chunk in hashes.chunks(CONTENT_HASH_CHUNK) {
124+
let placeholders = (1..=chunk.len())
125+
.map(|index| format!("${index}"))
126+
.collect::<Vec<_>>()
127+
.join(",");
128+
(transaction, _) = query(
129+
transaction,
130+
&format!(
131+
"DELETE FROM {content} WHERE {column} IN ({placeholders}) AND NOT EXISTS \
132+
(SELECT 1 FROM {table} n WHERE n.{column}={content}.{column}){elsewhere}"
133+
),
134+
chunk.iter().cloned().map(Parameter::Text).collect(),
135+
)
136+
.await?;
137+
}
138+
}
139+
Ok(transaction)
140+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Snapshot retention keeps recent publications per location and target, collected in small batches.
2+
3+
pub(crate) const DEFAULT_SNAPSHOT_RETENTION_COUNT: u16 = 10;
4+
pub(crate) const SNAPSHOT_RETENTION_BATCH: usize = 10;
5+
pub(crate) const CONTENT_HASH_CHUNK: usize = 500;

‎crates/sqlbuild-orchestration/src/snapshots/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ pub mod read_person_node_sql;
1111
pub(crate) mod read_snapshot_documents;
1212
pub(crate) mod read_snapshot_nodes;
1313
pub(crate) mod rebuild_projections;
14+
pub mod run_snapshot_retention;
1415
pub mod verify_execution;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! Collect unreferenced old snapshots periodically; every pass is safe to repeat.
2+
3+
use std::time::Duration;
4+
5+
use crate::snapshots::_helpers::retention::prune_snapshots;
6+
use crate::snapshots::models::SnapshotRetention;
7+
use crate::store::errors::ControlError;
8+
use crate::store::models::ControlStore;
9+
10+
pub async fn run_snapshot_retention(
11+
store: &ControlStore,
12+
retention: SnapshotRetention,
13+
) -> Result<(), ControlError> {
14+
let mut interval = tokio::time::interval(Duration::from_secs(60));
15+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
16+
loop {
17+
interval.tick().await;
18+
match prune_snapshots(store, retention).await {
19+
Ok(_) => {}
20+
Err(ControlError::Database(error)) if ControlError::transient_database(&error) => {}
21+
Err(error) => return Err(error),
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)