kmp_adapter_embedded/adapter/
consolidation.rs1use super::{
2 engine::{Key, ReadTx, Table},
3 serdes::{decode, encode},
4 store::EmbeddedKernelStore,
5};
6use kmp_domain::{
7 PortError,
8 consolidation::{
9 ConsolidatedView, ConsolidationFuture, ConsolidationRead, ConsolidationReadStatus,
10 ConsolidationSource, ConsolidationStore, ConsolidationWrite, consolidate,
11 },
12};
13use sha2::{Digest, Sha256};
14
15fn authored_at(now: std::time::SystemTime) -> Result<String, PortError> {
16 let elapsed = now
17 .duration_since(std::time::UNIX_EPOCH)
18 .map_err(|e| PortError::InvalidState(e.to_string()))?;
19 let seconds = i64::try_from(elapsed.as_secs())
20 .map_err(|_| PortError::InvalidState("clock out of range".into()))?;
21 let whole = kmp_domain::rfc3339_from_epoch_seconds(seconds);
22 let precise = format!(
25 "{}.{:09}Z",
26 whole.trim_end_matches('Z'),
27 elapsed.subsec_nanos()
28 );
29 kmp_domain::temporal_instant_rfc3339(&precise)
30 .ok_or_else(|| PortError::InvalidState("clock out of range".into()))
31}
32
33fn identity(about: &str, view: &str) -> Result<String, PortError> {
34 if about.trim().is_empty() || view.trim().is_empty() || about.len() > 512 || view.len() > 512 {
35 return Err(PortError::InvalidState(
36 "about and view require 1..512 bytes".into(),
37 ));
38 }
39 Ok(format!(
40 "{:x}",
41 Sha256::digest(encode("view identity", &(about, view))?)
42 ))
43}
44
45fn head(tx: &dyn ReadTx, id: &str) -> Result<u64, PortError> {
46 tx.get(Table::ConsolidationHeads, Key::Str(id))?
47 .map(|raw| decode("view head", &raw))
48 .transpose()
49 .map(|v| v.unwrap_or(0))
50}
51
52fn revision(tx: &dyn ReadTx, id: &str, rev: u64) -> Result<Option<ConsolidatedView>, PortError> {
53 tx.get(Table::ConsolidationViews, Key::Str2(id, &rev.to_string()))?
54 .map(|raw| decode("view revision", &raw))
55 .transpose()
56}
57
58impl ConsolidationStore for EmbeddedKernelStore {
59 fn consolidation_sources(
60 &self,
61 about: String,
62 refs: Vec<String>,
63 ) -> ConsolidationFuture<'_, Vec<ConsolidationSource>> {
64 Box::pin(async move {
65 self.run(move |store| {
66 let tx = store.begin_read()?;
67 super::consolidation_source::capture(tx.as_ref(), &about, &refs)
68 })
69 .await
70 })
71 }
72
73 fn write_consolidation(
74 &self,
75 command: ConsolidationWrite,
76 ) -> ConsolidationFuture<'_, ConsolidatedView> {
77 Box::pin(async move {
78 self.run(move |store| {
79 let id = identity(&command.about, &command.view)?;
80 let bytes = encode("consolidation command", &command)?;
81 if bytes.len() > 2_097_152 {
82 return Err(PortError::InvalidState(
83 "consolidation command exceeds 2 MiB".into(),
84 ));
85 }
86 let digest = format!("{:x}", Sha256::digest(bytes));
87 let mut tx = store.begin_write()?;
88 if let Some(raw) = tx.get(
91 Table::ConsolidationReceipts,
92 Key::Str2(&id, &command.idempotency_key),
93 )? {
94 let (stored_digest, rev): (String, u64) = decode("view receipt", &raw)?;
95 if digest != stored_digest {
96 return Err(PortError::Conflict(
97 "idempotency key reused for a different consolidation".into(),
98 ));
99 }
100 return revision(tx.as_ref(), &id, rev)?.ok_or_else(|| {
101 PortError::InvalidState("view receipt lacks revision".into())
102 });
103 }
104 let actual = head(tx.as_ref(), &id)?;
105 if actual != command.expect_revision {
106 return Err(PortError::Conflict(format!(
107 "view moved: expected {}, actual {actual}",
108 command.expect_revision
109 )));
110 }
111 let refs = command.sources.keys().cloned().collect::<Vec<_>>();
112 let sources =
113 super::consolidation_source::capture(tx.as_ref(), &command.about, &refs)?;
114 let view = consolidate(
115 &command,
116 sources,
117 authored_at(std::time::SystemTime::now())?,
118 )?;
119 tx.insert(
120 Table::ConsolidationViews,
121 Key::Str2(&id, &view.revision.to_string()),
122 &encode("view", &view)?,
123 )?;
124 tx.insert(
125 Table::ConsolidationHeads,
126 Key::Str(&id),
127 &encode("head", &view.revision)?,
128 )?;
129 tx.insert(
130 Table::ConsolidationReceipts,
131 Key::Str2(&id, &command.idempotency_key),
132 &encode("receipt", &(digest, view.revision))?,
133 )?;
134 tx.commit()?;
135 Ok(view)
136 })
137 .await
138 })
139 }
140
141 fn read_consolidation(
142 &self,
143 about: String,
144 view: String,
145 requested: Option<u64>,
146 ) -> ConsolidationFuture<'_, ConsolidationRead> {
147 Box::pin(async move {
148 self.run(move |store| {
149 let id = identity(&about, &view)?;
150 let tx = store.begin_read()?;
151 let rev = requested.unwrap_or(head(tx.as_ref(), &id)?);
152 let Some(view) = revision(tx.as_ref(), &id, rev)? else {
153 return Ok(ConsolidationRead {
154 status: ConsolidationReadStatus::Missing,
155 changed_sources: vec![],
156 view: None,
157 });
158 };
159 if requested.is_some() {
160 return Ok(ConsolidationRead {
161 status: ConsolidationReadStatus::HistoricalAudit,
162 changed_sources: vec![],
163 view: Some(view),
164 });
165 }
166 let mut changed = Vec::new();
167 for source in &view.sources {
170 match super::consolidation_source::capture(
171 tx.as_ref(),
172 &about,
173 std::slice::from_ref(&source.reference),
174 ) {
175 Ok(current) if current.first().is_some_and(|s| s.stamp == source.stamp) => {
176 }
177 Ok(_) | Err(PortError::InvalidState(_)) => {
178 changed.push(source.reference.clone())
179 }
180 Err(error) => return Err(error),
181 }
182 }
183 if !changed.is_empty() {
184 return Ok(ConsolidationRead {
185 status: ConsolidationReadStatus::Stale,
186 changed_sources: changed,
187 view: None,
188 });
189 }
190 Ok(ConsolidationRead {
191 status: ConsolidationReadStatus::Current,
192 changed_sources: vec![],
193 view: Some(view),
194 })
195 })
196 .await
197 })
198 }
199}
200
201#[cfg(test)]
202#[path = "consolidation_tests.rs"]
203mod tests;