Skip to main content

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}