fix(1001): stop create_* from half-writing onto tombstoned IDs
Root cause: db::next_item_number scanned the visible CRDT index and the
content store but not the tombstone set, so it would hand out a numeric
ID whose CRDT entry had been tombstoned. crdt_state::write_item then
silently no-op'd the insert (tombstone-match guard) while the content
store and SQLite shadow happily accepted the row, producing a split-
brain half-write that was invisible to every CRDT-driven read path and
couldn't be cleaned up by delete_story / purge_story.
This change closes the loop:
- crdt_state::read::{is_tombstoned, tombstoned_ids} expose the
tombstone set so callers outside crdt_state can consult it.
- db::next_item_number now scans tombstoned_ids() too. The allocator
skips past tombstoned numeric IDs instead of treating their slots as
free.
- write_item logs a WARN when it rejects a write for a tombstoned ID
(was silent). The warn is a tripwire — if the allocator ever lets one
slip through again we'll see it in the log.
- create_item_in_backlog adds two defence-in-depth checks:
(a) before any write, reject if the allocator returned a
tombstoned ID;
(b) after the writes, call read_item to confirm the CRDT entry
materialised. If not, roll back the content-store + shadow-DB
rows via db::delete_item and return Err.
Regression tests cover the allocator skip, the is_tombstoned accessor,
and the create_item_in_backlog rollback path.
Out of scope for this commit:
- Recovery of the already-half-written items currently in the running
pipeline (989, 1000, 1001) — Stage 2/3 of the plan, handled
separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-11
@@ -212,20 +212,27 @@ pub fn delete_item(story_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next available item number by scanning both the CRDT state
|
||||
/// and the in-memory content store for the highest existing number.
|
||||
/// Get the next available item number by scanning the CRDT state, the
|
||||
/// in-memory content store, AND the tombstone set for the highest existing
|
||||
/// number.
|
||||
///
|
||||
/// Tombstoned IDs are excluded from `read_all_typed` (their CRDT entry is
|
||||
/// `is_deleted`) and from `all_content_ids` (their content row is cleared by
|
||||
/// `evict_item`). Without consulting the tombstone set, the allocator can
|
||||
/// hand out a tombstoned numeric ID; `crdt_state::write_item` would then
|
||||
/// silently reject the new entry while the content store and SQLite shadow
|
||||
/// happily accept it, producing a split-brain half-write (bug 1001).
|
||||
pub fn next_item_number() -> u32 {
|
||||
let mut max_num: u32 = 0;
|
||||
|
||||
let parse_leading_digits = |s: &str| -> Option<u32> {
|
||||
let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
num_str.parse::<u32>().ok()
|
||||
};
|
||||
|
||||
// Scan CRDT items via typed projection.
|
||||
for item in crate::pipeline_state::read_all_typed() {
|
||||
let num_str: String = item
|
||||
.story_id
|
||||
.0
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if let Ok(n) = num_str.parse::<u32>()
|
||||
if let Some(n) = parse_leading_digits(&item.story_id.0)
|
||||
&& n > max_num
|
||||
{
|
||||
max_num = n;
|
||||
@@ -234,8 +241,19 @@ pub fn next_item_number() -> u32 {
|
||||
|
||||
// Also scan the content store (might have items not yet in CRDT).
|
||||
for id in all_content_ids() {
|
||||
let num_str: String = id.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = num_str.parse::<u32>()
|
||||
if let Some(n) = parse_leading_digits(&id)
|
||||
&& n > max_num
|
||||
{
|
||||
max_num = n;
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan tombstones — a tombstoned ID still poisons that slot because
|
||||
// crdt_state::write_item rejects writes for tombstoned IDs. Without this
|
||||
// pass, the next allocated ID can collide with a tombstone and produce
|
||||
// a half-write (bug 1001).
|
||||
for id in crate::crdt_state::tombstoned_ids() {
|
||||
if let Some(n) = parse_leading_digits(&id)
|
||||
&& n > max_num
|
||||
{
|
||||
max_num = n;
|
||||
|
||||
Reference in New Issue
Block a user