| 9 |
9 |
|
//! - the **committed ledger** (`sync_committed_hlc`), the HLC last applied for a
|
| 10 |
10 |
|
//! row, which the gate uses to drop a re-pulled older change.
|
| 11 |
11 |
|
//!
|
| 12 |
|
- |
//! [`resolve_pull`] is the entry point: it turns a pulled batch into the resolved
|
| 13 |
|
- |
//! `Vec<ChangeEntry>` the apply engine writes, dispatching on the schema's
|
| 14 |
|
- |
//! [`ConflictStrategy`].
|
|
12 |
+ |
//! [`resolve_pull`] is the entry point: it turns a pulled batch into the
|
|
13 |
+ |
//! [`ResolvedChanges`] the apply engine writes, dispatching on the schema's
|
|
14 |
+ |
//! [`ConflictStrategy`]. That return type is the pipeline's boundary: the apply
|
|
15 |
+ |
//! engine takes nothing else, so a batch cannot reach the database without
|
|
16 |
+ |
//! having been resolved.
|
| 15 |
17 |
|
|
| 16 |
18 |
|
use std::collections::HashMap;
|
| 17 |
19 |
|
|
| 147 |
149 |
|
}
|
| 148 |
150 |
|
|
| 149 |
151 |
|
/// Advance the committed ledger for a batch of applied entries.
|
|
152 |
+ |
///
|
|
153 |
+ |
/// Takes a plain slice rather than [`ResolvedChanges`]: the ledger also records
|
|
154 |
+ |
/// this device's own pushed edits, which never pass through the pull pipeline,
|
|
155 |
+ |
/// so requiring the resolved type here would be sealing the wrong door.
|
| 150 |
156 |
|
pub fn record_committed(conn: &Connection, entries: &[ChangeEntry]) -> Result<()> {
|
| 151 |
157 |
|
for e in entries {
|
| 152 |
158 |
|
set_committed(conn, &e.table, &e.row_id, &e.hlc)?;
|
| 156 |
162 |
|
|
| 157 |
163 |
|
// pull resolution
|
| 158 |
164 |
|
|
|
165 |
+ |
/// Changes that have been through [`resolve_pull`], and the only thing
|
|
166 |
+ |
/// [`apply_remote_changes`](super::apply::apply_remote_changes) accepts.
|
|
167 |
+ |
///
|
|
168 |
+ |
/// The point is what it makes unrepresentable. Applying a batch straight off
|
|
169 |
+ |
/// the wire, skipping conflict detection, the committed-HLC gate and the
|
|
170 |
+ |
/// collapse, used to be a plain function call that compiled; the invariant
|
|
171 |
+ |
/// lived in the order the pipeline's steps happened to be written in, and
|
|
172 |
+ |
/// nothing carried it to the consumers that depend on it. Now the only way to
|
|
173 |
+ |
/// obtain one is to run the pipeline. Same technique as
|
|
174 |
+ |
/// [`CleanChanges`](crate::conflict::CleanChanges) one layer up, and as MNW's
|
|
175 |
+ |
/// `S3DeleteAuthority`.
|
|
176 |
+ |
///
|
|
177 |
+ |
/// What it guarantees depends on the strategy that produced it, and the
|
|
178 |
+ |
/// difference is deliberate:
|
|
179 |
+ |
///
|
|
180 |
+ |
/// - [`ConflictStrategy::HybridLogicalClock`]: **exactly one entry per
|
|
181 |
+ |
/// `(table, row_id)`**, the winner under
|
|
182 |
+ |
/// [`change_order`](crate::conflict::change_order). This is the one the
|
|
183 |
+ |
/// consumers care about. Applying two entries for one row would make the
|
|
184 |
+ |
/// final value depend on apply order, and the committed ledger, which
|
|
185 |
+ |
/// advances only, would record the highest HLC of the batch rather than the
|
|
186 |
+ |
/// HLC of the value actually written, leaving the two disagreeing about what
|
|
187 |
+ |
/// this device holds.
|
|
188 |
+ |
/// - [`ConflictStrategy::ServerOrder`]: entries verbatim in server sequence
|
|
189 |
+ |
/// order, which **may hold several per row**. That is the whole meaning of
|
|
190 |
+ |
/// the strategy (last delivered wins), so it cannot be collapsed without
|
|
191 |
+ |
/// changing what an app opted into. Its consumers are safe for a different
|
|
192 |
+ |
/// reason: applying in order leaves the last one standing, and the committed
|
|
193 |
+ |
/// ledger it writes is never read back, because only the HLC strategy gates
|
|
194 |
+ |
/// against it.
|
|
195 |
+ |
#[derive(Debug, Clone)]
|
|
196 |
+ |
pub struct ResolvedChanges(Vec<ChangeEntry>);
|
|
197 |
+ |
|
|
198 |
+ |
impl ResolvedChanges {
|
|
199 |
+ |
/// The resolved entries, in apply order.
|
|
200 |
+ |
pub fn iter(&self) -> impl Iterator<Item = &ChangeEntry> {
|
|
201 |
+ |
self.0.iter()
|
|
202 |
+ |
}
|
|
203 |
+ |
|
|
204 |
+ |
/// The resolved entries as a slice, for callers that only read them.
|
|
205 |
+ |
pub fn as_slice(&self) -> &[ChangeEntry] {
|
|
206 |
+ |
&self.0
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
/// How many changes will be applied.
|
|
210 |
+ |
pub fn len(&self) -> usize {
|
|
211 |
+ |
self.0.len()
|
|
212 |
+ |
}
|
|
213 |
+ |
|
|
214 |
+ |
/// Whether the pull resolved to nothing at all, every change gated out or
|
|
215 |
+ |
/// lost its conflict.
|
|
216 |
+ |
pub fn is_empty(&self) -> bool {
|
|
217 |
+ |
self.0.is_empty()
|
|
218 |
+ |
}
|
|
219 |
+ |
|
|
220 |
+ |
/// Build one directly, for tests that exercise a consumer rather than the
|
|
221 |
+ |
/// pipeline that feeds it. Test-only on purpose: outside tests the pipeline
|
|
222 |
+ |
/// is the only constructor, which is the entire point of the type.
|
|
223 |
+ |
#[cfg(test)]
|
|
224 |
+ |
pub(crate) fn for_test(entries: Vec<ChangeEntry>) -> Self {
|
|
225 |
+ |
ResolvedChanges(entries)
|
|
226 |
+ |
}
|
|
227 |
+ |
}
|
|
228 |
+ |
|
|
229 |
+ |
impl<'a> IntoIterator for &'a ResolvedChanges {
|
|
230 |
+ |
type Item = &'a ChangeEntry;
|
|
231 |
+ |
type IntoIter = std::slice::Iter<'a, ChangeEntry>;
|
|
232 |
+ |
|
|
233 |
+ |
fn into_iter(self) -> Self::IntoIter {
|
|
234 |
+ |
self.0.iter()
|
|
235 |
+ |
}
|
|
236 |
+ |
}
|
|
237 |
+ |
|
| 159 |
238 |
|
/// Turn a pulled batch into the resolved changes to apply, per the schema's
|
| 160 |
239 |
|
/// [`ConflictStrategy`].
|
| 161 |
240 |
|
///
|
| 183 |
262 |
|
pulled: Vec<PulledChange>,
|
| 184 |
263 |
|
now: DateTime<Utc>,
|
| 185 |
264 |
|
scope: &str,
|
| 186 |
|
- |
) -> Result<Vec<ChangeEntry>> {
|
|
265 |
+ |
) -> Result<ResolvedChanges> {
|
| 187 |
266 |
|
match schema.conflict {
|
| 188 |
|
- |
ConflictStrategy::ServerOrder => Ok(pulled.into_iter().map(|p| p.entry).collect()),
|
|
267 |
+ |
ConflictStrategy::ServerOrder => Ok(ResolvedChanges(
|
|
268 |
+ |
pulled.into_iter().map(|p| p.entry).collect(),
|
|
269 |
+ |
)),
|
| 189 |
270 |
|
ConflictStrategy::HybridLogicalClock => {
|
| 190 |
271 |
|
let now_ms = now.timestamp_millis();
|
| 191 |
272 |
|
observe(conn, node, pulled.iter().map(|p| p.entry.hlc), now_ms)?;
|
| 253 |
334 |
|
tracing::warn!("could not trim the conflict stash: {e}");
|
| 254 |
335 |
|
}
|
| 255 |
336 |
|
|
| 256 |
|
- |
Ok(collapse_max_hlc(resolved))
|
|
337 |
+ |
Ok(ResolvedChanges(collapse_max_hlc(resolved)))
|
| 257 |
338 |
|
}
|
| 258 |
339 |
|
}
|
| 259 |
340 |
|
}
|
| 532 |
613 |
|
let now = Utc::now();
|
| 533 |
614 |
|
let resolved = resolve_pull(conn, s, node, pulled, now, "").unwrap();
|
| 534 |
615 |
|
apply_remote_changes(conn, s, &resolved, "").unwrap();
|
| 535 |
|
- |
record_committed(conn, &resolved).unwrap();
|
|
616 |
+ |
record_committed(conn, resolved.as_slice()).unwrap();
|
| 536 |
617 |
|
}
|
| 537 |
618 |
|
|
| 538 |
619 |
|
#[test]
|
| 666 |
747 |
|
);
|
| 667 |
748 |
|
}
|
| 668 |
749 |
|
|
|
750 |
+ |
/// The two guarantees `ResolvedChanges` documents, asserted on the same
|
|
751 |
+ |
/// input so the difference between them is the only variable. Under the HLC
|
|
752 |
+ |
/// strategy a batch carrying two changes for one row resolves to one entry;
|
|
753 |
+ |
/// under `ServerOrder` it deliberately stays two, because last-delivered-wins
|
|
754 |
+ |
/// is what an app choosing that strategy asked for.
|
|
755 |
+ |
#[test]
|
|
756 |
+ |
fn resolve_pull_collapses_a_row_under_hlc_and_does_not_under_server_order() {
|
|
757 |
+ |
let (src, sn) = device(2);
|
|
758 |
+ |
let first = local_edit_as_pulled(&src, sn, "r", "first", 100, 1);
|
|
759 |
+ |
let second = local_edit_as_pulled(&src, sn, "r", "second", 200, 2);
|
|
760 |
+ |
|
|
761 |
+ |
let (hlc_device, hn) = device(1);
|
|
762 |
+ |
let under_hlc = resolve_pull(
|
|
763 |
+ |
&hlc_device,
|
|
764 |
+ |
&schema(),
|
|
765 |
+ |
hn,
|
|
766 |
+ |
vec![first.clone(), second.clone()],
|
|
767 |
+ |
Utc::now(),
|
|
768 |
+ |
"",
|
|
769 |
+ |
)
|
|
770 |
+ |
.unwrap();
|
|
771 |
+ |
assert_eq!(
|
|
772 |
+ |
under_hlc.len(),
|
|
773 |
+ |
1,
|
|
774 |
+ |
"the HLC strategy promises one entry per row; the apply order would \
|
|
775 |
+ |
otherwise decide the value"
|
|
776 |
+ |
);
|
|
777 |
+ |
|
|
778 |
+ |
let (server_device, svn) = device(3);
|
|
779 |
+ |
let under_server_order = resolve_pull(
|
|
780 |
+ |
&server_device,
|
|
781 |
+ |
&server_order_schema(),
|
|
782 |
+ |
svn,
|
|
783 |
+ |
vec![first, second],
|
|
784 |
+ |
Utc::now(),
|
|
785 |
+ |
"",
|
|
786 |
+ |
)
|
|
787 |
+ |
.unwrap();
|
|
788 |
+ |
assert_eq!(
|
|
789 |
+ |
under_server_order.len(),
|
|
790 |
+ |
2,
|
|
791 |
+ |
"ServerOrder must not collapse: last delivered wins is the strategy"
|
|
792 |
+ |
);
|
|
793 |
+ |
}
|
|
794 |
+ |
|
| 669 |
795 |
|
// ── Conflict stash ──
|
| 670 |
796 |
|
//
|
| 671 |
797 |
|
// LWW always discards one side; these pin that the discarded bytes are kept
|
| 832 |
958 |
|
)
|
| 833 |
959 |
|
.unwrap();
|
| 834 |
960 |
|
apply_remote_changes(&mut conn, &schema(), &resolved, "").unwrap();
|
| 835 |
|
- |
record_committed(&conn, &resolved).unwrap();
|
|
961 |
+ |
record_committed(&conn, resolved.as_slice()).unwrap();
|
| 836 |
962 |
|
assert!(stash_rows(&conn).is_empty(), "nothing lost yet");
|
| 837 |
963 |
|
|
| 838 |
964 |
|
// Now pull an older change for the same row. No pending edit contests it.
|