1use sofie_core::{Error, Ordered, Result, SqlValue};
2
3use crate::heard::Listeners;
4use crate::worker::JobContext;
5use crate::{Aggregate, Column, Context, Db, Dialect, Outcome, Plan, Resolve, Select, Step};
6
7const BATCH: u64 = 1000;
8
9pub struct Prune {
11 db: Db,
12}
13
14impl Resolve<JobContext> for Prune {
15 fn resolve(context: &mut JobContext) -> impl Future<Output = Result<Self>> + Send {
16 std::future::ready(Ok(Self {
17 db: context.db().clone(),
18 }))
19 }
20}
21
22impl Prune {
23 #[cfg(feature = "product-reports")]
24 pub(crate) fn db(&self) -> &Db {
25 &self.db
26 }
27
28 pub async fn before<A: Aggregate, T: Ordered + Clone + Send>(
30 &self,
31 column: Column<A, T>,
32 before: T,
33 ) -> Result<u64> {
34 #[cfg(feature = "children")]
35 if !A::CHILDREN.is_empty() {
36 return Err(Error::internal_message(format!(
37 "`{}` has children, which pruning would leave behind; delete its records with \
38 their children in a job of the module's own",
39 A::NAME
40 )));
41 }
42 #[cfg(feature = "search-engine")]
43 let index = crate::search::index_of(&self.db, A::MODULE, A::NAME);
44 let heard = self.db.arrivals().heard().known().unwrap_or_default();
45 let mut deleted = 0;
46 loop {
47 let mut query = A::query()
48 .where_(column.lt(before.clone()))
49 .order_by(column.asc())
50 .limit(BATCH);
51 query.criteria.with_hidden = true;
52 let records = query.all(&self.db.read()).await?;
53 let batch = u64::try_from(records.len())
54 .map_err(|error| Error::internal("counting pruned records", error))?;
55 let events = match records.first() {
56 Some(first) => heard_events(first, &heard.listeners)?,
57 None => Events::default(),
58 };
59 self.db
60 .commit(&mut Pruned {
61 records,
62 events,
63 #[cfg(feature = "search-engine")]
64 index: index.clone(),
65 })
66 .await?;
67 deleted += batch;
68 if batch < BATCH {
69 return Ok(deleted);
70 }
71 }
72 }
73}
74
75#[cfg_attr(
76 not(feature = "contracts"),
77 expect(unused_variables, clippy::unnecessary_wraps)
78)]
79fn heard_events<A: Aggregate>(first: &A, heard: &Listeners) -> Result<Events> {
80 Ok(Events {
81 #[cfg(feature = "contracts")]
82 change: change_event(first, heard)?,
83 pruned: A::PRUNED.filter(|name| heard.hear(name)),
84 })
85}
86
87#[cfg(feature = "contracts")]
88fn change_event<A: Aggregate>(first: &A, heard: &Listeners) -> Result<Option<&'static str>> {
89 match A::CHANGED {
90 Some(name) if heard.hear_pruned(name) => {
91 Ok(crate::change_event::changes(first)?.then_some(name))
92 }
93 _ => Ok(None),
94 }
95}
96
97#[derive(Default)]
98struct Events {
99 #[cfg(feature = "contracts")]
100 change: Option<&'static str>,
101 pruned: Option<&'static str>,
102}
103
104struct Pruned<A> {
105 records: Vec<A>,
106 events: Events,
107 #[cfg(feature = "search-engine")]
108 index: Option<crate::SearchIndex>,
109}
110
111impl<A: Aggregate> Outcome for Pruned<A> {
113 fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
114 if self.records.is_empty() {
115 return Ok(());
116 }
117 plan.push(delete_loaded::<A>(&self.records, plan.dialect())?);
118 #[cfg(feature = "search-engine")]
119 if let Some(index) = &self.index {
120 let ids: Vec<sofie_core::Uuid> = self
121 .records
122 .iter()
123 .map(|record| record.meta().id())
124 .collect();
125 crate::search::queue_forget(index, &ids, plan)?;
126 }
127 for record in &self.records {
128 let meta = record.meta();
129 #[cfg(feature = "contracts")]
130 if let Some(name) = self.events.change {
131 let change = crate::change_event::pruned_change(meta);
132 plan.raise_one(name, change);
133 }
134 if let Some(name) = self.events.pruned {
135 let pruned = serde_json::json!({"id": meta.id(), "version": meta.version() + 1});
136 plan.raise_one(name, pruned);
137 }
138 #[cfg(feature = "cache")]
139 plan.changed(crate::ChangedRecord {
140 module: A::MODULE,
141 aggregate: A::NAME,
142 id: Some(record.meta().id()),
143 });
144 #[cfg(feature = "live")]
145 plan.live_change(crate::TableChange::of::<A>(record.owner(), plan.reach()));
146 }
147 Ok(())
148 }
149}
150
151fn delete_loaded<A: Aggregate>(records: &[A], dialect: &dyn Dialect) -> Result<Step> {
152 let table = crate::aggregate::table::<A>().qualified(dialect);
153 let (id, version) = (dialect.quote("id"), dialect.quote("version"));
154 let expected = u64::try_from(records.len())
155 .map_err(|error| Error::internal("counting pruned records", error))?;
156 if dialect.binds_arrays() {
157 let sql = format!(
159 "DELETE FROM {table} WHERE ({id}, {version}) IN \
160 (SELECT * FROM unnest({}::uuid[], {}::bigint[]))",
161 dialect.placeholder(1),
162 dialect.placeholder(2)
163 );
164 let ids = records
165 .iter()
166 .map(|record| SqlValue::Uuid(record.meta().id()))
167 .collect();
168 let versions = records
169 .iter()
170 .map(|record| SqlValue::Int(record.meta().version()))
171 .collect();
172 let params = vec![SqlValue::Array(ids), SqlValue::Array(versions)];
173 return Ok(Step::statement(sql, params, Some(expected)));
174 }
175 let mut params = Vec::with_capacity(records.len() * 2);
176 let mut matches = Vec::with_capacity(records.len());
177 for record in records {
178 let meta = record.meta();
179 params.extend([SqlValue::Uuid(meta.id()), SqlValue::Int(meta.version())]);
180 matches.push(format!(
181 "({id} = {} AND {version} = {})",
182 dialect.placeholder(params.len() - 1),
183 dialect.placeholder(params.len())
184 ));
185 }
186 let sql = format!("DELETE FROM {table} WHERE {}", matches.join(" OR "));
187 Ok(Step::statement(sql, params, Some(expected)))
188}