p2panda_core/logs.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Identify append-only logs and compute their differences.
4use std::collections::BTreeMap;
5use std::hash::Hash as StdHash;
6
7use serde::{Deserialize, Serialize};
8
9use crate::traits::Author;
10
11/// Uniquely identify a single-author log.
12///
13/// The `LogId` exists purely to group a set of operations and is intended to be implemented for
14/// any type which meets the design requirements of a particular application.
15///
16/// A blanket implementation is provided for any type meeting the required trait bounds.
17///
18/// Here we briefly outline several implementation scenarios:
19///
20/// An application relying on a one-log-per-author design might choose to implement `LogId` for a
21/// thin wrapper around an Ed25519 public key; this effectively ties the log to the public key of
22/// the author. Secure Scuttlebutt (SSB) is an example of a protocol which relies on this model.
23///
24/// In an application where one author may produce operations grouped into multiple logs, `LogId`
25/// might be represented a unique number for each log instance.
26///
27/// Some applications might require semantic grouping of operations. For example, a chat
28/// application may choose to create a separate log for each author-channel pairing. In such a
29/// scenario, `LogId` might be implemented for a `struct` containing a `String` representation of
30/// the channel name.
31///
32/// Finally, please note that implementers of `LogId` must take steps to ensure their log design
33/// is fit for purpose and that all operations have been thoroughly validated before being
34/// persisted. No such validation checks are provided by `p2panda-store`.
35pub trait LogId: Clone + Eq + Ord + StdHash + Serialize + for<'de> Deserialize<'de> {}
36
37impl<T> LogId for T where T: Clone + Eq + Ord + StdHash + Serialize + for<'de> Deserialize<'de> {}
38
39/// Sequence number of an entry in an append-only log.
40///
41/// ```text
42/// [0] <- [1] <- [2]
43/// ^
44/// log height
45/// ```
46///
47/// To express the state vector of a log we can use the _log height_ (also HEAD or "frontier") which
48/// is a `SeqNum`.
49pub type SeqNum = u32;
50
51/// Author logs mapping.
52///
53/// Each log is identified with `L` and grouped by author `A`.
54///
55/// ```text
56/// Author "panda":
57/// - "trees" log
58/// - "animals" log
59///
60/// Author "icebear":
61/// - "trees" log
62/// ```
63pub type Logs<A, L> = BTreeMap<A, Vec<L>>;
64
65/// Map of [`SeqNum`] log heights grouped by author `A`. Each log is identified with `L`.
66///
67/// ```text
68/// Author "panda":
69/// - "trees" log: 6
70/// - "animals" log: 12
71///
72/// Author "icebear":
73/// - "trees" log: 4
74/// ```
75pub type LogHeights<A, L> = BTreeMap<A, BTreeMap<L, SeqNum>>;
76
77/// Map of log ranges grouped by author `A`. Each log is identified with `L`.
78///
79/// Log ranges are used to express the _difference_ between two [`LogHeights`] state vectors. Use
80/// the [`compare_logs`] method to compute it.
81///
82/// ```text
83/// Author "panda":
84/// - "trees" log: [0..6]
85/// - "animals" log: [4..12]
86///
87/// Author "icebear":
88/// - "trees" log: [3..4]
89/// ```
90pub type LogRanges<A, L> = BTreeMap<A, BTreeMap<L, (Option<SeqNum>, Option<SeqNum>)>>;
91
92/// Compare two sets of logs (local and remote) and calculate the "diff" representing ranges of
93/// entries that should be sent to the remote.
94///
95/// Local and remote states are represented by a map of authors to logs, where the logs are
96/// represented by their unique identifier and current log height. If the remote is not aware of a
97/// log, then the range containing all entries in the local log will be contained in the diff, if
98/// the remote knows of some entries in a log, then the range representing only the entries they
99/// need will be included.
100///
101/// Log ranges are represented by `(Option<u32>, Option<u32>)` tuples where the first value is an
102/// exclusive "from" sequence number and the later is an inclusive "until" sequence number. If
103/// either values are `None` that signifies that all entries from the start, or to the end, are
104/// required.
105///
106/// The returned ranges can be used in a sync protocol to then fetch entries from a store and send
107/// them to the remote. If both local and remote replicas do this then they will arrive at the
108/// same state. If pruned logs are being replicated and a range has been returned from this
109/// method, then it is expected only the remaining "frontier" will be replicated for each log.
110pub fn compare_logs<A, L>(local: &LogHeights<A, L>, remote: &LogHeights<A, L>) -> LogRanges<A, L>
111where
112 A: Author,
113 L: LogId,
114{
115 let mut remote_needs: LogRanges<A, L> = BTreeMap::default();
116
117 // Iterate over all authors.
118 for (verifying_key, local_logs) in local {
119 let Some(remote_logs) = remote.get(verifying_key) else {
120 // If the remote did not know of this author, then they need all entries in all of
121 // their logs that the local knows of.
122 let needs = local_logs
123 .iter()
124 .map(|(log_id, log_height)| (log_id.clone(), (None, Some(*log_height))))
125 .collect();
126 remote_needs.insert(verifying_key.to_owned(), needs);
127 continue;
128 };
129
130 // If the local and remote logs are equal then nothing needs to be sent.
131 if local_logs == remote_logs {
132 continue;
133 }
134
135 // Iterate over all local logs for this author.
136 for (log_id, local_log_height) in local_logs {
137 let Some(remote_log_height) = remote_logs.get(log_id) else {
138 // If the remote did not know of this log, then they need all entries from the
139 // local.
140 remote_needs
141 .entry(verifying_key.to_owned())
142 .or_default()
143 .insert(log_id.clone(), (None, Some(*local_log_height)));
144 continue;
145 };
146
147 // If the remote log height is less than the local, then include the exact range they
148 // need in the diff.
149 if remote_log_height < local_log_height {
150 remote_needs
151 .entry(verifying_key.to_owned())
152 .or_default()
153 .insert(
154 log_id.clone(),
155 (Some(*remote_log_height), Some(*local_log_height)),
156 );
157 }
158 }
159 }
160
161 remote_needs
162}
163
164#[cfg(test)]
165mod tests {
166 use std::collections::BTreeMap;
167
168 use crate::logs::compare_logs;
169
170 type Author = u8;
171
172 impl crate::traits::Author for Author {}
173
174 const ALICE: Author = 0;
175 const BOB: Author = 1;
176
177 #[test]
178 fn both_empty() {
179 let local: BTreeMap<Author, BTreeMap<u32, u32>> = BTreeMap::new();
180 let remote: BTreeMap<Author, BTreeMap<u32, u32>> = BTreeMap::new();
181 let result = compare_logs(&local, &remote);
182 assert!(result.is_empty());
183 }
184
185 #[test]
186 fn remote_empty() {
187 let mut local: BTreeMap<Author, BTreeMap<u32, u32>> = BTreeMap::new();
188 let logs = BTreeMap::from([(1, 5), (2, 10)]);
189 local.insert(ALICE, logs);
190
191 let remote: BTreeMap<Author, BTreeMap<u32, u32>> = BTreeMap::new();
192
193 let result = compare_logs(&local, &remote);
194 let needs = result.get(&ALICE).unwrap();
195
196 assert_eq!(needs.get(&1), Some(&(None, Some(5))));
197 assert_eq!(needs.get(&2), Some(&(None, Some(10))));
198 }
199
200 #[test]
201 fn remote_missing_single_log() {
202 let mut local = BTreeMap::new();
203 local.insert(ALICE, BTreeMap::from([(1, 5), (2, 10)]));
204
205 let mut remote = BTreeMap::new();
206 remote.insert(ALICE, BTreeMap::from([(1, 5)]));
207
208 let result = compare_logs(&local, &remote);
209 let needs = result.get(&ALICE).unwrap();
210
211 assert_eq!(needs.get(&2), Some(&(None, Some(10))));
212 assert!(!needs.contains_key(&1));
213 }
214
215 #[test]
216 fn remote_behind() {
217 let mut local = BTreeMap::new();
218 local.insert(ALICE, BTreeMap::from([(1, 20)]));
219
220 let mut remote = BTreeMap::new();
221 remote.insert(ALICE, BTreeMap::from([(1, 10)]));
222
223 let result = compare_logs(&local, &remote);
224 let needs = result.get(&ALICE).unwrap();
225
226 assert_eq!(needs.get(&1), Some(&(Some(10), Some(20))));
227 }
228
229 #[test]
230 fn remote_ahead() {
231 let mut local = BTreeMap::new();
232 local.insert(ALICE, BTreeMap::from([(1, 20)]));
233
234 let mut remote = BTreeMap::new();
235 remote.insert(ALICE, BTreeMap::from([(1, 30)]));
236
237 let result = compare_logs(&local, &remote);
238 assert!(result.is_empty());
239 }
240
241 #[test]
242 fn equal() {
243 let mut local = BTreeMap::new();
244 local.insert(ALICE, BTreeMap::from([(1, 20)]));
245
246 let mut remote = BTreeMap::new();
247 remote.insert(ALICE, BTreeMap::from([(1, 20)]));
248
249 let result = compare_logs(&local, &remote);
250 assert!(result.is_empty());
251 }
252
253 #[test]
254 fn remote_missing_multiple_logs() {
255 let mut local = BTreeMap::new();
256 local.insert(ALICE, BTreeMap::from([(1, 5), (2, 10), (3, 15)]));
257
258 let mut remote = BTreeMap::new();
259 remote.insert(ALICE, BTreeMap::from([(1, 5)]));
260
261 let result = compare_logs(&local, &remote);
262 let needs = result.get(&ALICE).unwrap();
263
264 assert_eq!(needs.get(&2), Some(&(None, Some(10))));
265 assert_eq!(needs.get(&3), Some(&(None, Some(15))));
266 assert!(!needs.contains_key(&1));
267 }
268
269 #[test]
270 fn remote_missing_author() {
271 let mut local = BTreeMap::new();
272 local.insert(ALICE, BTreeMap::from([(1, 5)]));
273 local.insert(BOB, BTreeMap::from([(1, 5)]));
274
275 let mut remote = BTreeMap::new();
276 remote.insert(ALICE, BTreeMap::from([(1, 5)]));
277
278 let result = compare_logs(&local, &remote);
279 let needs = result.get(&BOB).unwrap();
280
281 assert_eq!(needs.get(&1), Some(&(None, Some(5))));
282 }
283}