icechunk_storage/
readback.rs1use std::sync::Once;
11
12use tracing::{trace, warn};
13
14use crate::storage::{
15 Settings, StorageError, StorageResult, VersionInfo, VersionedUpdateResult,
16 other_error,
17};
18
19pub const WRITE_ID_METADATA_KEY: &str = "icechunkwriteid";
22
23pub const WRITE_ID_METADATA_KEY_DEPRECATED: &str = "icechunk_write_id";
25
26static CONDITIONAL_WITHOUT_METADATA_WARNED: Once = Once::new();
27
28fn warn_conditional_without_metadata_once() {
30 CONDITIONAL_WITHOUT_METADATA_WARNED.call_once(|| {
31 warn!(
32 "conditional PUT is enabled but `unsafe_use_metadata` is \
33 disabled — lost-response recovery for conditional writes \
34 requires user metadata to stamp write-ids; without it, \
35 transient PUT failures may surface as spurious conflicts \
36 even when the write actually landed. See \
37 icechunk_storage::Settings::unsafe_use_metadata."
38 );
39 });
40}
41
42pub fn write_id_for(
45 settings: &Settings,
46 conditional: bool,
47 mk: impl FnOnce() -> String,
48) -> Option<String> {
49 if !conditional {
50 None
51 } else if settings.unsafe_use_metadata() {
52 Some(mk())
53 } else {
54 warn_conditional_without_metadata_once();
55 None
56 }
57}
58
59#[derive(Debug, PartialEq, Eq)]
61pub enum ReadbackOutcome {
62 OurWrite(VersionInfo),
63 NotOurs,
66 MissingVersion,
68}
69
70impl ReadbackOutcome {
71 pub fn classify(
75 our: &str,
76 stored_write_id: Option<&str>,
77 version: &VersionInfo,
78 ) -> ReadbackOutcome {
79 if stored_write_id != Some(our) {
80 ReadbackOutcome::NotOurs
81 } else if version.is_create() {
82 ReadbackOutcome::MissingVersion
83 } else {
84 ReadbackOutcome::OurWrite(version.clone())
85 }
86 }
87}
88
89pub fn resolve_precondition(
93 readback: StorageResult<ReadbackOutcome>,
94 key: &str,
95) -> StorageResult<VersionedUpdateResult> {
96 match readback? {
97 ReadbackOutcome::OurWrite(new_version) => {
98 trace!(
99 key,
100 "precondition failed but our write-id is stored; retried PUT, success"
101 );
102 Ok(VersionedUpdateResult::Updated { new_version })
103 }
104 ReadbackOutcome::NotOurs => Ok(VersionedUpdateResult::NotOnLatestVersion),
105 ReadbackOutcome::MissingVersion => {
106 Err(other_error("readback object is missing a version identity"))
107 }
108 }
109}
110
111pub fn resolve_lost_response(
115 readback: StorageResult<ReadbackOutcome>,
116 key: &str,
117 original: StorageError,
118) -> StorageResult<VersionedUpdateResult> {
119 match readback? {
120 ReadbackOutcome::OurWrite(new_version) => {
121 trace!(
122 key,
123 "PUT response lost but our write-id is stored; treating as success"
124 );
125 Ok(VersionedUpdateResult::Updated { new_version })
126 }
127 _ => Err(original),
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::storage::{ETag, StorageErrorKind};
135
136 fn version(etag: Option<&str>) -> VersionInfo {
137 VersionInfo { etag: etag.map(|e| ETag(e.to_string())), generation: None }
138 }
139
140 #[test]
141 fn classify_match_returns_readback_version() {
142 assert_eq!(
144 ReadbackOutcome::classify("W", Some("W"), &version(Some("E1"))),
145 ReadbackOutcome::OurWrite(VersionInfo::from_etag_only("E1".to_string()))
146 );
147 }
148
149 #[test]
150 fn classify_branches() {
151 assert_eq!(
153 ReadbackOutcome::classify("W", Some("OTHER"), &version(Some("E1"))),
154 ReadbackOutcome::NotOurs
155 );
156 assert_eq!(
157 ReadbackOutcome::classify("W", None, &version(Some("E1"))),
158 ReadbackOutcome::NotOurs
159 );
160 assert_eq!(
162 ReadbackOutcome::classify("W", Some("W"), &version(None)),
163 ReadbackOutcome::MissingVersion
164 );
165 }
166
167 #[test]
168 fn precondition_absent_is_conflict_not_error() {
169 assert_eq!(
171 resolve_precondition(Ok(ReadbackOutcome::NotOurs), "k").unwrap(),
172 VersionedUpdateResult::NotOnLatestVersion
173 );
174 }
175
176 #[test]
177 fn precondition_inconclusive_readback_propagates() {
178 let err = resolve_precondition(Err(other_error("head failed")), "k")
181 .expect_err("inconclusive readback must propagate");
182 assert!(matches!(err.kind, StorageErrorKind::Other(_)));
183 }
184
185 #[test]
186 fn lost_response_inconclusive_propagates_head_error() {
187 let err = resolve_lost_response(
188 Err(other_error("head failed")),
189 "k",
190 other_error("original put error"),
191 )
192 .expect_err("inconclusive readback must propagate");
193 assert!(matches!(err.kind, StorageErrorKind::Other(s) if s == "head failed"));
194 }
195
196 #[test]
197 fn lost_response_non_ours_propagates_original() {
198 let err = resolve_lost_response(
199 Ok(ReadbackOutcome::NotOurs),
200 "k",
201 other_error("original put error"),
202 )
203 .expect_err("a write that did not land must propagate the original error");
204 assert!(
205 matches!(err.kind, StorageErrorKind::Other(s) if s == "original put error")
206 );
207 }
208
209 #[test]
210 fn precondition_missing_version_is_error() {
211 let err = resolve_precondition(Ok(ReadbackOutcome::MissingVersion), "k")
213 .expect_err("a landed write without a version identity cannot succeed");
214 assert!(matches!(err.kind, StorageErrorKind::Other(_)));
215 }
216
217 #[test]
218 fn lost_response_our_write_recovers_readback_version() {
219 assert_eq!(
220 resolve_lost_response(
221 Ok(ReadbackOutcome::OurWrite(version(Some("E1")))),
222 "k",
223 other_error("original put error"),
224 )
225 .unwrap(),
226 VersionedUpdateResult::Updated {
227 new_version: VersionInfo::from_etag_only("E1".to_string())
228 }
229 );
230 }
231
232 #[test]
233 fn write_id_metadata_key_is_portable() {
234 assert!(
235 WRITE_ID_METADATA_KEY.starts_with(|c: char| c.is_ascii_alphabetic())
236 && WRITE_ID_METADATA_KEY.chars().all(|c| c.is_ascii_alphanumeric()),
237 "Azure rejects `-` and nginx-fronted S3 gateways drop `_`"
238 );
239 }
240}