laminar_core/checkpoint/checkpoint_store/
artifact_intent.rs1use bytes::Bytes;
4use object_store::PutPayload;
5
6use super::validation::validate_abort_seal_request;
7use super::{CheckpointStoreError, ObjectStoreCheckpointStore};
8use crate::checkpoint::canonical_json_bytes;
9use crate::checkpoint::checkpoint_manifest::{
10 checkpoint_artifact_intent_sha256, CheckpointManifest, StateChunkId,
11 PREPARED_SINK_DESCRIPTOR_VERSION,
12};
13
14pub const MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_BYTES: usize = 64 * 1024;
16const MAX_CHECKPOINT_SINK_ARTIFACT_INTENTS: usize = 1_024;
17pub const MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_AGGREGATE_BYTES: usize = 1024 * 1024;
19pub(super) const MAX_CHECKPOINT_ARTIFACT_INTENT_RECORD_BYTES: u64 = 5 * 1024 * 1024;
20const CHECKPOINT_ARTIFACT_INTENT_VERSION: u32 = 1;
21
22#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
24#[serde(deny_unknown_fields)]
25pub struct CheckpointSinkArtifactIntent {
26 pub sink_name: String,
28 pub format_version: u16,
30 pub payload: Option<Vec<u8>>,
32 pub sha256: String,
34}
35
36impl std::fmt::Debug for CheckpointSinkArtifactIntent {
37 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 formatter
39 .debug_struct("CheckpointSinkArtifactIntent")
40 .field("sink_name", &self.sink_name)
41 .field("format_version", &self.format_version)
42 .field("payload_bytes", &self.payload.as_ref().map_or(0, Vec::len))
43 .field("sha256", &self.sha256)
44 .finish()
45 }
46}
47
48impl CheckpointSinkArtifactIntent {
49 pub fn try_new(
54 sink_name: String,
55 payload: Option<Vec<u8>>,
56 ) -> Result<Self, CheckpointStoreError> {
57 if sink_name.is_empty() {
58 return Err(CheckpointStoreError::Invalid(
59 "sink artifact intent name must not be empty".into(),
60 ));
61 }
62 if payload
63 .as_ref()
64 .is_some_and(|payload| payload.len() > MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_BYTES)
65 {
66 return Err(CheckpointStoreError::Invalid(format!(
67 "sink artifact intent exceeds {MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_BYTES} bytes"
68 )));
69 }
70 let sha256 = checkpoint_artifact_intent_sha256(payload.as_deref());
71 Ok(Self {
72 sink_name,
73 format_version: PREPARED_SINK_DESCRIPTOR_VERSION,
74 payload,
75 sha256,
76 })
77 }
78}
79
80#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
81#[serde(deny_unknown_fields)]
82pub(super) struct CheckpointArtifactIntentRecord {
83 version: u32,
84 artifact_identity_sha256: String,
85 chunk: StateChunkId,
86 sink_intents: Vec<CheckpointSinkArtifactIntent>,
87}
88
89impl CheckpointArtifactIntentRecord {
90 pub(super) fn sink_intents(&self) -> &[CheckpointSinkArtifactIntent] {
91 &self.sink_intents
92 }
93}
94
95pub(super) fn validate_artifact_intent_record(
96 record: &CheckpointArtifactIntentRecord,
97 expected_chunk: StateChunkId,
98 expected_artifact_identity_sha256: &str,
99) -> Result<(), CheckpointStoreError> {
100 validate_abort_seal_request(expected_chunk, expected_artifact_identity_sha256)?;
101 if record.version != CHECKPOINT_ARTIFACT_INTENT_VERSION
102 || record.chunk != expected_chunk
103 || record.artifact_identity_sha256 != expected_artifact_identity_sha256
104 {
105 return Err(CheckpointStoreError::Invalid(format!(
106 "participant {} checkpoint {} has a different sink artifact intent",
107 expected_chunk.participant_id, expected_chunk.checkpoint_id
108 )));
109 }
110 validate_sink_intents(&record.sink_intents)
111}
112
113pub(super) fn validate_sink_intents(
114 intents: &[CheckpointSinkArtifactIntent],
115) -> Result<(), CheckpointStoreError> {
116 if intents.is_empty() || intents.len() > MAX_CHECKPOINT_SINK_ARTIFACT_INTENTS {
117 return Err(CheckpointStoreError::Invalid(format!(
118 "sink artifact intent count must be in 1..={MAX_CHECKPOINT_SINK_ARTIFACT_INTENTS}"
119 )));
120 }
121 if !intents
122 .windows(2)
123 .all(|pair| pair[0].sink_name < pair[1].sink_name)
124 {
125 return Err(CheckpointStoreError::Invalid(
126 "sink artifact intents must be strictly ordered and unique".into(),
127 ));
128 }
129 let mut aggregate = 0_usize;
130 for intent in intents {
131 if intent.sink_name.is_empty()
132 || intent.format_version != PREPARED_SINK_DESCRIPTOR_VERSION
133 || intent.sha256 != checkpoint_artifact_intent_sha256(intent.payload.as_deref())
134 {
135 return Err(CheckpointStoreError::Invalid(format!(
136 "sink artifact intent '{}' has an invalid envelope",
137 intent.sink_name
138 )));
139 }
140 let bytes = intent.payload.as_ref().map_or(0, Vec::len);
141 if bytes > MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_BYTES {
142 return Err(CheckpointStoreError::Invalid(format!(
143 "sink artifact intent '{}' exceeds {MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_BYTES} bytes",
144 intent.sink_name
145 )));
146 }
147 aggregate = aggregate
148 .checked_add(bytes)
149 .ok_or_else(|| CheckpointStoreError::Invalid("sink intent byte overflow".into()))?;
150 if aggregate > MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_AGGREGATE_BYTES {
151 return Err(CheckpointStoreError::Invalid(format!(
152 "sink artifact intents exceed {MAX_CHECKPOINT_SINK_ARTIFACT_INTENT_AGGREGATE_BYTES} aggregate bytes"
153 )));
154 }
155 }
156 Ok(())
157}
158
159fn artifact_intent_bytes(
160 record: &CheckpointArtifactIntentRecord,
161) -> Result<Bytes, CheckpointStoreError> {
162 let encoded = Bytes::from(canonical_json_bytes(record)?);
163 if u64::try_from(encoded.len()).unwrap_or(u64::MAX)
164 > MAX_CHECKPOINT_ARTIFACT_INTENT_RECORD_BYTES
165 {
166 return Err(CheckpointStoreError::Invalid(format!(
167 "checkpoint sink artifact intent record exceeds {MAX_CHECKPOINT_ARTIFACT_INTENT_RECORD_BYTES} bytes"
168 )));
169 }
170 Ok(encoded)
171}
172
173impl ObjectStoreCheckpointStore {
174 pub(super) fn decode_pending_artifact_intent_record(
175 bytes: &Bytes,
176 chunk: StateChunkId,
177 ) -> Result<Option<CheckpointArtifactIntentRecord>, CheckpointStoreError> {
178 let Ok(record) = serde_json::from_slice::<CheckpointArtifactIntentRecord>(bytes) else {
179 return Ok(None);
180 };
181 validate_artifact_intent_record(&record, chunk, &record.artifact_identity_sha256)?;
182 if artifact_intent_bytes(&record)? != *bytes {
183 return Err(CheckpointStoreError::Invalid(format!(
184 "participant {} checkpoint {} sink artifact intent is not canonical",
185 chunk.participant_id, chunk.checkpoint_id
186 )));
187 }
188 Ok(Some(record))
189 }
190
191 pub(super) fn decode_artifact_intent_record(
192 bytes: &Bytes,
193 chunk: StateChunkId,
194 expected_artifact_identity_sha256: &str,
195 ) -> Result<Option<CheckpointArtifactIntentRecord>, CheckpointStoreError> {
196 let Some(record) = Self::decode_pending_artifact_intent_record(bytes, chunk)? else {
197 return Ok(None);
198 };
199 if record.artifact_identity_sha256 != expected_artifact_identity_sha256 {
200 return Err(CheckpointStoreError::Invalid(format!(
201 "participant {} checkpoint {} has a different sink artifact intent",
202 chunk.participant_id, chunk.checkpoint_id
203 )));
204 }
205 Ok(Some(record))
206 }
207
208 pub(super) async fn save_artifact_intent_record(
209 &self,
210 chunk: StateChunkId,
211 artifact_identity_sha256: &str,
212 sink_intents: Vec<CheckpointSinkArtifactIntent>,
213 ) -> Result<(), CheckpointStoreError> {
214 validate_abort_seal_request(chunk, artifact_identity_sha256)?;
215 validate_sink_intents(&sink_intents)?;
216 let record = CheckpointArtifactIntentRecord {
217 version: CHECKPOINT_ARTIFACT_INTENT_VERSION,
218 artifact_identity_sha256: artifact_identity_sha256.to_owned(),
219 chunk,
220 sink_intents,
221 };
222 let encoded = artifact_intent_bytes(&record)?;
223 let path = self.manifest_path(chunk);
224 if self
225 .create_immutable(&path, PutPayload::from_bytes(encoded.clone()))
226 .await?
227 {
228 return Ok(());
229 }
230 let existing = self
231 .load_manifest_or_abort_seal_bytes(chunk)
232 .await?
233 .ok_or_else(|| {
234 CheckpointStoreError::Invalid(format!(
235 "participant {} checkpoint {} sink intent create conflicted but no object exists",
236 chunk.participant_id, chunk.checkpoint_id
237 ))
238 })?;
239 if existing == encoded {
240 Ok(())
241 } else {
242 Err(CheckpointStoreError::Invalid(format!(
243 "participant {} checkpoint {} already has different durable artifact state",
244 chunk.participant_id, chunk.checkpoint_id
245 )))
246 }
247 }
248
249 pub(super) async fn promote_artifact_intent_to_manifest(
250 &self,
251 manifest: &CheckpointManifest,
252 encoded_manifest: Bytes,
253 current: &Bytes,
254 ) -> Result<bool, CheckpointStoreError> {
255 let chunk = manifest.node_data.chunk;
256 let inventory = crate::checkpoint_decision::CheckpointArtifactInventory {
257 deployment_id: manifest.deployment_id.clone(),
258 pipeline_identity: manifest.pipeline_identity.clone(),
259 attempt: crate::checkpoint::CheckpointAttempt::new(
260 manifest.epoch,
261 manifest.checkpoint_id,
262 ),
263 assignment_fence: manifest.assignment_fence.clone(),
264 sink_artifact_intent_protocol: !manifest.sink_artifact_intents.is_empty(),
265 };
266 let identity = super::checkpoint_artifact_identity_sha256(&inventory, chunk)?;
267 let Some(record) = Self::decode_artifact_intent_record(current, chunk, &identity)? else {
268 return Ok(false);
269 };
270 validate_manifest_intents(manifest, record.sink_intents())?;
271 self.replace_exact(&self.manifest_path(chunk), current, encoded_manifest)
272 .await?;
273 Ok(true)
274 }
275}
276
277fn validate_manifest_intents(
278 manifest: &CheckpointManifest,
279 intents: &[CheckpointSinkArtifactIntent],
280) -> Result<(), CheckpointStoreError> {
281 if manifest.sink_artifact_intents.len() != intents.len() {
282 return Err(CheckpointStoreError::Invalid(
283 "checkpoint manifest does not retain its admitted sink artifact intents".into(),
284 ));
285 }
286 for (descriptor, intent) in manifest.sink_artifact_intents.iter().zip(intents) {
287 if descriptor.sink_name != intent.sink_name
288 || descriptor.format_version != intent.format_version
289 || descriptor.payload.is_some() != intent.payload.is_some()
290 || descriptor.sha256 != intent.sha256
291 {
292 return Err(CheckpointStoreError::Invalid(format!(
293 "checkpoint manifest changed sink artifact intent '{}'",
294 intent.sink_name
295 )));
296 }
297 }
298 Ok(())
299}