@feltdb/core 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1173 -72
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +52 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +96 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +26 -5
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +191 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +1153 -0
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +771 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +490 -157
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +369 -41
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-DaNwCLRX.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-j8IlhNqJ.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
|
@@ -295,7 +295,7 @@ pub type Result<T> = std::result::Result<T, FlowError>;
|
|
|
295
295
|
pub enum FlowError {
|
|
296
296
|
Io(std::io::Error),
|
|
297
297
|
Serde(serde_json::Error),
|
|
298
|
-
CorruptLogLine(
|
|
298
|
+
CorruptLogLine(Box<LogCorruption>),
|
|
299
299
|
CapabilityError(String),
|
|
300
300
|
/// A transaction precondition did not hold. Nothing was written.
|
|
301
301
|
///
|
|
@@ -308,6 +308,8 @@ pub enum FlowError {
|
|
|
308
308
|
expected: u64,
|
|
309
309
|
actual: u64,
|
|
310
310
|
},
|
|
311
|
+
/// A backup artifact was rejected. Nothing was restored.
|
|
312
|
+
BackupRejected(Box<BackupProblem>),
|
|
311
313
|
/// The durable database is in a format this build does not understand.
|
|
312
314
|
///
|
|
313
315
|
/// Returned by [`FeltDb::open`] **before any record is interpreted**, and
|
|
@@ -321,7 +323,7 @@ impl Display for FlowError {
|
|
|
321
323
|
match self {
|
|
322
324
|
FlowError::Io(e) => write!(f, "io error: {e}"),
|
|
323
325
|
FlowError::Serde(e) => write!(f, "serde error: {e}"),
|
|
324
|
-
FlowError::CorruptLogLine(
|
|
326
|
+
FlowError::CorruptLogLine(corruption) => write!(f, "CORRUPT_DURABLE_LOG: {corruption}"),
|
|
325
327
|
FlowError::CapabilityError(msg) => write!(f, "capability error: {msg}"),
|
|
326
328
|
FlowError::PreconditionFailed(failure) => write!(f, "PRECONDITION_FAILED: {failure}"),
|
|
327
329
|
FlowError::RevisionConflict { expected, actual } => write!(
|
|
@@ -331,6 +333,7 @@ impl Display for FlowError {
|
|
|
331
333
|
FlowError::IncompatibleFormat(incompatibility) => {
|
|
332
334
|
write!(f, "INCOMPATIBLE_DURABLE_FORMAT: {incompatibility}")
|
|
333
335
|
}
|
|
336
|
+
FlowError::BackupRejected(problem) => write!(f, "BACKUP_REJECTED: {problem}"),
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
339
|
}
|
|
@@ -393,6 +396,22 @@ struct Inner {
|
|
|
393
396
|
/// inside the same state mutation boundary as `rows` and rebuilt from
|
|
394
397
|
/// `rows` after recovery.
|
|
395
398
|
equality_index: EqualityIndex,
|
|
399
|
+
/// What replaying the durable log had to do to succeed.
|
|
400
|
+
///
|
|
401
|
+
/// Recorded rather than logged, so an operator can ask after the fact
|
|
402
|
+
/// whether this database opened cleanly.
|
|
403
|
+
log_recovery: LogRecovery,
|
|
404
|
+
/// The durable format this database was accepted as, recorded at the open
|
|
405
|
+
/// that accepted it.
|
|
406
|
+
durable_format: DurableFormat,
|
|
407
|
+
/// How far a single-record write is pushed before it returns.
|
|
408
|
+
durability_mode: DurabilityMode,
|
|
409
|
+
/// Writes since the last stable-storage barrier, for grouped durability.
|
|
410
|
+
writes_since_barrier: u32,
|
|
411
|
+
/// When a policy-driven compaction should rewrite the durable log.
|
|
412
|
+
compaction_policy: CompactionPolicy,
|
|
413
|
+
/// Operations pruned since the last log rewrite.
|
|
414
|
+
pruned_since_rewrite: usize,
|
|
396
415
|
/// Each resource's newest revision, as `(id, sequence)`.
|
|
397
416
|
///
|
|
398
417
|
/// Derived from the `state` rows exactly like `equality_index` is derived
|
|
@@ -771,6 +790,526 @@ pub struct StoredRow {
|
|
|
771
790
|
pub operation: Option<Operation>,
|
|
772
791
|
}
|
|
773
792
|
|
|
793
|
+
/// When a compaction should actually rewrite the durable log.
|
|
794
|
+
///
|
|
795
|
+
/// Pruning acknowledged operations from memory is cheap. **Rewriting the log is
|
|
796
|
+
/// not**: it serializes every row in the database and fsyncs it while holding
|
|
797
|
+
/// the one lock that serializes every read and every write. Doing that on a
|
|
798
|
+
/// short timer stops the world on that timer.
|
|
799
|
+
///
|
|
800
|
+
/// The policy separates the two. Pruning still happens whenever it can; the
|
|
801
|
+
/// rewrite waits until enough has accumulated to be worth the stall.
|
|
802
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
803
|
+
pub struct CompactionPolicy {
|
|
804
|
+
/// Operations that must have been pruned since the last rewrite before the
|
|
805
|
+
/// next one is worth its cost.
|
|
806
|
+
pub rewrite_after_pruned: usize,
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
impl Default for CompactionPolicy {
|
|
810
|
+
fn default() -> Self {
|
|
811
|
+
// Chosen so a busy database rewrites occasionally rather than
|
|
812
|
+
// constantly. At one operation per write this is one rewrite per
|
|
813
|
+
// thousand acknowledged writes instead of one every timer tick.
|
|
814
|
+
CompactionPolicy {
|
|
815
|
+
rewrite_after_pruned: 1000,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/// What a policy-driven compaction did.
|
|
821
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
822
|
+
pub enum CompactionOutcome {
|
|
823
|
+
/// Nothing was acknowledged by every active peer; there was no work.
|
|
824
|
+
Idle,
|
|
825
|
+
/// Operations were pruned from memory and the log rewrite was **deferred**,
|
|
826
|
+
/// because the accumulated gain does not yet justify the stall.
|
|
827
|
+
Deferred {
|
|
828
|
+
pruned: usize,
|
|
829
|
+
pending_since_rewrite: usize,
|
|
830
|
+
},
|
|
831
|
+
/// The durable log was rewritten.
|
|
832
|
+
Rewritten { pruned: usize },
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
impl CompactionOutcome {
|
|
836
|
+
/// Operations pruned from the in-memory log by this call.
|
|
837
|
+
pub fn pruned(&self) -> usize {
|
|
838
|
+
match self {
|
|
839
|
+
CompactionOutcome::Idle => 0,
|
|
840
|
+
CompactionOutcome::Deferred { pruned, .. }
|
|
841
|
+
| CompactionOutcome::Rewritten { pruned } => *pruned,
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/// Whether this call rewrote the durable log.
|
|
846
|
+
pub fn rewrote_log(&self) -> bool {
|
|
847
|
+
matches!(self, CompactionOutcome::Rewritten { .. })
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/// How far a single-record write is pushed before it returns.
|
|
852
|
+
///
|
|
853
|
+
/// This is a **contract**, not a tuning knob: it decides what a successful
|
|
854
|
+
/// mutation means. Nothing here makes a claim about surviving power loss —
|
|
855
|
+
/// see [`DurabilityMode::guarantee`] for what each mode does and does not
|
|
856
|
+
/// establish.
|
|
857
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
858
|
+
pub enum DurabilityMode {
|
|
859
|
+
/// Write and flush to the operating system. **The default.**
|
|
860
|
+
///
|
|
861
|
+
/// The bytes leave the process before the call returns, so a committed
|
|
862
|
+
/// write survives the process dying. They are not forced to the device.
|
|
863
|
+
Flushed,
|
|
864
|
+
/// Write, flush, and `fsync` before returning.
|
|
865
|
+
///
|
|
866
|
+
/// Issues a stable-storage barrier per mutation. Whether that barrier
|
|
867
|
+
/// actually reaches the platter depends on the filesystem, the mount
|
|
868
|
+
/// options and the drive's write cache — none of which this database can
|
|
869
|
+
/// observe, so this mode buys a barrier, not a proof.
|
|
870
|
+
Synced,
|
|
871
|
+
/// Flush every write, and `fsync` once every `every` writes.
|
|
872
|
+
///
|
|
873
|
+
/// Amortises the barrier. The cost is an explicit window: up to `every - 1`
|
|
874
|
+
/// acknowledged writes may not yet have been through one.
|
|
875
|
+
Grouped { every: u32 },
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
impl Default for DurabilityMode {
|
|
879
|
+
fn default() -> Self {
|
|
880
|
+
DurabilityMode::Flushed
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
impl DurabilityMode {
|
|
885
|
+
/// What a successful mutation guarantees under this mode, stated so that a
|
|
886
|
+
/// caller can read the contract rather than infer it from an API name.
|
|
887
|
+
pub fn guarantee(&self) -> &'static str {
|
|
888
|
+
match self {
|
|
889
|
+
DurabilityMode::Flushed => {
|
|
890
|
+
"the record has left the process; it survives a process crash. Stable-storage durability is not established."
|
|
891
|
+
}
|
|
892
|
+
DurabilityMode::Synced => {
|
|
893
|
+
"a stable-storage barrier was issued for this record. Whether it reached the device depends on the storage stack, which this database cannot observe."
|
|
894
|
+
}
|
|
895
|
+
DurabilityMode::Grouped { .. } => {
|
|
896
|
+
"the record has left the process. A stable-storage barrier is issued periodically, so an acknowledged write may not yet have been through one."
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/// How many acknowledged writes may not yet have been through a barrier.
|
|
902
|
+
///
|
|
903
|
+
/// `None` where no barrier is issued at all.
|
|
904
|
+
pub fn unbarriered_window(&self) -> Option<u32> {
|
|
905
|
+
match self {
|
|
906
|
+
DurabilityMode::Flushed => None,
|
|
907
|
+
DurabilityMode::Synced => Some(0),
|
|
908
|
+
DurabilityMode::Grouped { every } => Some(every.saturating_sub(1)),
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/// What is actually known about this database's durable storage.
|
|
914
|
+
///
|
|
915
|
+
/// Every variant corresponds to a fact the open established. There is no
|
|
916
|
+
/// variant meaning "probably fine", and none that says anything about power
|
|
917
|
+
/// loss — that is unproven, and health may not manufacture a guarantee the
|
|
918
|
+
/// database does not have.
|
|
919
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
920
|
+
pub enum StorageHealth {
|
|
921
|
+
/// Every durable byte replayed as a complete, valid record.
|
|
922
|
+
Clean,
|
|
923
|
+
/// Operational, **and the last write did not land**.
|
|
924
|
+
///
|
|
925
|
+
/// The log ended in an incomplete append, which was discarded. The database
|
|
926
|
+
/// is usable and is not in the same condition as one that replayed cleanly,
|
|
927
|
+
/// and reporting them identically is what makes an operator unable to tell
|
|
928
|
+
/// a healthy database from one that lost its most recent write.
|
|
929
|
+
RecoveredIncompleteWrite {
|
|
930
|
+
byte_offset: u64,
|
|
931
|
+
discarded_bytes: usize,
|
|
932
|
+
},
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
impl StorageHealth {
|
|
936
|
+
/// Whether the durable log replayed without discarding anything.
|
|
937
|
+
pub fn is_clean(&self) -> bool {
|
|
938
|
+
matches!(self, StorageHealth::Clean)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/// A short stable label for an operational surface.
|
|
942
|
+
pub fn label(&self) -> &'static str {
|
|
943
|
+
match self {
|
|
944
|
+
StorageHealth::Clean => "clean",
|
|
945
|
+
StorageHealth::RecoveredIncompleteWrite { .. } => "recovered-incomplete-write",
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
impl Display for StorageHealth {
|
|
951
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
952
|
+
match self {
|
|
953
|
+
StorageHealth::Clean => write!(f, "durable log replayed cleanly"),
|
|
954
|
+
StorageHealth::RecoveredIncompleteWrite {
|
|
955
|
+
byte_offset,
|
|
956
|
+
discarded_bytes,
|
|
957
|
+
} => write!(
|
|
958
|
+
f,
|
|
959
|
+
"durable log ended in an incomplete append: {discarded_bytes} bytes at offset \
|
|
960
|
+
{byte_offset} were discarded, so the last write did not land"
|
|
961
|
+
),
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/// What this database knows about itself, as facts rather than labels.
|
|
967
|
+
///
|
|
968
|
+
/// Deliberately small. It reports only conditions the open actually
|
|
969
|
+
/// established, and it is not an operations dashboard: replication health,
|
|
970
|
+
/// backup freshness and capacity are not here, because none of them has a
|
|
971
|
+
/// defined operational contract yet and health must not invent one.
|
|
972
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
973
|
+
pub struct DatabaseHealth {
|
|
974
|
+
/// The durable format this database was accepted as.
|
|
975
|
+
///
|
|
976
|
+
/// A database in a format this build does not understand never opens, so
|
|
977
|
+
/// reaching this at all is itself the compatibility fact.
|
|
978
|
+
pub durable_format: DurableFormat,
|
|
979
|
+
/// What replaying the durable log had to do.
|
|
980
|
+
pub recovery: LogRecovery,
|
|
981
|
+
/// The storage condition that follows from it.
|
|
982
|
+
pub storage: StorageHealth,
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
impl DatabaseHealth {
|
|
986
|
+
/// Whether this database is in the condition a clean open produces.
|
|
987
|
+
///
|
|
988
|
+
/// **Not** a claim that the database is correct, replicated, backed up or
|
|
989
|
+
/// durable against power loss. It says the durable log replayed with
|
|
990
|
+
/// nothing discarded, and nothing more.
|
|
991
|
+
pub fn is_nominal(&self) -> bool {
|
|
992
|
+
self.storage.is_clean()
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/// The backup artifact format this build writes and understands.
|
|
997
|
+
///
|
|
998
|
+
/// Versioned separately from the on-disk database: a backup is an artifact an
|
|
999
|
+
/// operator keeps, moves and restores long after the database that produced it,
|
|
1000
|
+
/// so its compatibility boundary is its own.
|
|
1001
|
+
pub const BACKUP_VERSION: u32 = 1;
|
|
1002
|
+
|
|
1003
|
+
const BACKUP_RECORD_TYPE: &str = "feltdb.backup.v1";
|
|
1004
|
+
|
|
1005
|
+
/// The first line of a backup artifact. Everything needed to verify the rest.
|
|
1006
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1007
|
+
struct BackupHeader {
|
|
1008
|
+
record_type: String,
|
|
1009
|
+
backup_version: u32,
|
|
1010
|
+
/// The durable format the rows are in.
|
|
1011
|
+
format_version: u32,
|
|
1012
|
+
created_unix_ms: u64,
|
|
1013
|
+
row_count: usize,
|
|
1014
|
+
observed_versions: HashMap<String, u64>,
|
|
1015
|
+
/// Digest of the rows exactly as written. Detects any alteration.
|
|
1016
|
+
content_digest: String,
|
|
1017
|
+
/// Digest of what the database *means* — see [`BackupVerification`].
|
|
1018
|
+
meaning_digest: String,
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/// Why a backup artifact was rejected.
|
|
1022
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1023
|
+
pub struct BackupProblem {
|
|
1024
|
+
/// What is wrong, in terms of the artifact rather than the parser.
|
|
1025
|
+
pub reason: String,
|
|
1026
|
+
/// Where, when the problem has a location.
|
|
1027
|
+
pub line_number: Option<usize>,
|
|
1028
|
+
/// What the operator can do.
|
|
1029
|
+
pub action: String,
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
impl Display for BackupProblem {
|
|
1033
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
1034
|
+
match self.line_number {
|
|
1035
|
+
Some(line) => write!(f, "record {line}: {}. {}", self.reason, self.action),
|
|
1036
|
+
None => write!(f, "{}. {}", self.reason, self.action),
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/// What a backup artifact contains, established by reading it.
|
|
1042
|
+
///
|
|
1043
|
+
/// `meaning_digest` is the part that matters. A content digest proves the file
|
|
1044
|
+
/// is intact; it does not prove that restoring it produces the same database.
|
|
1045
|
+
/// The meaning digest covers what the database *is*, independent of how it was
|
|
1046
|
+
/// written down:
|
|
1047
|
+
///
|
|
1048
|
+
/// - every live record: capability, key and canonical value;
|
|
1049
|
+
/// - every revision: its resource, identity, parent, sequence and content
|
|
1050
|
+
/// identity, and the authority that produced it;
|
|
1051
|
+
/// - every resource's retention policy and horizon.
|
|
1052
|
+
///
|
|
1053
|
+
/// It deliberately excludes wall-clock timestamps and sync envelopes, which
|
|
1054
|
+
/// differ between two databases that mean the same thing. Restore recomputes it
|
|
1055
|
+
/// from the **restored database** and refuses if it disagrees with the
|
|
1056
|
+
/// artifact, so a restore proves equivalence rather than mere parseability.
|
|
1057
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1058
|
+
pub struct BackupVerification {
|
|
1059
|
+
pub backup_version: u32,
|
|
1060
|
+
pub format_version: u32,
|
|
1061
|
+
pub created_unix_ms: u64,
|
|
1062
|
+
pub rows: usize,
|
|
1063
|
+
pub content_digest: String,
|
|
1064
|
+
pub meaning_digest: String,
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
fn backup_problem(reason: &str, line_number: Option<usize>, action: &str) -> FlowError {
|
|
1068
|
+
FlowError::BackupRejected(Box::new(BackupProblem {
|
|
1069
|
+
reason: reason.to_string(),
|
|
1070
|
+
line_number,
|
|
1071
|
+
action: action.to_string(),
|
|
1072
|
+
}))
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/// Digest the rows exactly as they will be written.
|
|
1076
|
+
fn backup_content_digest(rows: &[StoredRow]) -> Result<String> {
|
|
1077
|
+
let mut hasher = Sha256::new();
|
|
1078
|
+
for row in rows {
|
|
1079
|
+
hasher.update(serde_json::to_vec(row)?);
|
|
1080
|
+
hasher.update(b"\n");
|
|
1081
|
+
}
|
|
1082
|
+
Ok(format!("{:x}", hasher.finalize()))
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/// Digest what a set of rows *means*, independent of how it was written.
|
|
1086
|
+
///
|
|
1087
|
+
/// Canonical by construction: rows are ordered by capability and key, and only
|
|
1088
|
+
/// the fields that carry meaning take part. Two databases with the same digest
|
|
1089
|
+
/// hold the same records, the same revision graph and the same retention state.
|
|
1090
|
+
fn backup_meaning_digest(rows: &[StoredRow]) -> Result<String> {
|
|
1091
|
+
let mut meaningful: Vec<(&str, &str, String)> = rows
|
|
1092
|
+
.iter()
|
|
1093
|
+
.filter(|row| !row.deleted)
|
|
1094
|
+
.map(|row| {
|
|
1095
|
+
(
|
|
1096
|
+
row.capability.as_str(),
|
|
1097
|
+
row.key.as_str(),
|
|
1098
|
+
row.value.to_string(),
|
|
1099
|
+
)
|
|
1100
|
+
})
|
|
1101
|
+
.collect();
|
|
1102
|
+
meaningful.sort();
|
|
1103
|
+
|
|
1104
|
+
let mut hasher = Sha256::new();
|
|
1105
|
+
hasher.update(b"feltdb.backup.meaning.v1");
|
|
1106
|
+
for (capability, key, value) in meaningful {
|
|
1107
|
+
hasher.update(capability.as_bytes());
|
|
1108
|
+
hasher.update([0u8]);
|
|
1109
|
+
hasher.update(key.as_bytes());
|
|
1110
|
+
hasher.update([0u8]);
|
|
1111
|
+
hasher.update(value.as_bytes());
|
|
1112
|
+
hasher.update([0u8]);
|
|
1113
|
+
}
|
|
1114
|
+
Ok(format!("{:x}", hasher.finalize()))
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/// Read and check a backup artifact **without opening it as a database**.
|
|
1118
|
+
///
|
|
1119
|
+
/// Structural and semantic checks only, and it never writes: verifying a backup
|
|
1120
|
+
/// must be something an operator can do to an artifact they are not yet willing
|
|
1121
|
+
/// to restore.
|
|
1122
|
+
pub fn verify_backup(path: &Path) -> Result<BackupVerification> {
|
|
1123
|
+
let text = std::fs::read_to_string(path)?;
|
|
1124
|
+
let mut lines = text.lines();
|
|
1125
|
+
|
|
1126
|
+
let Some(header_line) = lines.next() else {
|
|
1127
|
+
return Err(backup_problem(
|
|
1128
|
+
"the artifact is empty",
|
|
1129
|
+
None,
|
|
1130
|
+
"Restore from a different backup.",
|
|
1131
|
+
));
|
|
1132
|
+
};
|
|
1133
|
+
let header: BackupHeader = serde_json::from_str(header_line).map_err(|error| {
|
|
1134
|
+
backup_problem(
|
|
1135
|
+
&format!("the header is not a FeltDB backup header: {error}"),
|
|
1136
|
+
Some(1),
|
|
1137
|
+
"Check that this file is a FeltDB backup and was not truncated at the start.",
|
|
1138
|
+
)
|
|
1139
|
+
})?;
|
|
1140
|
+
if header.record_type != BACKUP_RECORD_TYPE {
|
|
1141
|
+
return Err(backup_problem(
|
|
1142
|
+
&format!("unexpected record type {}", header.record_type),
|
|
1143
|
+
Some(1),
|
|
1144
|
+
"Check that this file is a FeltDB backup.",
|
|
1145
|
+
));
|
|
1146
|
+
}
|
|
1147
|
+
if header.backup_version != BACKUP_VERSION {
|
|
1148
|
+
return Err(backup_problem(
|
|
1149
|
+
&format!(
|
|
1150
|
+
"backup format version {} is not the version this build reads ({BACKUP_VERSION})",
|
|
1151
|
+
header.backup_version
|
|
1152
|
+
),
|
|
1153
|
+
Some(1),
|
|
1154
|
+
"Restore with a FeltDB build that reads this backup version.",
|
|
1155
|
+
));
|
|
1156
|
+
}
|
|
1157
|
+
if header.format_version != DURABLE_FORMAT_VERSION {
|
|
1158
|
+
return Err(backup_problem(
|
|
1159
|
+
&format!(
|
|
1160
|
+
"the rows are in durable format version {} and this build requires version {DURABLE_FORMAT_VERSION}",
|
|
1161
|
+
header.format_version
|
|
1162
|
+
),
|
|
1163
|
+
Some(1),
|
|
1164
|
+
"Restore with a FeltDB build matching the format the backup was taken in.",
|
|
1165
|
+
));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
let mut rows = Vec::with_capacity(header.row_count);
|
|
1169
|
+
for (index, line) in lines.enumerate() {
|
|
1170
|
+
if line.trim().is_empty() {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
let row: StoredRow = serde_json::from_str(line).map_err(|error| {
|
|
1174
|
+
backup_problem(
|
|
1175
|
+
&format!("not a durable record: {error}"),
|
|
1176
|
+
Some(index + 2),
|
|
1177
|
+
"The backup is damaged. Restore from another copy.",
|
|
1178
|
+
)
|
|
1179
|
+
})?;
|
|
1180
|
+
rows.push(row);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if rows.len() != header.row_count {
|
|
1184
|
+
return Err(backup_problem(
|
|
1185
|
+
&format!(
|
|
1186
|
+
"the header declares {} records and the artifact holds {}",
|
|
1187
|
+
header.row_count,
|
|
1188
|
+
rows.len()
|
|
1189
|
+
),
|
|
1190
|
+
None,
|
|
1191
|
+
"The backup is truncated or was appended to. Restore from another copy.",
|
|
1192
|
+
));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
let content_digest = backup_content_digest(&rows)?;
|
|
1196
|
+
if content_digest != header.content_digest {
|
|
1197
|
+
return Err(backup_problem(
|
|
1198
|
+
"the records do not match the digest in the header",
|
|
1199
|
+
None,
|
|
1200
|
+
"The backup has been altered or corrupted. Restore from another copy.",
|
|
1201
|
+
));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
let meaning_digest = backup_meaning_digest(&rows)?;
|
|
1205
|
+
if meaning_digest != header.meaning_digest {
|
|
1206
|
+
return Err(backup_problem(
|
|
1207
|
+
"the records do not mean what the header says they mean",
|
|
1208
|
+
None,
|
|
1209
|
+
"The backup has been altered. Restore from another copy.",
|
|
1210
|
+
));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
Ok(BackupVerification {
|
|
1214
|
+
backup_version: header.backup_version,
|
|
1215
|
+
format_version: header.format_version,
|
|
1216
|
+
created_unix_ms: header.created_unix_ms,
|
|
1217
|
+
rows: rows.len(),
|
|
1218
|
+
content_digest,
|
|
1219
|
+
meaning_digest,
|
|
1220
|
+
})
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/// Where durable corruption was found, and what is wrong with it.
|
|
1224
|
+
///
|
|
1225
|
+
/// Carries a location so an operator can find the damage, and an excerpt so
|
|
1226
|
+
/// they can recognise it, without the error having to quote a whole record.
|
|
1227
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1228
|
+
pub struct LogCorruption {
|
|
1229
|
+
/// 1-based line number in the durable log.
|
|
1230
|
+
pub line_number: usize,
|
|
1231
|
+
/// Byte offset of the start of the record.
|
|
1232
|
+
pub byte_offset: u64,
|
|
1233
|
+
/// What is wrong, in terms of the record rather than the parser.
|
|
1234
|
+
pub reason: String,
|
|
1235
|
+
/// The beginning of the record, truncated.
|
|
1236
|
+
pub excerpt: String,
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
impl Display for LogCorruption {
|
|
1240
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
1241
|
+
write!(
|
|
1242
|
+
f,
|
|
1243
|
+
"durable log record {} at byte {} is corrupt: {}. Excerpt: {}. \
|
|
1244
|
+
The database was not opened and was not modified.",
|
|
1245
|
+
self.line_number, self.byte_offset, self.reason, self.excerpt
|
|
1246
|
+
)
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/// What replaying the durable log had to do to succeed.
|
|
1251
|
+
///
|
|
1252
|
+
/// A clean open and an open that discarded an incomplete final append are
|
|
1253
|
+
/// different facts, and the difference must survive past an internal debug
|
|
1254
|
+
/// message: an operator needs to know their database opened *and* that its last
|
|
1255
|
+
/// write did not land.
|
|
1256
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1257
|
+
pub enum LogRecovery {
|
|
1258
|
+
/// Every durable byte was a complete, valid record.
|
|
1259
|
+
Clean,
|
|
1260
|
+
/// The log ended in an incomplete append.
|
|
1261
|
+
///
|
|
1262
|
+
/// The final bytes carried no record terminator, so they cannot be a record
|
|
1263
|
+
/// this database finished writing. They were not replayed, and the log was
|
|
1264
|
+
/// truncated back to the last complete record so that the next append does
|
|
1265
|
+
/// not splice onto a partial line.
|
|
1266
|
+
///
|
|
1267
|
+
/// **This is the only condition under which durable bytes are discarded.**
|
|
1268
|
+
/// A *complete* record that is invalid is corruption, not a torn write, and
|
|
1269
|
+
/// refuses the open however close to the end of the file it sits.
|
|
1270
|
+
RecoveredTornTail {
|
|
1271
|
+
/// Byte offset where the incomplete append began.
|
|
1272
|
+
byte_offset: u64,
|
|
1273
|
+
/// How many bytes were discarded.
|
|
1274
|
+
discarded_bytes: usize,
|
|
1275
|
+
},
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
impl Default for LogRecovery {
|
|
1279
|
+
fn default() -> Self {
|
|
1280
|
+
LogRecovery::Clean
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
impl Default for DurableFormat {
|
|
1285
|
+
fn default() -> Self {
|
|
1286
|
+
DurableFormat::Versioned(DURABLE_FORMAT_VERSION)
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
impl LogRecovery {
|
|
1291
|
+
/// Whether the open replayed the log without discarding anything.
|
|
1292
|
+
pub fn is_clean(&self) -> bool {
|
|
1293
|
+
matches!(self, LogRecovery::Clean)
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
impl Display for LogRecovery {
|
|
1298
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
1299
|
+
match self {
|
|
1300
|
+
LogRecovery::Clean => write!(f, "clean"),
|
|
1301
|
+
LogRecovery::RecoveredTornTail {
|
|
1302
|
+
byte_offset,
|
|
1303
|
+
discarded_bytes,
|
|
1304
|
+
} => write!(
|
|
1305
|
+
f,
|
|
1306
|
+
"recovered from an incomplete final append: {discarded_bytes} bytes at offset \
|
|
1307
|
+
{byte_offset} were not a complete record and were discarded"
|
|
1308
|
+
),
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
774
1313
|
/// The durable format this build writes and understands.
|
|
775
1314
|
///
|
|
776
1315
|
/// Independent of the package version on purpose: an application release that
|
|
@@ -989,46 +1528,124 @@ fn append_derived_row_locked(
|
|
|
989
1528
|
deleted: false,
|
|
990
1529
|
operation: None,
|
|
991
1530
|
};
|
|
992
|
-
|
|
1531
|
+
append_event_locked(inner, &row)?;
|
|
993
1532
|
inner.put_row(row);
|
|
994
1533
|
Ok(())
|
|
995
1534
|
}
|
|
996
1535
|
|
|
997
|
-
///
|
|
998
|
-
///
|
|
999
|
-
/// This is where the resource-scoped model meets the write path. The resource
|
|
1000
|
-
/// is the record's key, the parent is that resource's current head, and the
|
|
1001
|
-
/// sequence is allocated from the parent — so a resource that returns to a value
|
|
1002
|
-
/// it previously held records a *new* revision rather than resurrecting the old
|
|
1003
|
-
/// one.
|
|
1536
|
+
/// Work out the revision an authoritative write will produce, without writing
|
|
1537
|
+
/// anything.
|
|
1004
1538
|
///
|
|
1005
|
-
///
|
|
1006
|
-
///
|
|
1007
|
-
///
|
|
1008
|
-
|
|
1539
|
+
/// Separated from persisting it so the operation that carries this mutation can
|
|
1540
|
+
/// name the revision it produced. A peer then **reconstructs that revision**
|
|
1541
|
+
/// rather than minting a local substitute for the resulting state — which is
|
|
1542
|
+
/// the difference between replicating history and replicating the present.
|
|
1543
|
+
fn plan_revision(inner: &Inner, resource: &str, content: String) -> state_model::StateRevision {
|
|
1009
1544
|
let parent = inner.revision_heads.get(resource).cloned();
|
|
1010
1545
|
let (parent_id, sequence) = match &parent {
|
|
1011
1546
|
Some((id, sequence)) => (Some(id.clone()), sequence + 1),
|
|
1012
1547
|
None => (None, 0),
|
|
1013
1548
|
};
|
|
1014
|
-
|
|
1015
|
-
let revision = state_model::StateRevision::at(
|
|
1549
|
+
state_model::StateRevision::at(
|
|
1016
1550
|
resource.to_string(),
|
|
1017
1551
|
content,
|
|
1018
1552
|
parent_id,
|
|
1019
1553
|
sequence,
|
|
1020
|
-
|
|
1021
|
-
)
|
|
1554
|
+
inner.instance_id.clone(),
|
|
1555
|
+
)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/// What a peer needs to rebuild this revision exactly.
|
|
1559
|
+
fn provenance_of(revision: &state_model::StateRevision) -> operation::RevisionProvenance {
|
|
1560
|
+
operation::RevisionProvenance {
|
|
1561
|
+
resource: revision.resource.clone(),
|
|
1562
|
+
id: revision.id.as_hex().to_string(),
|
|
1563
|
+
parent_id: revision
|
|
1564
|
+
.parent_id
|
|
1565
|
+
.as_ref()
|
|
1566
|
+
.map(|id| id.as_hex().to_string()),
|
|
1567
|
+
sequence: revision.sequence,
|
|
1568
|
+
content_id: revision.content_id.as_hex().to_string(),
|
|
1569
|
+
authority: revision.authority.clone(),
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/// Persist a revision under the caller's lock, and apply the resource's
|
|
1574
|
+
/// retention policy.
|
|
1575
|
+
///
|
|
1576
|
+
/// Idempotent: a revision already stored is left exactly as it is. Committing
|
|
1577
|
+
/// the same historical fact twice — which replication does routinely — must not
|
|
1578
|
+
/// rewrite it.
|
|
1579
|
+
fn persist_revision_locked(inner: &mut Inner, revision: state_model::StateRevision) -> Result<()> {
|
|
1580
|
+
let key = format!("state:revision:{}", revision.id.as_hex());
|
|
1581
|
+
let already = inner
|
|
1582
|
+
.rows
|
|
1583
|
+
.get("state")
|
|
1584
|
+
.and_then(|bucket| bucket.get(&key))
|
|
1585
|
+
.is_some_and(|row| !row.deleted);
|
|
1586
|
+
|
|
1587
|
+
let resource = revision.resource.clone();
|
|
1022
1588
|
let id = revision.id.clone();
|
|
1023
|
-
let
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1589
|
+
let sequence = revision.sequence;
|
|
1590
|
+
|
|
1591
|
+
if !already {
|
|
1592
|
+
let value = serde_json::to_value(&revision)?;
|
|
1593
|
+
append_derived_row_locked(inner, "state", &key, value)?;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// The head advances only forward. A fork leaves two revisions at one
|
|
1597
|
+
// sequence; whichever is seen last does not become "the" head by accident.
|
|
1598
|
+
let advance = inner
|
|
1027
1599
|
.revision_heads
|
|
1028
|
-
.
|
|
1029
|
-
|
|
1600
|
+
.get(&resource)
|
|
1601
|
+
.is_none_or(|(_, current)| sequence > *current);
|
|
1602
|
+
if advance {
|
|
1603
|
+
inner
|
|
1604
|
+
.revision_heads
|
|
1605
|
+
.insert(resource.clone(), (id, sequence));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
apply_retention_locked(inner, &resource)
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/// Rebuild the revision an operation carries, and check it is the one the
|
|
1612
|
+
/// originating authority actually committed.
|
|
1613
|
+
///
|
|
1614
|
+
/// The identity is recomputed from the parts rather than trusted: if the value
|
|
1615
|
+
/// that arrived is not the value the revision was computed from, or the stated
|
|
1616
|
+
/// identity does not follow from the stated resource, parent and sequence, the
|
|
1617
|
+
/// operation is refused rather than turned into a plausible local history.
|
|
1618
|
+
fn reconstruct_revision(
|
|
1619
|
+
provenance: &operation::RevisionProvenance,
|
|
1620
|
+
value: &Value,
|
|
1621
|
+
) -> Result<state_model::StateRevision> {
|
|
1622
|
+
let content = value.to_string();
|
|
1623
|
+
let revision = state_model::StateRevision::at(
|
|
1624
|
+
provenance.resource.clone(),
|
|
1625
|
+
content,
|
|
1626
|
+
provenance
|
|
1627
|
+
.parent_id
|
|
1628
|
+
.as_ref()
|
|
1629
|
+
.map(|id| state_model::StateId::from_hex(id.clone())),
|
|
1630
|
+
provenance.sequence,
|
|
1631
|
+
provenance.authority.clone(),
|
|
1632
|
+
);
|
|
1633
|
+
if revision.content_id.as_hex() != provenance.content_id {
|
|
1634
|
+
return Err(FlowError::CapabilityError(format!(
|
|
1635
|
+
"replicated revision {} does not match the value it arrived with",
|
|
1636
|
+
provenance.id
|
|
1637
|
+
)));
|
|
1638
|
+
}
|
|
1639
|
+
if revision.id.as_hex() != provenance.id {
|
|
1640
|
+
return Err(FlowError::CapabilityError(format!(
|
|
1641
|
+
"replicated revision identity {} does not follow from its own resource, parent and sequence",
|
|
1642
|
+
provenance.id
|
|
1643
|
+
)));
|
|
1644
|
+
}
|
|
1645
|
+
Ok(revision)
|
|
1030
1646
|
}
|
|
1031
1647
|
|
|
1648
|
+
/// Expire whatever the resource's configured policy no longer retains.
|
|
1032
1649
|
/// Expire whatever the resource's configured policy no longer retains.
|
|
1033
1650
|
///
|
|
1034
1651
|
/// The common path is one map lookup: with no policy configured there is
|
|
@@ -1114,9 +1731,12 @@ impl FeltDb {
|
|
|
1114
1731
|
// and before anything is written, so a database this build does not
|
|
1115
1732
|
// understand is refused with its bytes untouched rather than opened
|
|
1116
1733
|
// with whatever happened to deserialize.
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1734
|
+
let accepted_format = match inspect_durable_format(&path)? {
|
|
1735
|
+
FormatCompatibility::Incompatible(incompatibility) => {
|
|
1736
|
+
return Err(FlowError::IncompatibleFormat(Box::new(incompatibility)));
|
|
1737
|
+
}
|
|
1738
|
+
FormatCompatibility::Compatible(format) => format,
|
|
1739
|
+
};
|
|
1120
1740
|
|
|
1121
1741
|
if let Some(parent) = path.parent() {
|
|
1122
1742
|
fs::create_dir_all(parent)?;
|
|
@@ -1144,6 +1764,7 @@ impl FeltDb {
|
|
|
1144
1764
|
let mut inner = Inner {
|
|
1145
1765
|
path: path.clone(),
|
|
1146
1766
|
instance_id: instance_id.clone(),
|
|
1767
|
+
durable_format: accepted_format,
|
|
1147
1768
|
..Inner::default()
|
|
1148
1769
|
};
|
|
1149
1770
|
|
|
@@ -1161,6 +1782,239 @@ impl FeltDb {
|
|
|
1161
1782
|
})
|
|
1162
1783
|
}
|
|
1163
1784
|
|
|
1785
|
+
/// Write a self-contained durable backup artifact.
|
|
1786
|
+
///
|
|
1787
|
+
/// The artifact does not depend on this database remaining present: it
|
|
1788
|
+
/// carries every live record, the revision graph, retention state and the
|
|
1789
|
+
/// observed operation versions, plus the digests needed to check all of it
|
|
1790
|
+
/// without a live database.
|
|
1791
|
+
pub fn write_backup(&self, path: &Path) -> Result<BackupVerification> {
|
|
1792
|
+
let (rows, observed_versions) = {
|
|
1793
|
+
let inner = self.state();
|
|
1794
|
+
let mut rows: Vec<StoredRow> = inner
|
|
1795
|
+
.rows
|
|
1796
|
+
.values()
|
|
1797
|
+
.flat_map(|bucket| bucket.values().cloned())
|
|
1798
|
+
.filter(|row| !row.deleted)
|
|
1799
|
+
.collect();
|
|
1800
|
+
rows.sort_by(|left, right| {
|
|
1801
|
+
left.capability
|
|
1802
|
+
.cmp(&right.capability)
|
|
1803
|
+
.then_with(|| left.key.cmp(&right.key))
|
|
1804
|
+
});
|
|
1805
|
+
(rows, inner.change_log.versions())
|
|
1806
|
+
};
|
|
1807
|
+
|
|
1808
|
+
let content_digest = backup_content_digest(&rows)?;
|
|
1809
|
+
let meaning_digest = backup_meaning_digest(&rows)?;
|
|
1810
|
+
let header = BackupHeader {
|
|
1811
|
+
record_type: BACKUP_RECORD_TYPE.to_string(),
|
|
1812
|
+
backup_version: BACKUP_VERSION,
|
|
1813
|
+
format_version: DURABLE_FORMAT_VERSION,
|
|
1814
|
+
created_unix_ms: now_ms() as u64,
|
|
1815
|
+
row_count: rows.len(),
|
|
1816
|
+
observed_versions,
|
|
1817
|
+
content_digest: content_digest.clone(),
|
|
1818
|
+
meaning_digest: meaning_digest.clone(),
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
if let Some(parent) = path.parent() {
|
|
1822
|
+
fs::create_dir_all(parent)?;
|
|
1823
|
+
}
|
|
1824
|
+
let mut file = OpenOptions::new()
|
|
1825
|
+
.create(true)
|
|
1826
|
+
.write(true)
|
|
1827
|
+
.truncate(true)
|
|
1828
|
+
.open(path)?;
|
|
1829
|
+
write_json_line(&mut file, &header)?;
|
|
1830
|
+
for row in &rows {
|
|
1831
|
+
write_json_line(&mut file, row)?;
|
|
1832
|
+
}
|
|
1833
|
+
file.flush()?;
|
|
1834
|
+
file.sync_all()?;
|
|
1835
|
+
|
|
1836
|
+
Ok(BackupVerification {
|
|
1837
|
+
backup_version: BACKUP_VERSION,
|
|
1838
|
+
format_version: DURABLE_FORMAT_VERSION,
|
|
1839
|
+
created_unix_ms: header.created_unix_ms,
|
|
1840
|
+
rows: rows.len(),
|
|
1841
|
+
content_digest,
|
|
1842
|
+
meaning_digest,
|
|
1843
|
+
})
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/// Restore a backup artifact into a **new** database.
|
|
1847
|
+
///
|
|
1848
|
+
/// Refuses to write over an existing database: a restore that could
|
|
1849
|
+
/// overwrite the thing an operator is trying to recover is not a recovery
|
|
1850
|
+
/// tool. The artifact is verified first, and the restored database is
|
|
1851
|
+
/// verified again afterwards — its meaning is recomputed from the live
|
|
1852
|
+
/// database and compared with the artifact's. A restore therefore proves
|
|
1853
|
+
/// that the result *means the same thing*, not merely that the file parsed.
|
|
1854
|
+
pub fn restore_backup(backup: &Path, target: &Path) -> Result<(Self, BackupVerification)> {
|
|
1855
|
+
let verification = verify_backup(backup)?;
|
|
1856
|
+
if target.exists() {
|
|
1857
|
+
return Err(backup_problem(
|
|
1858
|
+
"the restore target already exists",
|
|
1859
|
+
None,
|
|
1860
|
+
"Restore into a new path. FeltDB will not write over an existing database.",
|
|
1861
|
+
));
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
let text = std::fs::read_to_string(backup)?;
|
|
1865
|
+
let mut lines = text.lines();
|
|
1866
|
+
let header: BackupHeader = serde_json::from_str(lines.next().unwrap_or_default())?;
|
|
1867
|
+
|
|
1868
|
+
if let Some(parent) = target.parent() {
|
|
1869
|
+
fs::create_dir_all(parent)?;
|
|
1870
|
+
}
|
|
1871
|
+
{
|
|
1872
|
+
let mut file = OpenOptions::new()
|
|
1873
|
+
.create_new(true)
|
|
1874
|
+
.write(true)
|
|
1875
|
+
.open(target)?;
|
|
1876
|
+
write_json_line(
|
|
1877
|
+
&mut file,
|
|
1878
|
+
&FormatRecord {
|
|
1879
|
+
record_type: FORMAT_RECORD_TYPE.to_string(),
|
|
1880
|
+
format_version: header.format_version,
|
|
1881
|
+
},
|
|
1882
|
+
)?;
|
|
1883
|
+
for line in lines {
|
|
1884
|
+
if line.trim().is_empty() {
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
writeln!(file, "{line}")?;
|
|
1888
|
+
}
|
|
1889
|
+
file.flush()?;
|
|
1890
|
+
file.sync_all()?;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
let restored = FeltDb::open(target)?;
|
|
1894
|
+
|
|
1895
|
+
// The equivalence check. Anything that changed the database's meaning
|
|
1896
|
+
// between the artifact and the live result fails here.
|
|
1897
|
+
let live_rows = {
|
|
1898
|
+
let inner = restored.state();
|
|
1899
|
+
let mut rows: Vec<StoredRow> = inner
|
|
1900
|
+
.rows
|
|
1901
|
+
.values()
|
|
1902
|
+
.flat_map(|bucket| bucket.values().cloned())
|
|
1903
|
+
.filter(|row| !row.deleted)
|
|
1904
|
+
.collect();
|
|
1905
|
+
rows.sort_by(|left, right| {
|
|
1906
|
+
left.capability
|
|
1907
|
+
.cmp(&right.capability)
|
|
1908
|
+
.then_with(|| left.key.cmp(&right.key))
|
|
1909
|
+
});
|
|
1910
|
+
rows
|
|
1911
|
+
};
|
|
1912
|
+
let restored_meaning = backup_meaning_digest(&live_rows)?;
|
|
1913
|
+
if restored_meaning != verification.meaning_digest {
|
|
1914
|
+
return Err(backup_problem(
|
|
1915
|
+
"the restored database does not mean what the backup says it means",
|
|
1916
|
+
None,
|
|
1917
|
+
"The backup or the restore path is damaged. Do not use this database.",
|
|
1918
|
+
));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
Ok((restored, verification))
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/// A digest of this database's **application state**: the records an
|
|
1925
|
+
/// application reads, and nothing else.
|
|
1926
|
+
///
|
|
1927
|
+
/// Excludes revisions and retention state, so it answers only "do these two
|
|
1928
|
+
/// databases hold the same values?". Pair it with
|
|
1929
|
+
/// `StateStore::history_digest` to ask the separate and stronger question
|
|
1930
|
+
/// of whether they hold the same history — two databases can match on this
|
|
1931
|
+
/// and differ completely on that, which is exactly the failure replication
|
|
1932
|
+
/// used to have.
|
|
1933
|
+
pub fn state_digest(&self) -> Result<String> {
|
|
1934
|
+
let inner = self.state();
|
|
1935
|
+
let mut entries: Vec<(String, String, String)> = inner
|
|
1936
|
+
.rows
|
|
1937
|
+
.iter()
|
|
1938
|
+
.filter(|(capability, _)| is_versioned(capability))
|
|
1939
|
+
.flat_map(|(capability, bucket)| {
|
|
1940
|
+
bucket
|
|
1941
|
+
.values()
|
|
1942
|
+
.filter(|row| !row.deleted)
|
|
1943
|
+
.map(move |row| (capability.clone(), row.key.clone(), row.value.to_string()))
|
|
1944
|
+
})
|
|
1945
|
+
.collect();
|
|
1946
|
+
entries.sort();
|
|
1947
|
+
|
|
1948
|
+
let mut hasher = Sha256::new();
|
|
1949
|
+
hasher.update(b"feltdb.state.v1");
|
|
1950
|
+
for (capability, key, value) in entries {
|
|
1951
|
+
hasher.update(capability.as_bytes());
|
|
1952
|
+
hasher.update([0u8]);
|
|
1953
|
+
hasher.update(key.as_bytes());
|
|
1954
|
+
hasher.update([0u8]);
|
|
1955
|
+
hasher.update(value.as_bytes());
|
|
1956
|
+
hasher.update([0u8]);
|
|
1957
|
+
}
|
|
1958
|
+
Ok(format!("{:x}", hasher.finalize()))
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/// How far a single-record write is pushed before it returns.
|
|
1962
|
+
pub fn durability_mode(&self) -> DurabilityMode {
|
|
1963
|
+
self.state().durability_mode
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/// Choose what a successful single-record write guarantees.
|
|
1967
|
+
///
|
|
1968
|
+
/// This changes the database's contract, not merely its speed. Transaction
|
|
1969
|
+
/// commits always issue a stable-storage barrier and are unaffected.
|
|
1970
|
+
pub fn set_durability_mode(&self, mode: DurabilityMode) {
|
|
1971
|
+
let mut inner = self.state();
|
|
1972
|
+
inner.durability_mode = mode;
|
|
1973
|
+
inner.writes_since_barrier = 0;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/// What this database knows about itself.
|
|
1977
|
+
///
|
|
1978
|
+
/// Reports facts the open established — the durable format it was accepted
|
|
1979
|
+
/// as, and whether replay discarded an incomplete final append. It reports
|
|
1980
|
+
/// nothing it has not observed: **health can expose a proven fact, it
|
|
1981
|
+
/// cannot create a stronger guarantee.**
|
|
1982
|
+
///
|
|
1983
|
+
/// In particular this says nothing about power-loss durability, which is
|
|
1984
|
+
/// unproven, nor about replication or backup state, which have no
|
|
1985
|
+
/// operational contract yet.
|
|
1986
|
+
pub fn health(&self) -> DatabaseHealth {
|
|
1987
|
+
let recovery = self.log_recovery();
|
|
1988
|
+
let storage = match &recovery {
|
|
1989
|
+
LogRecovery::Clean => StorageHealth::Clean,
|
|
1990
|
+
LogRecovery::RecoveredTornTail {
|
|
1991
|
+
byte_offset,
|
|
1992
|
+
discarded_bytes,
|
|
1993
|
+
} => StorageHealth::RecoveredIncompleteWrite {
|
|
1994
|
+
byte_offset: *byte_offset,
|
|
1995
|
+
discarded_bytes: *discarded_bytes,
|
|
1996
|
+
},
|
|
1997
|
+
};
|
|
1998
|
+
DatabaseHealth {
|
|
1999
|
+
durable_format: self.state().durable_format.clone(),
|
|
2000
|
+
recovery,
|
|
2001
|
+
storage,
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/// What replaying the durable log had to do for this database to open.
|
|
2006
|
+
///
|
|
2007
|
+
/// [`LogRecovery::Clean`] means every durable byte was a complete, valid
|
|
2008
|
+
/// record. [`LogRecovery::RecoveredTornTail`] means the log ended in an
|
|
2009
|
+
/// incomplete append that was discarded — the database is usable and its
|
|
2010
|
+
/// **last write did not land**, which is a different fact from healthy and
|
|
2011
|
+
/// one an operator has to be able to learn.
|
|
2012
|
+
///
|
|
2013
|
+
/// A corrupt log does not reach here at all: it refuses the open.
|
|
2014
|
+
pub fn log_recovery(&self) -> LogRecovery {
|
|
2015
|
+
self.state().log_recovery.clone()
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1164
2018
|
pub fn insert<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
1165
2019
|
let capability = key
|
|
1166
2020
|
.split_once(':')
|
|
@@ -1277,7 +2131,9 @@ impl FeltDb {
|
|
|
1277
2131
|
inner.sequence += 1;
|
|
1278
2132
|
inner.sync_state.increment_vector_clock();
|
|
1279
2133
|
let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
|
|
1280
|
-
let
|
|
2134
|
+
let planned =
|
|
2135
|
+
is_versioned(&capability).then(|| plan_revision(&inner, key, value.to_string()));
|
|
2136
|
+
let mut operation = Operation::update(
|
|
1281
2137
|
inner.sequence,
|
|
1282
2138
|
inner.instance_id.clone(),
|
|
1283
2139
|
inner.sequence,
|
|
@@ -1287,6 +2143,7 @@ impl FeltDb {
|
|
|
1287
2143
|
capability.clone(),
|
|
1288
2144
|
)
|
|
1289
2145
|
.with_vector_clock(vector_clock);
|
|
2146
|
+
operation.revision = planned.as_ref().map(provenance_of);
|
|
1290
2147
|
let row = StoredRow {
|
|
1291
2148
|
capability: capability.clone(),
|
|
1292
2149
|
key: key.to_string(),
|
|
@@ -1298,12 +2155,12 @@ impl FeltDb {
|
|
|
1298
2155
|
deleted: false,
|
|
1299
2156
|
operation: Some(operation.clone()),
|
|
1300
2157
|
};
|
|
1301
|
-
|
|
2158
|
+
append_event_locked(&mut inner, &row)?;
|
|
1302
2159
|
inner.authority_revision += 1;
|
|
1303
2160
|
inner.change_log.add_operation(operation);
|
|
1304
2161
|
inner.put_row(row.clone());
|
|
1305
|
-
if
|
|
1306
|
-
|
|
2162
|
+
if let Some(revision) = planned {
|
|
2163
|
+
persist_revision_locked(&mut inner, revision)?;
|
|
1307
2164
|
}
|
|
1308
2165
|
let event = ChangeEvent {
|
|
1309
2166
|
capability,
|
|
@@ -2118,8 +2975,13 @@ impl FeltDb {
|
|
|
2118
2975
|
// Get current vector clock to attach to operation
|
|
2119
2976
|
let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
|
|
2120
2977
|
|
|
2978
|
+
// The revision this write produces is worked out first, so the
|
|
2979
|
+
// operation can name it and a peer can rebuild the same one.
|
|
2980
|
+
let planned =
|
|
2981
|
+
is_versioned(&capability).then(|| plan_revision(&inner, &key, canonical.clone()));
|
|
2982
|
+
|
|
2121
2983
|
// Create an operation with vector clock for convergence
|
|
2122
|
-
let op = Operation::insert(
|
|
2984
|
+
let mut op = Operation::insert(
|
|
2123
2985
|
inner.sequence,
|
|
2124
2986
|
inner.instance_id.clone(),
|
|
2125
2987
|
inner.sequence,
|
|
@@ -2129,16 +2991,17 @@ impl FeltDb {
|
|
|
2129
2991
|
capability.clone(),
|
|
2130
2992
|
)
|
|
2131
2993
|
.with_vector_clock(vector_clock);
|
|
2994
|
+
op.revision = planned.as_ref().map(provenance_of);
|
|
2132
2995
|
|
|
2133
2996
|
// Add operation to change log for sync
|
|
2134
2997
|
row.operation = Some(op.clone());
|
|
2135
2998
|
inner.change_log.add_operation(op.clone());
|
|
2136
2999
|
|
|
2137
|
-
|
|
3000
|
+
append_event_locked(&mut inner, &row)?;
|
|
2138
3001
|
inner.authority_revision += 1;
|
|
2139
3002
|
inner.put_row(row);
|
|
2140
|
-
if
|
|
2141
|
-
|
|
3003
|
+
if let Some(revision) = planned {
|
|
3004
|
+
persist_revision_locked(&mut inner, revision)?;
|
|
2142
3005
|
}
|
|
2143
3006
|
let suffix = key.rsplit_once(':').map(|(_, right)| right);
|
|
2144
3007
|
if let Some(id) = suffix.and_then(|s| s.parse::<u64>().ok()) {
|
|
@@ -2193,8 +3056,11 @@ impl FeltDb {
|
|
|
2193
3056
|
// Get current vector clock to attach to operation
|
|
2194
3057
|
let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
|
|
2195
3058
|
|
|
3059
|
+
let planned =
|
|
3060
|
+
is_versioned(&capability).then(|| plan_revision(&inner, &key, canonical.clone()));
|
|
3061
|
+
|
|
2196
3062
|
// Create an update operation with vector clock for convergence
|
|
2197
|
-
let op = Operation::update(
|
|
3063
|
+
let mut op = Operation::update(
|
|
2198
3064
|
inner.sequence,
|
|
2199
3065
|
inner.instance_id.clone(),
|
|
2200
3066
|
inner.sequence,
|
|
@@ -2204,16 +3070,17 @@ impl FeltDb {
|
|
|
2204
3070
|
capability.clone(),
|
|
2205
3071
|
)
|
|
2206
3072
|
.with_vector_clock(vector_clock);
|
|
3073
|
+
op.revision = planned.as_ref().map(provenance_of);
|
|
2207
3074
|
|
|
2208
3075
|
// Add operation to change log for sync
|
|
2209
3076
|
row.operation = Some(op.clone());
|
|
2210
3077
|
inner.change_log.add_operation(op.clone());
|
|
2211
3078
|
|
|
2212
|
-
|
|
3079
|
+
append_event_locked(&mut inner, &row)?;
|
|
2213
3080
|
inner.authority_revision += 1;
|
|
2214
3081
|
inner.put_row(row);
|
|
2215
|
-
if
|
|
2216
|
-
|
|
3082
|
+
if let Some(revision) = planned {
|
|
3083
|
+
persist_revision_locked(&mut inner, revision)?;
|
|
2217
3084
|
}
|
|
2218
3085
|
}
|
|
2219
3086
|
|
|
@@ -2955,12 +3822,56 @@ impl FeltDb {
|
|
|
2955
3822
|
/// Atomically replace acknowledged history with a state snapshot and any
|
|
2956
3823
|
/// operations still needed by at least one configured peer.
|
|
2957
3824
|
pub fn compact_operation_log(&self, active_peers: &[String]) -> Result<usize> {
|
|
3825
|
+
self.compact_internal(active_peers, true)
|
|
3826
|
+
.map(|outcome| outcome.pruned())
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
/// Compact under the database's [`CompactionPolicy`].
|
|
3830
|
+
///
|
|
3831
|
+
/// Prunes acknowledged operations from memory whenever it can, and rewrites
|
|
3832
|
+
/// the durable log **only when enough has accumulated to justify the
|
|
3833
|
+
/// stall**. Use this on a timer; use
|
|
3834
|
+
/// [`compact_operation_log`](Self::compact_operation_log) when a caller is
|
|
3835
|
+
/// explicitly asking for a rewrite now.
|
|
3836
|
+
///
|
|
3837
|
+
/// The distinction is the whole point. Pruning is cheap and in memory.
|
|
3838
|
+
/// Rewriting serializes every row in the database and fsyncs it while
|
|
3839
|
+
/// holding the one lock that serializes every read and every write, so
|
|
3840
|
+
/// doing it on a short timer stops the world on that timer.
|
|
3841
|
+
pub fn maybe_compact_operation_log(
|
|
3842
|
+
&self,
|
|
3843
|
+
active_peers: &[String],
|
|
3844
|
+
) -> Result<CompactionOutcome> {
|
|
3845
|
+
self.compact_internal(active_peers, false)
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
/// The policy deciding when a timer-driven compaction rewrites the log.
|
|
3849
|
+
pub fn compaction_policy(&self) -> CompactionPolicy {
|
|
3850
|
+
self.state().compaction_policy
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/// Set the policy deciding when a timer-driven compaction rewrites the log.
|
|
3854
|
+
pub fn set_compaction_policy(&self, policy: CompactionPolicy) {
|
|
3855
|
+
self.state().compaction_policy = policy;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
fn compact_internal(&self, active_peers: &[String], force: bool) -> Result<CompactionOutcome> {
|
|
2958
3859
|
let mut inner = self.state();
|
|
2959
3860
|
let removed = inner.change_log.prune_acknowledged(active_peers);
|
|
2960
3861
|
if removed == 0 {
|
|
2961
|
-
return Ok(
|
|
3862
|
+
return Ok(CompactionOutcome::Idle);
|
|
2962
3863
|
}
|
|
2963
3864
|
persist_sync_metadata(&inner)?;
|
|
3865
|
+
inner.pruned_since_rewrite += removed;
|
|
3866
|
+
|
|
3867
|
+
// The cheap half is done. The expensive half runs only when asked for
|
|
3868
|
+
// explicitly, or when the accumulated gain has earned it.
|
|
3869
|
+
if !force && inner.pruned_since_rewrite < inner.compaction_policy.rewrite_after_pruned {
|
|
3870
|
+
return Ok(CompactionOutcome::Deferred {
|
|
3871
|
+
pruned: removed,
|
|
3872
|
+
pending_since_rewrite: inner.pruned_since_rewrite,
|
|
3873
|
+
});
|
|
3874
|
+
}
|
|
2964
3875
|
|
|
2965
3876
|
let temporary = inner.path.with_extension("compact.tmp");
|
|
2966
3877
|
let mut file = OpenOptions::new()
|
|
@@ -2991,7 +3902,8 @@ impl FeltDb {
|
|
|
2991
3902
|
file.sync_all()?;
|
|
2992
3903
|
fs::rename(temporary, &inner.path)?;
|
|
2993
3904
|
inner.bootstrapped = true;
|
|
2994
|
-
|
|
3905
|
+
inner.pruned_since_rewrite = 0;
|
|
3906
|
+
Ok(CompactionOutcome::Rewritten { pruned: removed })
|
|
2995
3907
|
}
|
|
2996
3908
|
|
|
2997
3909
|
/// Register a peer in the distributed fabric
|
|
@@ -3174,6 +4086,22 @@ impl FeltDb {
|
|
|
3174
4086
|
*counter = (*counter).max(id + 1);
|
|
3175
4087
|
}
|
|
3176
4088
|
|
|
4089
|
+
// Reconstruct the historical fact the originating authority recorded.
|
|
4090
|
+
//
|
|
4091
|
+
// Not a local mint of the resulting state: the identity, parent and
|
|
4092
|
+
// sequence come from the operation, so the peer ends up holding the
|
|
4093
|
+
// *same* revision rather than an equivalent-looking one of its own.
|
|
4094
|
+
// Deliberately independent of which value won any last-writer race
|
|
4095
|
+
// above — the origin committed this revision whether or not its value
|
|
4096
|
+
// is what is current here, and dropping it would lose the divergence
|
|
4097
|
+
// that reconciliation needs to see.
|
|
4098
|
+
if let Some(provenance) = op.revision.clone() {
|
|
4099
|
+
if let Some(value) = op.value.clone() {
|
|
4100
|
+
let revision = reconstruct_revision(&provenance, &value)?;
|
|
4101
|
+
persist_revision_locked(&mut inner, revision)?;
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
|
|
3177
4105
|
inner.change_log.add_operation(op);
|
|
3178
4106
|
inner.sync_state.increment_received(1);
|
|
3179
4107
|
let event = ChangeEvent {
|
|
@@ -3519,7 +4447,9 @@ pub fn inspect_durable_format(path: &Path) -> Result<FormatCompatibility> {
|
|
|
3519
4447
|
)));
|
|
3520
4448
|
}
|
|
3521
4449
|
|
|
3522
|
-
|
|
4450
|
+
// Read-only: inspecting a database it is about to reject must not touch it.
|
|
4451
|
+
let scan = scan_log(path)?;
|
|
4452
|
+
let lines: Vec<String> = scan.lines.into_iter().map(|line| line.text).collect();
|
|
3523
4453
|
let mut declared: Option<u32> = None;
|
|
3524
4454
|
let mut legacy_revision = false;
|
|
3525
4455
|
|
|
@@ -3610,56 +4540,120 @@ pub fn inspect_durable_format(path: &Path) -> Result<FormatCompatibility> {
|
|
|
3610
4540
|
Ok(FormatCompatibility::Compatible(DurableFormat::Unversioned))
|
|
3611
4541
|
}
|
|
3612
4542
|
|
|
3613
|
-
|
|
4543
|
+
/// One complete record in the durable log, with where it came from.
|
|
4544
|
+
struct ScannedLine {
|
|
4545
|
+
text: String,
|
|
4546
|
+
line_number: usize,
|
|
4547
|
+
byte_offset: u64,
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
/// The bytes at the end of a log that are not a complete record.
|
|
4551
|
+
struct TornTail {
|
|
4552
|
+
byte_offset: u64,
|
|
4553
|
+
bytes: usize,
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
/// The durable log, read and classified. **This never writes.**
|
|
4557
|
+
struct LogScan {
|
|
4558
|
+
lines: Vec<ScannedLine>,
|
|
4559
|
+
torn_tail: Option<TornTail>,
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4562
|
+
/// Read the durable log without modifying it.
|
|
4563
|
+
///
|
|
4564
|
+
/// The only thing separated out here is the **terminator**: a final record with
|
|
4565
|
+
/// no newline cannot be one this database finished writing, so it is reported
|
|
4566
|
+
/// as a torn tail. Everything else — including a complete final record that
|
|
4567
|
+
/// happens to be invalid — is returned as a line, to be validated by the
|
|
4568
|
+
/// caller and refused rather than quietly dropped for being near the end.
|
|
4569
|
+
///
|
|
4570
|
+
/// Being read-only is load-bearing twice over: a refused open must leave the
|
|
4571
|
+
/// database byte-identical, and format inspection must be able to run on a
|
|
4572
|
+
/// database it is about to reject.
|
|
4573
|
+
fn scan_log(path: &Path) -> Result<LogScan> {
|
|
3614
4574
|
let mut file = BufReader::new(OpenOptions::new().read(true).open(path)?);
|
|
3615
|
-
let mut lines
|
|
4575
|
+
let mut lines = Vec::new();
|
|
4576
|
+
let mut torn_tail = None;
|
|
3616
4577
|
let mut buffer = Vec::new();
|
|
4578
|
+
let mut offset = 0u64;
|
|
4579
|
+
let mut line_number = 0usize;
|
|
4580
|
+
|
|
3617
4581
|
loop {
|
|
3618
4582
|
buffer.clear();
|
|
3619
|
-
|
|
4583
|
+
let read = file.read_until(b'\n', &mut buffer)?;
|
|
4584
|
+
if read == 0 {
|
|
3620
4585
|
break;
|
|
3621
4586
|
}
|
|
3622
4587
|
let terminated = buffer.ends_with(b"\n");
|
|
3623
4588
|
let text = String::from_utf8_lossy(&buffer)
|
|
3624
4589
|
.trim_end_matches('\n')
|
|
3625
4590
|
.to_string();
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
4591
|
+
line_number += 1;
|
|
4592
|
+
|
|
4593
|
+
if !terminated {
|
|
4594
|
+
// An unterminated final record: the write did not finish. Empty
|
|
4595
|
+
// trailing bytes are nothing at all and are simply ignored.
|
|
4596
|
+
if !text.trim().is_empty() {
|
|
4597
|
+
torn_tail = Some(TornTail {
|
|
4598
|
+
byte_offset: offset,
|
|
4599
|
+
bytes: read,
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
break;
|
|
3635
4603
|
}
|
|
3636
|
-
}
|
|
3637
4604
|
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
4605
|
+
lines.push(ScannedLine {
|
|
4606
|
+
text,
|
|
4607
|
+
line_number,
|
|
4608
|
+
byte_offset: offset,
|
|
4609
|
+
});
|
|
4610
|
+
offset += read as u64;
|
|
3643
4611
|
}
|
|
3644
4612
|
|
|
3645
|
-
Ok(lines
|
|
4613
|
+
Ok(LogScan { lines, torn_tail })
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
/// Discard an incomplete final append, so the next append does not splice onto
|
|
4617
|
+
/// a partial record.
|
|
4618
|
+
///
|
|
4619
|
+
/// Called only after the whole log has replayed successfully. A log that is
|
|
4620
|
+
/// going to be refused is never truncated.
|
|
4621
|
+
fn truncate_torn_tail(path: &Path, tail: &TornTail) -> Result<()> {
|
|
4622
|
+
let file = OpenOptions::new().write(true).open(path)?;
|
|
4623
|
+
file.set_len(tail.byte_offset)?;
|
|
4624
|
+
file.sync_all()?;
|
|
4625
|
+
Ok(())
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
fn corruption(line: &ScannedLine, reason: &str) -> FlowError {
|
|
4629
|
+
let excerpt: String = line.text.chars().take(120).collect();
|
|
4630
|
+
FlowError::CorruptLogLine(Box::new(LogCorruption {
|
|
4631
|
+
line_number: line.line_number,
|
|
4632
|
+
byte_offset: line.byte_offset,
|
|
4633
|
+
reason: reason.to_string(),
|
|
4634
|
+
excerpt,
|
|
4635
|
+
}))
|
|
3646
4636
|
}
|
|
3647
4637
|
|
|
3648
4638
|
fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
3649
|
-
let
|
|
4639
|
+
let scan = scan_log(&inner.path)?;
|
|
4640
|
+
let torn_tail = scan.torn_tail;
|
|
4641
|
+
let lines = scan.lines;
|
|
3650
4642
|
|
|
3651
4643
|
let mut snapshot_log = false;
|
|
3652
|
-
|
|
3653
|
-
|
|
4644
|
+
let mut recoverable_revisions: Vec<(operation::RevisionProvenance, Value)> = Vec::new();
|
|
4645
|
+
for line in &lines {
|
|
4646
|
+
if line.text.trim().is_empty() {
|
|
3654
4647
|
continue;
|
|
3655
4648
|
}
|
|
3656
4649
|
let value: Value =
|
|
3657
|
-
serde_json::from_str(&line).map_err(|_|
|
|
4650
|
+
serde_json::from_str(&line.text).map_err(|_| corruption(line, "not valid JSON"))?;
|
|
3658
4651
|
if matches!(
|
|
3659
4652
|
value.get("record_type").and_then(Value::as_str),
|
|
3660
4653
|
Some("feltdb.snapshot.v1" | "flowdb.snapshot.v1")
|
|
3661
4654
|
) {
|
|
3662
|
-
let header: LogHeader = serde_json::from_value(value)
|
|
4655
|
+
let header: LogHeader = serde_json::from_value(value)
|
|
4656
|
+
.map_err(|error| corruption(line, &format!("malformed log header: {error}")))?;
|
|
3663
4657
|
inner.sequence = inner.sequence.max(header.local_sequence);
|
|
3664
4658
|
inner.change_log.observed_versions = header.observed_versions;
|
|
3665
4659
|
inner.bootstrapped = true;
|
|
@@ -3671,7 +4665,10 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
3671
4665
|
continue;
|
|
3672
4666
|
}
|
|
3673
4667
|
if value.get("record_type").and_then(Value::as_str) == Some("feltdb.transaction.v1") {
|
|
3674
|
-
let transaction: TransactionLogRecord =
|
|
4668
|
+
let transaction: TransactionLogRecord =
|
|
4669
|
+
serde_json::from_value(value).map_err(|error| {
|
|
4670
|
+
corruption(line, &format!("malformed transaction record: {error}"))
|
|
4671
|
+
})?;
|
|
3675
4672
|
// Older records did not carry a commit revision. Their durable
|
|
3676
4673
|
// order in the log supplies an unambiguous upgrade path.
|
|
3677
4674
|
inner.authority_revision =
|
|
@@ -3718,9 +4715,16 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
3718
4715
|
inner.sequence = inner.sequence.max(transaction.state_after);
|
|
3719
4716
|
continue;
|
|
3720
4717
|
}
|
|
3721
|
-
let row: StoredRow =
|
|
3722
|
-
|
|
4718
|
+
let row: StoredRow = serde_json::from_value(value)
|
|
4719
|
+
.map_err(|_| corruption(line, "not a durable record this build understands"))?;
|
|
3723
4720
|
if let Some(operation) = row.operation.clone() {
|
|
4721
|
+
// A revision is a deterministic function of the operation that
|
|
4722
|
+
// produced it, so it never has to be lost. If the crash landed
|
|
4723
|
+
// between a record and the revision recording it, the revision is
|
|
4724
|
+
// rebuilt below rather than left missing.
|
|
4725
|
+
if let (Some(provenance), Some(value)) = (&operation.revision, &operation.value) {
|
|
4726
|
+
recoverable_revisions.push((provenance.clone(), value.clone()));
|
|
4727
|
+
}
|
|
3724
4728
|
inner.sequence = inner.sequence.max(
|
|
3725
4729
|
(operation.instance_id == inner.instance_id)
|
|
3726
4730
|
.then_some(operation.sequence)
|
|
@@ -3754,6 +4758,61 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
3754
4758
|
}
|
|
3755
4759
|
}
|
|
3756
4760
|
|
|
4761
|
+
// Rebuild any revision whose record survived but whose own row did not.
|
|
4762
|
+
//
|
|
4763
|
+
// This is the crash window between appending a record and appending the
|
|
4764
|
+
// revision that records it. The revision is derivable from the operation —
|
|
4765
|
+
// resource, content, parent, sequence and authority all travel with it —
|
|
4766
|
+
// so recovery reconstructs it instead of leaving an authority holding less
|
|
4767
|
+
// history than its own operation stream implies. Nothing is invented: the
|
|
4768
|
+
// identity is recomputed from its parts and discarded if it does not follow
|
|
4769
|
+
// from them.
|
|
4770
|
+
//
|
|
4771
|
+
// The rebuilt rows are not appended here. They are derived, and stay
|
|
4772
|
+
// derivable from the same operations on every subsequent open.
|
|
4773
|
+
for (provenance, value) in recoverable_revisions {
|
|
4774
|
+
let key = format!("state:revision:{}", provenance.id);
|
|
4775
|
+
let present = inner
|
|
4776
|
+
.rows
|
|
4777
|
+
.get("state")
|
|
4778
|
+
.and_then(|bucket| bucket.get(&key))
|
|
4779
|
+
.is_some_and(|row| !row.deleted);
|
|
4780
|
+
if present {
|
|
4781
|
+
continue;
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
// A revision below the resource's retention horizon is absent because
|
|
4785
|
+
// it was expired on purpose. Rebuilding it would make recovery
|
|
4786
|
+
// resurrect history a policy deliberately discarded, which is a
|
|
4787
|
+
// different kind of wrong from losing it.
|
|
4788
|
+
let horizon = inner
|
|
4789
|
+
.rows
|
|
4790
|
+
.get("_retention")
|
|
4791
|
+
.and_then(|bucket| bucket.get(&format!("_retention:{}", provenance.resource)))
|
|
4792
|
+
.and_then(|row| serde_json::from_value::<RetentionRecord>(row.value.clone()).ok())
|
|
4793
|
+
.map(|state| state.horizon);
|
|
4794
|
+
if horizon.is_some_and(|horizon| provenance.sequence < horizon) {
|
|
4795
|
+
continue;
|
|
4796
|
+
}
|
|
4797
|
+
let Ok(revision) = reconstruct_revision(&provenance, &value) else {
|
|
4798
|
+
continue;
|
|
4799
|
+
};
|
|
4800
|
+
let Ok(encoded) = serde_json::to_value(&revision) else {
|
|
4801
|
+
continue;
|
|
4802
|
+
};
|
|
4803
|
+
inner.put_row(StoredRow {
|
|
4804
|
+
capability: "state".to_string(),
|
|
4805
|
+
key,
|
|
4806
|
+
rust_type: "feltdb::state_model::StateRevision".to_string(),
|
|
4807
|
+
value: encoded,
|
|
4808
|
+
unix_ms: now_ms(),
|
|
4809
|
+
content_hash: None,
|
|
4810
|
+
flow_ref: None,
|
|
4811
|
+
deleted: false,
|
|
4812
|
+
operation: None,
|
|
4813
|
+
});
|
|
4814
|
+
}
|
|
4815
|
+
|
|
3757
4816
|
// Compute collection cardinality from final row state (count non-deleted rows per capability)
|
|
3758
4817
|
for (capability, bucket) in &inner.rows {
|
|
3759
4818
|
let count = bucket.values().filter(|row| !row.deleted).count() as u64;
|
|
@@ -3762,6 +4821,17 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
3762
4821
|
.insert(capability.clone(), count);
|
|
3763
4822
|
}
|
|
3764
4823
|
|
|
4824
|
+
// Only now, with every complete record replayed, is it safe to discard an
|
|
4825
|
+
// incomplete final append. A log that was going to be refused never reaches
|
|
4826
|
+
// this point, so a refused open leaves the file byte-identical.
|
|
4827
|
+
if let Some(tail) = torn_tail {
|
|
4828
|
+
truncate_torn_tail(&inner.path, &tail)?;
|
|
4829
|
+
inner.log_recovery = LogRecovery::RecoveredTornTail {
|
|
4830
|
+
byte_offset: tail.byte_offset,
|
|
4831
|
+
discarded_bytes: tail.bytes,
|
|
4832
|
+
};
|
|
4833
|
+
}
|
|
4834
|
+
|
|
3765
4835
|
Ok(())
|
|
3766
4836
|
}
|
|
3767
4837
|
|
|
@@ -3829,15 +4899,46 @@ fn persist_sync_metadata(inner: &Inner) -> Result<()> {
|
|
|
3829
4899
|
}
|
|
3830
4900
|
|
|
3831
4901
|
fn append_event(path: &Path, event: &StoredRow) -> Result<()> {
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4902
|
+
append_event_with(path, event, DurabilityMode::Flushed, &mut 0)
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
/// Append one record under the database's configured durability mode.
|
|
4906
|
+
fn append_event_locked(inner: &mut Inner, event: &StoredRow) -> Result<()> {
|
|
4907
|
+
let path = inner.path.clone();
|
|
4908
|
+
let mode = inner.durability_mode;
|
|
4909
|
+
append_event_with(&path, event, mode, &mut inner.writes_since_barrier)
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
/// Append one record, pushing it as far as the durability mode requires.
|
|
4913
|
+
///
|
|
4914
|
+
/// `since_barrier` counts writes since the last stable-storage barrier and is
|
|
4915
|
+
/// only read by [`DurabilityMode::Grouped`].
|
|
4916
|
+
fn append_event_with(
|
|
4917
|
+
path: &Path,
|
|
4918
|
+
event: &StoredRow,
|
|
4919
|
+
mode: DurabilityMode,
|
|
4920
|
+
since_barrier: &mut u32,
|
|
4921
|
+
) -> Result<()> {
|
|
3835
4922
|
let _span = workload_diagnostics::span(workload_diagnostics::Phase::Persistence);
|
|
3836
4923
|
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
|
3837
4924
|
let mut line = serde_json::to_string(event)?;
|
|
3838
4925
|
line.push('\n');
|
|
3839
4926
|
file.write_all(line.as_bytes())?;
|
|
3840
4927
|
file.flush()?;
|
|
4928
|
+
|
|
4929
|
+
// The flush above is what makes a record survive the process dying. What
|
|
4930
|
+
// follows is the only thing that even asks the storage stack for more.
|
|
4931
|
+
match mode {
|
|
4932
|
+
DurabilityMode::Flushed => {}
|
|
4933
|
+
DurabilityMode::Synced => file.sync_all()?,
|
|
4934
|
+
DurabilityMode::Grouped { every } => {
|
|
4935
|
+
*since_barrier += 1;
|
|
4936
|
+
if *since_barrier >= every.max(1) {
|
|
4937
|
+
file.sync_all()?;
|
|
4938
|
+
*since_barrier = 0;
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
3841
4942
|
Ok(())
|
|
3842
4943
|
}
|
|
3843
4944
|
|