nodedb_physical/physical_plan/document/sum_target.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! The identity of one materialized-sum target row, as the Control Plane
4//! resolved it.
5//!
6//! A binding is `source.join_column -> target.target_column`, and one source
7//! collection may drive SEVERAL bindings. Two of them can share a join column
8//! and still point at different target collections — `entries.account_id` into
9//! both `accounts.balance` and `audit_totals.balance`, say. The join VALUE those
10//! two bindings read off a row is then identical, and it names two different
11//! rows in two different collections.
12//!
13//! So a resolution keyed by the join value alone cannot express the answer. It
14//! resolves the first binding, sees the value already present, skips the second,
15//! and every consumer that looks the value up gets the FIRST binding's target
16//! row — which the read-modify-write then writes the second binding's balance
17//! into. No error is raised on either plane: both totals are simply wrong.
18//!
19//! The key is therefore the PAIR, and every producer and every consumer keys on
20//! the pair.
21
22use nodedb_types::Surrogate;
23
24/// The `(target collection, join value)` pair one resolution entry answers for.
25///
26/// `target_collection` is the catalog name the binding carries — never
27/// db-qualified — because that is the form both planes compare bindings by.
28#[derive(
29 Debug,
30 Clone,
31 PartialEq,
32 Eq,
33 serde::Serialize,
34 serde::Deserialize,
35 zerompk::ToMessagePack,
36 zerompk::FromMessagePack,
37)]
38pub struct SumTargetKey {
39 /// TARGET collection of the binding this key belongs to.
40 pub target_collection: String,
41 /// Join-key value a source row carries for that binding's join column.
42 pub join_value: String,
43}
44
45impl SumTargetKey {
46 /// The key naming `target_collection`'s row for `join_value`.
47 pub fn new(target_collection: impl Into<String>, join_value: impl Into<String>) -> Self {
48 Self {
49 target_collection: target_collection.into(),
50 join_value: join_value.into(),
51 }
52 }
53}
54
55/// One resolved materialized-sum target: which row a
56/// `(target collection, join value)` pair names.
57///
58/// Produced on the Control Plane, where the primary-key → surrogate map lives,
59/// and consumed on the Data Plane, which never resolves anything itself.
60#[derive(
61 Debug,
62 Clone,
63 PartialEq,
64 Eq,
65 serde::Serialize,
66 serde::Deserialize,
67 zerompk::ToMessagePack,
68 zerompk::FromMessagePack,
69)]
70pub struct ResolvedSumTarget {
71 /// TARGET collection of the binding this entry was resolved for, or `None`
72 /// for an entry lifted out of a replicated record written before the target
73 /// collection travelled on the wire.
74 ///
75 /// `None` matches ANY binding of the source by join value alone — exactly
76 /// what such a record meant when it was written, and the only reading of it
77 /// that keeps a node able to replay its own committed log across the
78 /// upgrade. Nothing produces `None` at plan time.
79 pub target_collection: Option<String>,
80 /// Join-key value that names the target row.
81 pub join_value: String,
82 /// The target row's stable cross-engine identity.
83 pub surrogate: Surrogate,
84}
85
86impl ResolvedSumTarget {
87 /// The entry a binding's resolution produces.
88 pub fn new(
89 target_collection: impl Into<String>,
90 join_value: impl Into<String>,
91 surrogate: Surrogate,
92 ) -> Self {
93 Self {
94 target_collection: Some(target_collection.into()),
95 join_value: join_value.into(),
96 surrogate,
97 }
98 }
99
100 /// An entry that names no target collection — see
101 /// [`ResolvedSumTarget::target_collection`]. Only the replicated-record
102 /// decoder builds these.
103 pub fn untargeted(join_value: impl Into<String>, surrogate: Surrogate) -> Self {
104 Self {
105 target_collection: None,
106 join_value: join_value.into(),
107 surrogate,
108 }
109 }
110
111 /// Whether this entry answers for `target_collection`'s `join_value`.
112 pub fn addresses(&self, target_collection: &str, join_value: &str) -> bool {
113 self.join_value == join_value
114 && self
115 .target_collection
116 .as_deref()
117 .is_none_or(|declared| declared == target_collection)
118 }
119
120 /// Whether this entry answers for `key`.
121 pub fn matches_key(&self, key: &SumTargetKey) -> bool {
122 self.addresses(&key.target_collection, &key.join_value)
123 }
124}
125
126/// The materialized-sum resolution one committed transaction's writes to one
127/// SOURCE collection fold into their targets.
128///
129/// A transaction redo record carries source post-images only. Every replica
130/// applying it folds the source rows into their target rows, so the target
131/// identities travel with the redo, keyed by source collection.
132///
133/// The table holds only the targets the source rows fold on their own core.
134/// A target whose balance travels on an `ApplyBalanceDelta` task has no entry
135/// here. The install then skips it. The table is merged over every write of
136/// the collection, so an entry here applies to every row of the collection.
137#[derive(
138 Debug,
139 Clone,
140 PartialEq,
141 Eq,
142 serde::Serialize,
143 serde::Deserialize,
144 zerompk::ToMessagePack,
145 zerompk::FromMessagePack,
146)]
147pub struct RedoSumTargets {
148 /// SOURCE collection the transaction wrote.
149 pub collection: String,
150 /// Every target row the transaction's writes to `collection` fold into.
151 pub resolved: Vec<ResolvedSumTarget>,
152}
153
154/// The surrogate `resolved` binds `target_collection`'s `join_value` to.
155///
156/// The one lookup both planes use, so the Control Plane's "this one travels on
157/// its own task" and the Data Plane's "nobody else is applying this one" can
158/// never read the table differently.
159pub fn resolved_sum_surrogate(
160 resolved: &[ResolvedSumTarget],
161 target_collection: &str,
162 join_value: &str,
163) -> Option<Surrogate> {
164 resolved
165 .iter()
166 .find(|entry| entry.addresses(target_collection, join_value))
167 .map(|entry| entry.surrogate)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 /// Two bindings of one source that share a join column resolve to two
175 /// SEPARATE entries, and each lookup returns its own target's row.
176 #[test]
177 fn one_join_value_resolves_per_target_collection() {
178 let resolved = vec![
179 ResolvedSumTarget::new("accounts", "acc-1", Surrogate::new(500)),
180 ResolvedSumTarget::new("audit_totals", "acc-1", Surrogate::new(900)),
181 ];
182 assert_eq!(
183 resolved_sum_surrogate(&resolved, "accounts", "acc-1"),
184 Some(Surrogate::new(500))
185 );
186 assert_eq!(
187 resolved_sum_surrogate(&resolved, "audit_totals", "acc-1"),
188 Some(Surrogate::new(900))
189 );
190 }
191
192 /// A target the resolution does not cover reports nothing, even when some
193 /// OTHER target carries the same join value.
194 #[test]
195 fn a_foreign_target_is_not_answered_by_a_matching_join_value() {
196 let resolved = vec![ResolvedSumTarget::new(
197 "accounts",
198 "acc-1",
199 Surrogate::new(500),
200 )];
201 assert_eq!(
202 resolved_sum_surrogate(&resolved, "audit_totals", "acc-1"),
203 None
204 );
205 }
206
207 /// An entry decoded from a pre-widening replicated record answers for every
208 /// binding, which is what that record meant when it was written.
209 #[test]
210 fn an_untargeted_entry_answers_any_target() {
211 let resolved = vec![ResolvedSumTarget::untargeted("acc-1", Surrogate::new(500))];
212 assert_eq!(
213 resolved_sum_surrogate(&resolved, "accounts", "acc-1"),
214 Some(Surrogate::new(500))
215 );
216 assert_eq!(
217 resolved_sum_surrogate(&resolved, "audit_totals", "acc-1"),
218 Some(Surrogate::new(500))
219 );
220 }
221}