Skip to main content

kmp_adapter_embedded/adapter/
consolidation.rs

1use 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    // Preserve the sampled instant: rounding down would admit this derivation
23    // at a cutoff preceding its actual authorship within the same second.
24    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                // Retry returns the historical acceptance, even if a source or the
89                // view has moved since. The separate current read checks freshness.
90                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                // Work is bounded by this view's dependencies, not the store size.
168                // Capture failures fail closed; infrastructure errors still propagate.
169                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;