@@ -125,11 +125,9 @@ impl std::ops::Deref for DatabaseTombstones<'_> {
125125/// Single-pass extraction: walk `records`, decode every
126126/// `CollectionTombstoned` entry, and return the resulting set.
127127///
128- /// Records that fail to decode are logged and skipped — replay callers
129- /// that need hard failure on corrupt tombstones should validate CRCs
130- /// upstream; a CRC-passing but structurally corrupt payload is an
131- /// out-of-band programmer bug, not a run-time recoverable condition.
132- pub fn extract_tombstones ( records : & [ WalRecord ] ) -> TombstoneSet {
128+ /// A malformed tombstone fails extraction. Skipping one would replay writes
129+ /// below an unknown purge boundary and can resurrect a dropped collection.
130+ pub fn extract_tombstones ( records : & [ WalRecord ] ) -> crate :: Result < TombstoneSet > {
133131 let mut set = TombstoneSet :: new ( ) ;
134132 for record in records {
135133 let Some ( kind) = RecordType :: from_raw ( record. logical_record_type ( ) ) else {
@@ -142,22 +140,15 @@ pub fn extract_tombstones(records: &[WalRecord]) -> TombstoneSet {
142140 // they carry only a collection name and an LSN, no secrets.
143141 // If payload-level encryption is later extended to tombstones,
144142 // this call site changes to `decrypt_payload_ring` first.
145- match CollectionTombstonePayload :: from_bytes ( & record. payload ) {
146- Ok ( payload) => set. insert (
147- record. header . database_id ,
148- record. header . tenant_id ,
149- payload. collection ,
150- payload. purge_lsn ,
151- ) ,
152- Err ( e) => tracing:: warn!(
153- lsn = record. header. lsn,
154- tenant_id = record. header. tenant_id,
155- error = %e,
156- "skipping malformed CollectionTombstoned record during tombstone extraction" ,
157- ) ,
158- }
143+ let payload = CollectionTombstonePayload :: from_bytes ( & record. payload ) ?;
144+ set. insert (
145+ record. header . database_id ,
146+ record. header . tenant_id ,
147+ payload. collection ,
148+ payload. purge_lsn ,
149+ ) ;
159150 }
160- set
151+ Ok ( set)
161152}
162153
163154#[ cfg( test) ]
@@ -217,7 +208,7 @@ mod tests {
217208 tombstone_record( 7 , 1 , "orders" , 150 , 11 ) ,
218209 tombstone_record( 8 , 1 , "users" , 200 , 12 ) ,
219210 ] ;
220- let set = extract_tombstones ( & records) ;
211+ let set = extract_tombstones ( & records) . unwrap ( ) ;
221212 assert_eq ! ( set. len( ) , 3 ) ;
222213 assert_eq ! ( set. purge_lsn( 7 , 1 , "users" ) , Some ( 100 ) ) ;
223214 assert_eq ! ( set. purge_lsn( 7 , 1 , "orders" ) , Some ( 150 ) ) ;
@@ -251,12 +242,12 @@ mod tests {
251242 } )
252243 . unwrap( ) ,
253244 ] ;
254- let set = extract_tombstones ( & records) ;
245+ let set = extract_tombstones ( & records) . unwrap ( ) ;
255246 assert_eq ! ( set. len( ) , 1 ) ;
256247 }
257248
258249 #[ test]
259- fn extract_tolerates_corrupt_payload ( ) {
250+ fn extract_rejects_corrupt_payload ( ) {
260251 // Build a tombstone-typed record whose payload is too short to decode.
261252 let bogus = WalRecord :: new ( WalRecordArgs {
262253 record_type : RecordType :: CollectionTombstoned as u32 ,
@@ -270,13 +261,10 @@ mod tests {
270261 } )
271262 . unwrap ( ) ;
272263 let records = vec ! [ bogus, tombstone_record( 0 , 1 , "users" , 100 , 10 ) ] ;
273- let set = extract_tombstones ( & records) ;
274- assert_eq ! (
275- set. len( ) ,
276- 1 ,
277- "valid tombstone still captured despite peer corruption"
264+ assert ! (
265+ extract_tombstones( & records) . is_err( ) ,
266+ "malformed tombstone must fail replay rather than lose a purge boundary"
278267 ) ;
279- assert_eq ! ( set. purge_lsn( 0 , 1 , "users" ) , Some ( 100 ) ) ;
280268 }
281269
282270 #[ test]
0 commit comments