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_as::<A>(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.events.push(crate::RaisedEvent {
133 name,
134 payload: change,
135 });
136 }
137 if let Some(name) = self.events.pruned {
138 let pruned = serde_json::json!({"id": meta.id(), "version": meta.version() + 1});
139 plan.events.push(crate::RaisedEvent {
140 name,
141 payload: pruned,
142 });
143 }
144 #[cfg(feature = "cache")]
145 plan.announcements.changed(crate::ChangedRecord {
146 module: A::MODULE,
147 aggregate: A::NAME,
148 id: Some(record.meta().id()),
149 });
150 #[cfg(feature = "live")]
151 plan.announcements
152 .live_change(crate::TableChange::of::<A>(record.owner(), plan.reach()));
153 }
154 Ok(())
155 }
156}
157
158fn delete_loaded<A: Aggregate>(records: &[A], dialect: &dyn Dialect) -> Result<Step> {
159 let table = crate::aggregate::table::<A>().qualified(dialect);
160 let (id, version) = (dialect.quote("id"), dialect.quote("version"));
161 let expected = u64::try_from(records.len())
162 .map_err(|error| Error::internal("counting pruned records", error))?;
163 if dialect.binds_arrays() {
164 let sql = format!(
166 "DELETE FROM {table} WHERE ({id}, {version}) IN \
167 (SELECT * FROM unnest({}::uuid[], {}::bigint[]))",
168 dialect.placeholder(1),
169 dialect.placeholder(2)
170 );
171 let ids = records
172 .iter()
173 .map(|record| SqlValue::Uuid(record.meta().id()))
174 .collect();
175 let versions = records
176 .iter()
177 .map(|record| SqlValue::Int(record.meta().version()))
178 .collect();
179 let params = vec![SqlValue::Array(ids), SqlValue::Array(versions)];
180 return Ok(Step::statement(sql, params, Some(expected)));
181 }
182 let mut params = Vec::with_capacity(records.len() * 2);
183 let mut matches = Vec::with_capacity(records.len());
184 for record in records {
185 let meta = record.meta();
186 params.extend([SqlValue::Uuid(meta.id()), SqlValue::Int(meta.version())]);
187 matches.push(format!(
188 "({id} = {} AND {version} = {})",
189 dialect.placeholder(params.len() - 1),
190 dialect.placeholder(params.len())
191 ));
192 }
193 let sql = format!("DELETE FROM {table} WHERE {}", matches.join(" OR "));
194 Ok(Step::statement(sql, params, Some(expected)))
195}