Compare commits

..

2 Commits

Author SHA1 Message Date
overtrue 399c5d1252 test(capture): specify atomic populated native migration 2026-10-11 15:35:35 +08:00
overtrue 5b872b8e56 feat(capture): apply native entries atomically with durable replay 2026-10-11 11:50:32 +08:00
2 changed files with 640 additions and 94 deletions
+299 -92
View File
@@ -133,20 +133,155 @@ pub(crate) fn decode_native_entry(entry: crate::LogEntry) -> Result<crate::Captu
})
}
fn apply_created(
tx: &redb::WriteTransaction,
id: LogId,
command: &DecideCreated,
canonical: &[u8],
binding: &CaptureBinding,
) -> Result<CreatedResult, StoreError> {
let mut operations = tx.open_table(OPERATIONS)?;
let existing = operations
.get(command.operation_id.as_slice())?
.map(|v| decode_operation(v.value()))
.transpose()?;
let result = if let Some(existing) = existing {
if existing.canonical_command == canonical {
existing.result
} else {
CreatedResult::OperationConflict
}
} else {
let mut heads = tx.open_table(HEADS)?;
let mut versions = tx.open_table(VERSIONS)?;
let object = object_bytes(&command.object)?;
let key = version_key(&command.object, command.prepared.version_id)?;
let actual = heads
.get(object.as_slice())?
.map(|v| {
let mut d = Decoder::new(v.value());
let id = d.uuid()?;
d.finish()?;
Ok::<_, StoreError>(id)
})
.transpose()?;
let outcome = if command.binding_revision != binding.revision {
CreatedResult::BindingMismatch
} else if actual != command.expected_head {
CreatedResult::HeadMismatch { actual_head: actual }
} else if versions.get(key.as_slice())?.is_some() {
CreatedResult::VersionConflict
} else {
let mut version = Vec::new();
put_prepared(&mut version, &command.prepared);
version.extend(encode_id(id));
version.extend(binding.revision.to_be_bytes());
versions.insert(key.as_slice(), version.as_slice())?;
let mut event = object;
put_prepared(&mut event, &command.prepared);
event.extend(encode_id(id));
event.extend(binding_bytes(&binding)?);
tx.open_table(EVENTS)?
.insert(command.operation_id.as_slice(), event.as_slice())?;
heads.insert(object_bytes(&command.object)?.as_slice(), command.prepared.version_id.as_slice())?;
CreatedResult::Created {
version_id: command.prepared.version_id,
event_id: command.operation_id,
decision_log_id: id,
}
};
let mut op = u64::try_from(canonical.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes()
.to_vec();
op.extend(canonical);
op.extend(result_bytes(&outcome));
operations.insert(command.operation_id.as_slice(), op.as_slice())?;
outcome
};
Ok(result)
}
fn validate_response_kind(entry: &crate::CaptureRaftEntry, response: &crate::CaptureResponse) -> Result<(), StoreError> {
let kind = match entry.payload {
openraft::EntryPayload::Blank => crate::CaptureResponseKind::Blank,
openraft::EntryPayload::Normal(_) => crate::CaptureResponseKind::Created,
openraft::EntryPayload::Membership(_) => crate::CaptureResponseKind::Membership,
};
if response.kind() != kind || *response.log_id() != LogId::from(entry.log_id) {
return Err(StoreError::CorruptRecord);
}
Ok(())
}
fn encode_response(response: &crate::CaptureResponse) -> Result<Vec<u8>, StoreError> {
let (kind, body) = match response.kind() {
crate::CaptureResponseKind::Blank => (0, vec![]),
crate::CaptureResponseKind::Created => (1, result_bytes(response.created_result().ok_or(StoreError::CorruptRecord)?)),
crate::CaptureResponseKind::Membership => (2, vec![]),
};
let mut bytes = b"CRSP\x01".to_vec();
bytes.push(kind);
bytes.extend(encode_id(*response.log_id()));
bytes.extend(
u64::try_from(body.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes(),
);
bytes.extend(body);
Ok(bytes)
}
fn decode_response(bytes: &[u8]) -> Result<crate::CaptureResponse, StoreError> {
if bytes.len() < 38
|| &bytes[..5] != b"CRSP\x01"
|| number(&bytes[30..38])? != u64::try_from(bytes.len() - 38).map_err(|_| StoreError::CorruptRecord)?
{
return Err(StoreError::CorruptRecord);
}
let id = crate::decode_id(&bytes[6..30])?;
match bytes[5] {
0 if bytes.len() == 38 => crate::CaptureResponse::blank(id),
1 => {
let mut body = encode_id(id);
body.extend(&bytes[38..]);
crate::CaptureResponse::created(decode_apply(&body)?)
}
2 if bytes.len() == 38 => crate::CaptureResponse::membership(id),
_ => Err(StoreError::CorruptRecord),
}
}
fn entry_command(entry: &crate::LogEntry, native: bool) -> Result<DecideCreated, StoreError> {
if native {
match decode_native_entry(entry.clone())?.payload {
openraft::EntryPayload::Normal(command) => Ok(command.created().clone()),
_ => Err(StoreError::CorruptRecord),
}
} else {
DecideCreated::decode(&entry.payload)
}
}
pub(crate) fn native_application_state(tx: &ReadTransaction) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
let meta = tx.open_table(META)?;
if number(required(meta.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let last = read_applied(&meta)?;
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())?;
if last.is_some() || membership.log_id().is_some() || !membership.configs().is_empty() || !membership.nodes().is_empty() {
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
.map_err(|_| StoreError::CorruptRecord)?;
if membership
.log_id()
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
{
return Err(StoreError::CorruptRecord);
}
Ok((last, membership))
}
impl Store {
pub fn migrate_legacy_created_to_native(&mut self) -> Result<(), StoreError> {
Err(StoreError::UnsupportedSchema)
}
/// Explicitly opt an empty, bound store into native entry persistence.
pub fn enable_native_empty(&mut self) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
@@ -158,11 +293,13 @@ impl Store {
}
read_binding(&meta)?;
if schema == 4 {
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())?;
if read_applied(&meta)?.is_some()
|| membership.log_id().is_some()
|| !membership.configs().is_empty()
|| !membership.nodes().is_empty()
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
.map_err(|_| StoreError::CorruptRecord)?;
let last = read_applied(&meta)?;
if membership
.log_id()
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
|| (last.is_none() && (!membership.configs().is_empty() || !membership.nodes().is_empty()))
{
return Err(StoreError::CorruptRecord);
}
@@ -255,12 +392,64 @@ impl Store {
}
Ok(entries)
}
pub fn apply_next_native(&mut self, _id: LogId) -> Result<crate::CaptureResponse, StoreError> {
let _tx = self.write_transaction()?;
Err(StoreError::UnsupportedSchema)
pub fn apply_next_native(&mut self, id: LogId) -> Result<crate::CaptureResponse, StoreError> {
let tx = self.write_transaction()?;
let response;
{
let mut meta = tx.open_table(META)?;
if number(required(meta.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let binding = read_binding(&meta)?;
let logs = tx.open_table(LOGS)?;
let committed = validate_committed(&meta, &logs)?.ok_or(StoreError::InvalidCommittedLog)?;
let entry = decode_log(required(logs.get(id.index)?)?.value())?;
if entry.id != id || id.index > committed.index {
return Err(StoreError::InvalidCommittedLog);
}
let native = decode_native_entry(entry)?;
let last = read_applied(&meta)?;
let mut results = tx.open_table(APPLY_RESULTS)?;
if last.is_some_and(|last| id.index <= last.index) {
let saved = decode_response(required(results.get(id.index)?)?.value())?;
if *saved.log_id() != id {
return Err(StoreError::InvalidApplySequence);
}
validate_response_kind(&native, &saved)?;
return Ok(saved);
}
if id.index != next_index(last.map(|last| last.index))? {
return Err(StoreError::InvalidApplySequence);
}
response = match native.payload {
openraft::EntryPayload::Blank => crate::CaptureResponse::blank(id)?,
openraft::EntryPayload::Normal(command) => {
let canonical = command.created().encode()?;
let result = apply_created(&tx, id, command.created(), &canonical, &binding)?;
crate::CaptureResponse::created(CreatedApplyResult { log_id: id, result })?
}
openraft::EntryPayload::Membership(membership) => {
let stored = openraft::StoredMembership::new(Some(id.into()), membership);
let checked = crate::CaptureMembership::try_from(&stored)?;
meta.insert("native_membership", checked.encode()?.as_slice())?;
crate::CaptureResponse::membership(id)?
}
};
results.insert(id.index, encode_response(&response)?.as_slice())?;
let mut last = vec![1];
last.extend(encode_id(id));
meta.insert("last_applied", last.as_slice())?;
}
self.commit(tx)?;
Ok(response)
}
pub fn native_apply_response(&self, _index: u64) -> Result<Option<crate::CaptureResponse>, StoreError> {
Err(StoreError::UnsupportedSchema)
pub fn native_apply_response(&self, index: u64) -> Result<Option<crate::CaptureResponse>, StoreError> {
let tx = self.database.begin_read()?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let results = tx.open_table(APPLY_RESULTS)?;
results.get(index)?.map(|v| decode_response(v.value())).transpose()
}
pub fn native_application_state(&self) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
native_application_state(&self.database.begin_read()?)
@@ -326,65 +515,7 @@ impl Store {
return Err(StoreError::InvalidApplySequence);
}
let command = DecideCreated::decode(&entry.payload)?;
let mut operations = tx.open_table(OPERATIONS)?;
let existing = operations
.get(command.operation_id.as_slice())?
.map(|v| decode_operation(v.value()))
.transpose()?;
result = if let Some(existing) = existing {
if existing.canonical_command == entry.payload {
existing.result
} else {
CreatedResult::OperationConflict
}
} else {
let mut heads = tx.open_table(HEADS)?;
let mut versions = tx.open_table(VERSIONS)?;
let object = object_bytes(&command.object)?;
let key = version_key(&command.object, command.prepared.version_id)?;
let actual = heads
.get(object.as_slice())?
.map(|v| {
let mut d = Decoder::new(v.value());
let id = d.uuid()?;
d.finish()?;
Ok::<_, StoreError>(id)
})
.transpose()?;
let outcome = if command.binding_revision != binding.revision {
CreatedResult::BindingMismatch
} else if actual != command.expected_head {
CreatedResult::HeadMismatch { actual_head: actual }
} else if versions.get(key.as_slice())?.is_some() {
CreatedResult::VersionConflict
} else {
let mut version = Vec::new();
put_prepared(&mut version, &command.prepared);
version.extend(encode_id(id));
version.extend(binding.revision.to_be_bytes());
versions.insert(key.as_slice(), version.as_slice())?;
let mut event = object;
put_prepared(&mut event, &command.prepared);
event.extend(encode_id(id));
event.extend(binding_bytes(&binding)?);
tx.open_table(EVENTS)?
.insert(command.operation_id.as_slice(), event.as_slice())?;
heads.insert(object_bytes(&command.object)?.as_slice(), command.prepared.version_id.as_slice())?;
CreatedResult::Created {
version_id: command.prepared.version_id,
event_id: command.operation_id,
decision_log_id: id,
}
};
let mut op = u64::try_from(entry.payload.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes()
.to_vec();
op.extend(&entry.payload);
op.extend(result_bytes(&outcome));
operations.insert(command.operation_id.as_slice(), op.as_slice())?;
outcome
};
result = apply_created(&tx, id, &command, &entry.payload, &binding)?;
let mut applied = encode_id(id);
applied.extend(result_bytes(&result));
results.insert(id.index, applied.as_slice())?;
@@ -443,27 +574,29 @@ impl Store {
pub fn created_apply_result(&self, index: u64) -> Result<Option<CreatedApplyResult>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(APPLY_RESULTS)?;
t.get(index)?.map(|v| decode_apply(v.value())).transpose()
if number(required(tx.open_table(META)?.get("schema")?)?.value())? == 4 {
t.get(index)?
.map(|v| {
decode_response(v.value()).map(|r| {
r.created_result().cloned().map(|result| CreatedApplyResult {
log_id: *r.log_id(),
result,
})
})
})
.transpose()
.map(Option::flatten)
} else {
t.get(index)?.map(|v| decode_apply(v.value())).transpose()
}
}
}
pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreError> {
let meta = tx.open_table(META)?;
let schema = number(required(meta.get("schema")?)?.value())?;
if schema == 4 {
read_binding(&meta)?;
native_application_state(tx)?;
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
if !tx.open_table(table)?.is_empty()? {
return Err(StoreError::CorruptRecord);
}
}
if !tx.open_table(APPLY_RESULTS)?.is_empty()? {
return Err(StoreError::CorruptRecord);
}
return Ok(());
}
if schema != 3 {
let native = schema == 4;
if !matches!(schema, 3 | 4) {
return Ok(());
}
let binding = read_binding(&meta).map_err(|error| match error {
@@ -484,24 +617,85 @@ pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreErro
let mut next = Some(0);
let mut observed = None;
let mut first_operations = BTreeMap::new();
let mut expected_membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
let mut replay_operations = BTreeMap::<[u8; 16], (Vec<u8>, CreatedResult)>::new();
let mut replay_heads = BTreeMap::<Vec<u8>, [u8; 16]>::new();
let mut replay_versions = std::collections::BTreeSet::new();
for row in results.iter()? {
let (k, v) = row?;
let r = decode_apply(v.value())?;
if next != Some(k.value()) || r.log_id.index != k.value() {
let entry = decode_log(required(logs.get(k.value())?)?.value())?;
let r = if native {
let response = decode_response(v.value())?;
let native_entry = decode_native_entry(entry.clone())?;
validate_response_kind(&native_entry, &response)?;
if next != Some(k.value()) || response.log_id().index != k.value() {
return Err(StoreError::CorruptRecord);
}
observed = Some(*response.log_id());
next = k.value().checked_add(1);
match native_entry.payload {
openraft::EntryPayload::Membership(membership) => {
expected_membership =
crate::CaptureMembership::try_from(&openraft::StoredMembership::new(Some(entry.id.into()), membership))?;
continue;
}
openraft::EntryPayload::Blank => continue,
openraft::EntryPayload::Normal(_) => CreatedApplyResult {
log_id: *response.log_id(),
result: response.created_result().cloned().ok_or(StoreError::CorruptRecord)?,
},
}
} else {
decode_apply(v.value())?
};
if !native && (next != Some(k.value()) || r.log_id.index != k.value()) {
return Err(StoreError::CorruptRecord);
}
let entry = decode_log(required(logs.get(k.value())?)?.value())?;
if entry.id != r.log_id {
return Err(StoreError::CorruptRecord);
}
let c = DecideCreated::decode(&entry.payload)?;
let c = entry_command(&entry, native)?;
let canonical = c.encode()?;
let op = decode_operation(required(operations.get(c.operation_id.as_slice())?)?.value())?;
let canonical_matches = op.canonical_command == entry.payload;
let canonical_matches = op.canonical_command == canonical;
let expected = if canonical_matches {
op.result
} else {
CreatedResult::OperationConflict
};
if native {
let replay = if let Some((previous, result)) = replay_operations.get(&c.operation_id) {
if *previous == canonical {
result.clone()
} else {
CreatedResult::OperationConflict
}
} else {
let object = object_bytes(&c.object)?;
let key = version_key(&c.object, c.prepared.version_id)?;
let actual = replay_heads.get(&object).copied();
let result = if c.binding_revision != binding.revision {
CreatedResult::BindingMismatch
} else if actual != c.expected_head {
CreatedResult::HeadMismatch { actual_head: actual }
} else if replay_versions.contains(&key) {
CreatedResult::VersionConflict
} else {
replay_heads.insert(object, c.prepared.version_id);
replay_versions.insert(key);
CreatedResult::Created {
version_id: c.prepared.version_id,
event_id: c.operation_id,
decision_log_id: r.log_id,
}
};
replay_operations.insert(c.operation_id, (canonical.clone(), result.clone()));
result
};
if replay != r.result {
return Err(StoreError::CorruptRecord);
}
}
if expected != r.result {
return Err(StoreError::CorruptRecord);
}
@@ -514,6 +708,19 @@ pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreErro
if observed != last {
return Err(StoreError::CorruptRecord);
}
if native {
if native_application_state(tx)?.1 != expected_membership
|| heads.len()? != u64::try_from(replay_heads.len()).map_err(|_| StoreError::CorruptRecord)?
|| versions.len()? != u64::try_from(replay_versions.len()).map_err(|_| StoreError::CorruptRecord)?
{
return Err(StoreError::CorruptRecord);
}
for (object, id) in replay_heads {
if required(heads.get(object.as_slice())?)?.value() != id {
return Err(StoreError::CorruptRecord);
}
}
}
for row in operations.iter()? {
let (k, v) = row?;
let op = decode_operation(v.value())?;
@@ -592,7 +799,7 @@ pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreErro
return Err(StoreError::CorruptRecord);
}
let entry = decode_log(required(logs.get(version.decision_log_id.index)?)?.value())?;
let c = DecideCreated::decode(&entry.payload)?;
let c = entry_command(&entry, native)?;
if entry.id != version.decision_log_id
|| c.object != object
|| c.prepared != version.prepared
+341 -2
View File
@@ -211,7 +211,11 @@ impl Store {
let (key, value) = row?;
let entry = decode_log(value.value())?;
if next != Some(key.value()) || entry.id.index != key.value() {
return Err(StoreError::InvalidLogSequence);
return Err(if number(schema.value())? == 4 {
StoreError::CorruptRecord
} else {
StoreError::InvalidLogSequence
});
}
if number(schema.value())? == 4 {
application::decode_native_entry(entry.clone())?;
@@ -223,7 +227,11 @@ impl Store {
application::validate_application(&tx)?;
let tail = meta.get("tail")?.map(|v| decode_id(v.value())).transpose()?;
if tail != last {
return Err(StoreError::InvalidLogSequence);
return Err(if number(schema.value())? == 4 {
StoreError::CorruptRecord
} else {
StoreError::InvalidLogSequence
});
}
drop(schema);
drop(identity);
@@ -451,6 +459,286 @@ mod tests {
node_id: 0,
}
}
fn u17b_snapshot(store: &Store) -> Vec<Vec<(Vec<u8>, Vec<u8>)>> {
use crate::application::{APPLY_RESULTS, EVENTS, HEADS, OPERATIONS, VERSIONS};
let tx = store.database.begin_read().expect("snapshot transaction");
let meta = tx.open_table(META).expect("snapshot metadata");
let mut snapshot = vec![
meta.iter()
.expect("metadata iterator")
.map(|row| {
let (key, value) = row.expect("metadata row");
(key.value().as_bytes().to_vec(), value.value().to_vec())
})
.collect(),
];
for table in [LOGS, APPLY_RESULTS] {
snapshot.push(
tx.open_table(table)
.expect("snapshot indexed table")
.iter()
.expect("indexed iterator")
.map(|row| {
let (key, value) = row.expect("indexed row");
(key.value().to_be_bytes().to_vec(), value.value().to_vec())
})
.collect(),
);
}
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
snapshot.push(
tx.open_table(table)
.expect("snapshot business table")
.iter()
.expect("business iterator")
.map(|row| {
let (key, value) = row.expect("business row");
(key.value().to_vec(), value.value().to_vec())
})
.collect(),
);
}
snapshot
}
fn u17b_history() -> (tempfile::TempDir, CrashBackend, Store, Vec<DecideCreated>) {
let (dir, backend, mut store) = u17a_bound();
let first = u17a_command();
let mut rejected = first.clone();
rejected.operation_id = [10; 16];
rejected.prepared.version_id = [11; 16];
let mut second = first.clone();
second.operation_id = [12; 16];
second.prepared.version_id = [13; 16];
second.expected_head = Some(first.prepared.version_id);
second.prepared.content_length = 8193;
let mut suffix = second.clone();
suffix.operation_id = [14; 16];
suffix.prepared.version_id = [15; 16];
suffix.expected_head = Some(second.prepared.version_id);
let commands = vec![first.clone(), first, rejected, second, suffix];
let entries = commands
.iter()
.enumerate()
.map(|(index, command)| LogEntry {
id: u17a_id(u64::try_from(index).expect("fixture index")),
payload: command.encode().expect("canonical legacy Created"),
})
.collect::<Vec<_>>();
store.append(&entries).expect("legacy history and suffix");
store.save_committed(u17a_id(4)).expect("committed suffix");
for index in 0..4 {
store.apply_next_created(u17a_id(index)).expect("legacy history apply");
}
assert_eq!(committed_schema(&store), 3);
(dir, backend, store, commands)
}
#[test]
fn u17b_native_migration_populated_suffix() {
let (_dir, backend, mut store, commands) = u17b_history();
let before = u17b_snapshot(&store);
let results = (0..4)
.map(|index| {
store
.created_apply_result(index)
.expect("legacy result")
.expect("applied result")
})
.collect::<Vec<_>>();
assert!(
store.migrate_legacy_created_to_native().is_ok(),
"U17B_MIGRATION_SUCCESS canonical populated schema3 must migrate"
);
assert_eq!(committed_schema(&store), 4);
assert_eq!(&u17b_snapshot(&store)[3..], &before[3..], "business bytes preserved");
assert_eq!(store.capture_binding().expect("binding"), Some(u17a_binding()));
assert_eq!(store.log_state().expect("tail").last, Some(u17a_id(4)));
assert_eq!(store.read_committed().expect("committed"), Some(u17a_id(4)));
assert_eq!(
store.native_application_state().expect("application pair"),
(
Some(u17a_id(3)),
CaptureMembership::new(None, vec![], std::collections::BTreeMap::new()).expect("empty membership")
)
);
assert_eq!(
store.read_native_entries(0, 5).expect("all entries"),
commands
.iter()
.enumerate()
.map(|(index, command)| u17a_created(u64::try_from(index).expect("index"), command.clone()))
.collect::<Vec<_>>()
);
for result in results {
assert_eq!(
store.created_apply_result(result.log_id.index).expect("migrated result"),
Some(result.clone())
);
assert_eq!(
store
.native_apply_response(result.log_id.index)
.expect("native result")
.expect("response")
.created_result(),
Some(&result.result)
);
}
assert!(store.native_apply_response(4).expect("suffix response").is_none());
assert_eq!(
store
.created_operation(commands[0].operation_id)
.expect("first operation")
.expect("operation")
.result,
CreatedResult::Created {
version_id: commands[0].prepared.version_id,
event_id: commands[0].operation_id,
decision_log_id: u17a_id(0)
}
);
drop(store);
let mut store = Store::open(&backend.path, identity()).expect("migrated reopen");
assert_eq!(&u17b_snapshot(&store)[3..], &before[3..]);
let response = store.apply_next_native(u17a_id(4)).expect("real suffix application");
assert_eq!(
response.created_result(),
Some(&CreatedResult::Created {
version_id: commands[4].prepared.version_id,
event_id: commands[4].operation_id,
decision_log_id: u17a_id(4)
})
);
assert_eq!(
store.created_head(&commands[4].object).expect("suffix head"),
Some(commands[4].prepared.version_id)
);
assert_eq!(store.last_applied().expect("suffix frontier"), Some(u17a_id(4)));
}
#[test]
fn u17b_native_migration_opaque_refusal() {
let canonical = u17a_command().encode().expect("canonical command");
let mut noncanonical = canonical.clone();
noncanonical.push(0);
for payload in [
b"opaque payload".to_vec(),
canonical[..canonical.len() - 1].to_vec(),
noncanonical,
] {
let (_dir, backend, mut store) = u17a_bound();
store
.append(&[LogEntry { id: u17a_id(0), payload }])
.expect("invalid legacy suffix append");
let before = u17b_snapshot(&store);
assert!(store.migrate_legacy_created_to_native().is_err(), "invalid payload must refuse");
assert_eq!(u17b_snapshot(&store), before, "refusal changes no table bytes");
drop(store);
let store = Store::open(&backend.path, identity()).expect("refused suffix reopen");
assert_eq!(u17b_snapshot(&store), before);
assert_eq!(committed_schema(&store), 3);
}
}
#[test]
fn u17b_native_migration_empty_idempotent() {
let (_dir, backend, store) = u17a_bound();
drop(store);
let mut store = Store::from_database(backend.database(), identity()).expect("open legacy without upgrade");
assert_eq!(committed_schema(&store), 3);
store.migrate_legacy_created_to_native().expect("empty migration");
assert_eq!(committed_schema(&store), 4);
let before = u17b_snapshot(&store);
backend.media.lock().expect("reset migration syncs").syncs = 0;
// Reopen through the instrumented backend before checking the no-commit path.
drop(store);
let mut store = Store::from_database(backend.database(), identity()).expect("native reopen");
backend.media.lock().expect("reset reopen syncs").syncs = 0;
store.migrate_legacy_created_to_native().expect("native repeat");
assert_eq!(backend.media.lock().expect("repeat syncs").syncs, 0, "repeat must not commit");
assert_eq!(u17b_snapshot(&store), before);
store.writable = false;
assert_eq!(
store
.migrate_legacy_created_to_native()
.expect_err("disabled even on schema4")
.code(),
"write_disabled"
);
}
#[test]
fn u17b_native_migration_ack_and_cut() {
let (_dir, backend, mut store, _) = u17b_history();
let old = u17b_snapshot(&store);
backend.media.lock().expect("reset migration syncs").syncs = 0;
store.migrate_legacy_created_to_native().expect("migration ACK");
let reached = backend.media.lock().expect("actual migration syncs").syncs;
assert!(reached > 0, "migration must reach durability IO");
let new = u17b_snapshot(&store);
backend.cut();
drop(store);
assert_eq!(u17b_snapshot(&Store::open(&backend.path, identity()).expect("ACK recovery")), new);
for sync in 1..=reached {
for after in [false, true] {
let (_dir, backend, mut store, _) = u17b_history();
assert_eq!(u17b_snapshot(&store), old);
let (entered, resume) = backend.pause_sync(sync, after);
let writer = std::thread::spawn(move || {
let result = store.migrate_legacy_created_to_native();
(store, result)
});
entered.wait();
backend.cut();
resume.wait();
let (store, result) = writer.join().expect("migration cut writer");
drop(store);
if let Some(store) = u17a_cut_recovery(&backend) {
let actual = u17b_snapshot(&store);
assert!(
actual == old || actual == new,
"migration cut must never mix schema/log/result/business tables"
);
if result.is_ok() {
assert_eq!(actual, new, "ACK cannot recover old state");
}
} else {
assert!(result.is_err(), "ACK recovery must succeed");
}
}
}
let (_dir, backend, mut store, _) = u17b_history();
backend.arm(1, false, false);
assert_eq!(
store.migrate_legacy_created_to_native().expect_err("uncertain commit").code(),
"commit_indeterminate"
);
assert_eq!(
store
.migrate_legacy_created_to_native()
.expect_err("migration disabled")
.code(),
"write_disabled"
);
assert_eq!(store.append_native(&[]).expect_err("native append disabled").code(), "write_disabled");
assert_eq!(store.append(&[]).expect_err("legacy append disabled").code(), "write_disabled");
assert_eq!(
store.apply_next_native(u17a_id(4)).expect_err("native apply disabled").code(),
"write_disabled"
);
assert_eq!(
store
.apply_next_created(u17a_id(4))
.expect_err("legacy apply disabled")
.code(),
"write_disabled"
);
assert_eq!(
store
.pin_capture_binding(u17a_binding())
.expect_err("binding disabled")
.code(),
"write_disabled"
);
assert_eq!(store.save_committed(u17a_id(4)).expect_err("committed disabled").code(), "write_disabled");
assert_eq!(store.save_vote(vote()).expect_err("vote disabled").code(), "write_disabled");
assert_eq!(store.truncate_suffix(5).expect_err("truncate disabled").code(), "write_disabled");
}
fn u17a_id(index: u64) -> LogId {
LogId {
term: 11,
@@ -621,6 +909,7 @@ mod tests {
let response = store.apply_next_native(u17a_id(index)).expect("exact replay");
assert_eq!(store.native_apply_response(index).expect("receipt"), Some(response));
}
store.enable_native_empty().expect("U17A_ENABLE_VALID_NATIVE_REPLAY");
assert_eq!(backend.media.lock().expect("sync count").syncs, syncs);
drop(store);
let reopened = Store::open(&backend.path, identity()).expect("heterogeneous reopen");
@@ -898,6 +1187,56 @@ mod tests {
.expect("history frontier"),
Some(u17a_id(3))
);
let (_dir, backend, mut store) = u17a_store();
let first = u17a_command();
let mut rejected = first.clone();
rejected.prepared.version_id = [19; 16];
rejected.expected_head = Some(first.prepared.version_id);
let mut latest = rejected.clone();
latest.operation_id = [20; 16];
latest.prepared.version_id = [21; 16];
latest.prepared.preparation_id = [22; 16];
store
.append_native(&[
u17a_created(0, first),
u17a_created(1, rejected.clone()),
u17a_created(2, latest),
])
.expect("rejected history batch");
store.save_committed(u17a_id(2)).expect("commit rejected history");
for index in 0..=2 {
store.apply_next_native(u17a_id(index)).expect("rejected history apply");
}
assert_eq!(
store
.native_apply_response(1)
.expect("rejected receipt")
.expect("receipt exists")
.created_result(),
Some(&CreatedResult::OperationConflict)
);
drop(store);
let mut store = Store::open(&backend.path, identity()).expect("valid rejected history reopen");
let tx = store.write_transaction().expect("orphan version transaction");
let mut key = crate::created::object_bytes(&rejected.object).expect("orphan key");
key.extend(rejected.prepared.version_id);
let mut value = Vec::new();
crate::created::put_prepared(&mut value, &rejected.prepared);
value.extend(encode_id(u17a_id(1)));
value.extend(u17a_binding().revision.to_be_bytes());
tx.open_table(application::VERSIONS)
.expect("version table")
.insert(key.as_slice(), value.as_slice())
.expect("inject syntactically valid rejected version");
store.commit(tx).expect("persist rejected version");
drop(store);
assert_eq!(
Store::open(&backend.path, identity())
.err()
.expect("U17A_REJECTED_VERSION_NOT_MATERIALIZED")
.code(),
"corrupt_record"
);
}
#[test]
fn u17a_native_reopen_rejects_corruption() {