create-feltdb 0.11.7 → 0.11.9
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/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/lib.rs +363 -2
- package/dist/server-source/crates/feltdb/src/read_basis.rs +257 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +442 -73
- package/dist/server-source/crates/feltdb/src/workload_diagnostics.rs +18 -4
- package/dist/server-source/crates/feltdb-server/src/backup.rs +290 -7
- package/dist/server-source/crates/feltdb-server/src/main.rs +500 -23
- package/package.json +1 -1
- package/dist/server-source/crates/feltdb-wasm/test.db +0 -0
|
@@ -75,7 +75,7 @@ use feltdb_server::{
|
|
|
75
75
|
authenticated_principal::AuthenticatedPrincipal,
|
|
76
76
|
authorization::{authorize, AuthorizationRequest, Subject},
|
|
77
77
|
backup::{
|
|
78
|
-
|
|
78
|
+
restore_bundle, select_bundle_at,
|
|
79
79
|
server_artifacts, verify_bundle,
|
|
80
80
|
},
|
|
81
81
|
causal::{
|
|
@@ -601,6 +601,14 @@ struct CanonicalTransactionRequest {
|
|
|
601
601
|
environment: String,
|
|
602
602
|
transaction: TransactionRequest,
|
|
603
603
|
}
|
|
604
|
+
#[derive(Deserialize)]
|
|
605
|
+
struct CanonicalReadBasisRequest {
|
|
606
|
+
application_id: String,
|
|
607
|
+
revision_id: String,
|
|
608
|
+
#[serde(default = "production_environment")]
|
|
609
|
+
environment: String,
|
|
610
|
+
reads: feltdb::ReadSet,
|
|
611
|
+
}
|
|
604
612
|
fn production_environment() -> String {
|
|
605
613
|
"production".into()
|
|
606
614
|
}
|
|
@@ -1207,6 +1215,7 @@ async fn push_sync(
|
|
|
1207
1215
|
),
|
|
1208
1216
|
operations: vec![operation.operation.clone()],
|
|
1209
1217
|
preconditions: vec![],
|
|
1218
|
+
fences: vec![],
|
|
1210
1219
|
};
|
|
1211
1220
|
let _transaction_permit = state
|
|
1212
1221
|
.state_transaction_admission
|
|
@@ -4620,12 +4629,15 @@ fn runtime_inventory(
|
|
|
4620
4629
|
.filter(|value| value.status == ConnectionStatus::Active)
|
|
4621
4630
|
.map(|value| value.id)
|
|
4622
4631
|
.collect();
|
|
4623
|
-
|
|
4632
|
+
// Every managed request resolves its contract through here, so this reads
|
|
4633
|
+
// only the capability registry's own rows. A whole-database snapshot would
|
|
4634
|
+
// clone, sort, and hash every tenant's state per request.
|
|
4624
4635
|
let mut capabilities = BTreeSet::new();
|
|
4625
|
-
for row in
|
|
4626
|
-
.
|
|
4636
|
+
for row in state
|
|
4637
|
+
.db
|
|
4638
|
+
.list_collection("_flow_capabilities")?
|
|
4627
4639
|
.iter()
|
|
4628
|
-
.filter(|row|
|
|
4640
|
+
.filter(|row| !row.deleted)
|
|
4629
4641
|
{
|
|
4630
4642
|
if let Some(name) = row.value.get("name").and_then(Value::as_str) {
|
|
4631
4643
|
capabilities.insert(name.to_string());
|
|
@@ -4653,6 +4665,8 @@ fn revision_contract(
|
|
|
4653
4665
|
revision_id: &str,
|
|
4654
4666
|
environment: &str,
|
|
4655
4667
|
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4668
|
+
let _span =
|
|
4669
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::ContractResolution);
|
|
4656
4670
|
let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
|
|
4657
4671
|
let revision = state
|
|
4658
4672
|
.applications
|
|
@@ -7209,6 +7223,7 @@ async fn run_runtime_action(
|
|
|
7209
7223
|
if_version,
|
|
7210
7224
|
}],
|
|
7211
7225
|
preconditions: vec![],
|
|
7226
|
+
fences: vec![],
|
|
7212
7227
|
};
|
|
7213
7228
|
let write_policy = contract
|
|
7214
7229
|
.policies
|
|
@@ -7658,11 +7673,34 @@ async fn execute_canonical_query(
|
|
|
7658
7673
|
&input.revision_id,
|
|
7659
7674
|
&input.environment,
|
|
7660
7675
|
)?;
|
|
7676
|
+
let authorization = if feltdb::state_contract::is_substrate_collection(&input.query.collection)
|
|
7677
|
+
{
|
|
7678
|
+
let mut authorization = state_authorization_legacy(
|
|
7679
|
+
&principal,
|
|
7680
|
+
&tenant,
|
|
7681
|
+
&input.application_id,
|
|
7682
|
+
&contract.revision_id,
|
|
7683
|
+
"state:denied",
|
|
7684
|
+
);
|
|
7685
|
+
if application_scope(
|
|
7686
|
+
&state,
|
|
7687
|
+
&principal.key_id,
|
|
7688
|
+
&input.application_id,
|
|
7689
|
+
"composition:read",
|
|
7690
|
+
)
|
|
7691
|
+
.is_ok()
|
|
7692
|
+
{
|
|
7693
|
+
authorization.capabilities.insert("composition:read".into());
|
|
7694
|
+
}
|
|
7695
|
+
authorization
|
|
7696
|
+
} else {
|
|
7697
|
+
state_authorization(&principal, &contract, &input.query.collection, "read")
|
|
7698
|
+
};
|
|
7661
7699
|
let context = feltdb::state_contract::begin_query_read(
|
|
7662
7700
|
&state.db,
|
|
7663
7701
|
&contract.state.schema,
|
|
7664
7702
|
&contract.environment.state_namespace,
|
|
7665
|
-
|
|
7703
|
+
authorization,
|
|
7666
7704
|
)
|
|
7667
7705
|
.map_err(state_contract_error)?;
|
|
7668
7706
|
|
|
@@ -7885,6 +7923,76 @@ async fn execute_revision_recovery(
|
|
|
7885
7923
|
Ok(Json(response))
|
|
7886
7924
|
}
|
|
7887
7925
|
|
|
7926
|
+
/// Whether `principal` may read every record of `collection` — the condition
|
|
7927
|
+
/// for a read basis, captured or committed, to cover it.
|
|
7928
|
+
///
|
|
7929
|
+
/// A token digests every row it covers, and a refused fence reveals that one
|
|
7930
|
+
/// of them changed. Both are reads, so both require the right to read the whole
|
|
7931
|
+
/// collection: `state:read` under a read policy that does not depend on the
|
|
7932
|
+
/// record (none, or `authenticated`), or `composition:read` for a substrate
|
|
7933
|
+
/// collection. Owner, member, and self policies filter rows per actor; a read
|
|
7934
|
+
/// basis over such a collection is refused rather than digested.
|
|
7935
|
+
fn read_basis_readable(
|
|
7936
|
+
state: &AppState,
|
|
7937
|
+
principal: &Principal,
|
|
7938
|
+
contract: &ApplicationRuntimeContract,
|
|
7939
|
+
collection: &str,
|
|
7940
|
+
) -> bool {
|
|
7941
|
+
if feltdb::state_contract::is_substrate_collection(collection) {
|
|
7942
|
+
return application_scope(
|
|
7943
|
+
state,
|
|
7944
|
+
&principal.key_id,
|
|
7945
|
+
&contract.application_id,
|
|
7946
|
+
"composition:read",
|
|
7947
|
+
)
|
|
7948
|
+
.is_ok();
|
|
7949
|
+
}
|
|
7950
|
+
if !state_authorization(principal, contract, collection, "read")
|
|
7951
|
+
.capabilities
|
|
7952
|
+
.contains("state:read")
|
|
7953
|
+
{
|
|
7954
|
+
return false;
|
|
7955
|
+
}
|
|
7956
|
+
matches!(
|
|
7957
|
+
matching_state_policy(contract, collection)
|
|
7958
|
+
.and_then(|policy| policy.read.as_deref())
|
|
7959
|
+
.and_then(PolicySubject::from_str),
|
|
7960
|
+
None | Some(PolicySubject::Authenticated)
|
|
7961
|
+
)
|
|
7962
|
+
}
|
|
7963
|
+
|
|
7964
|
+
/// Capture a read basis over the application's state namespace.
|
|
7965
|
+
async fn capture_canonical_read_basis(
|
|
7966
|
+
State(state): State<AppState>,
|
|
7967
|
+
Extension(principal): Extension<Principal>,
|
|
7968
|
+
headers: HeaderMap,
|
|
7969
|
+
Json(input): Json<CanonicalReadBasisRequest>,
|
|
7970
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7971
|
+
let tenant = application_scope(
|
|
7972
|
+
&state,
|
|
7973
|
+
&principal.key_id,
|
|
7974
|
+
&input.application_id,
|
|
7975
|
+
"state:read",
|
|
7976
|
+
)?;
|
|
7977
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
7978
|
+
let contract = revision_contract(
|
|
7979
|
+
&state,
|
|
7980
|
+
&principal.key_id,
|
|
7981
|
+
&input.application_id,
|
|
7982
|
+
&input.revision_id,
|
|
7983
|
+
&input.environment,
|
|
7984
|
+
)?;
|
|
7985
|
+
let basis = feltdb::state_contract::capture_read_basis(
|
|
7986
|
+
&state.db,
|
|
7987
|
+
&contract.state.schema,
|
|
7988
|
+
&contract.environment.state_namespace,
|
|
7989
|
+
&input.reads,
|
|
7990
|
+
&|collection| read_basis_readable(&state, &principal, &contract, collection),
|
|
7991
|
+
)
|
|
7992
|
+
.map_err(state_contract_error)?;
|
|
7993
|
+
Ok(Json(json!(basis)))
|
|
7994
|
+
}
|
|
7995
|
+
|
|
7888
7996
|
async fn execute_canonical_transaction(
|
|
7889
7997
|
State(state): State<AppState>,
|
|
7890
7998
|
Extension(principal): Extension<Principal>,
|
|
@@ -7921,14 +8029,49 @@ async fn execute_canonical_transaction(
|
|
|
7921
8029
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
7922
8030
|
input.transaction.schema_version = contract.state.schema.schema_version;
|
|
7923
8031
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
7924
|
-
let collections = input
|
|
8032
|
+
let (substrate, collections): (Vec<String>, Vec<String>) = input
|
|
7925
8033
|
.transaction
|
|
7926
8034
|
.operations
|
|
7927
8035
|
.iter()
|
|
7928
8036
|
.map(|operation| operation.collection.clone())
|
|
7929
|
-
.
|
|
7930
|
-
let (authorized, write_policies) =
|
|
7931
|
-
|
|
8037
|
+
.partition(|collection| feltdb::state_contract::is_substrate_collection(collection));
|
|
8038
|
+
let (authorized, write_policies) = if collections.is_empty() && !substrate.is_empty() {
|
|
8039
|
+
(tenant == contract.tenant_id, BTreeMap::new())
|
|
8040
|
+
} else {
|
|
8041
|
+
authorize_transaction_collections(&principal, &contract, &tenant, &collections)
|
|
8042
|
+
};
|
|
8043
|
+
// Substrate records are written only under the dedicated capability.
|
|
8044
|
+
let substrate_authorized = substrate.is_empty()
|
|
8045
|
+
|| application_scope(
|
|
8046
|
+
&state,
|
|
8047
|
+
&principal.key_id,
|
|
8048
|
+
&input.application_id,
|
|
8049
|
+
"composition:write",
|
|
8050
|
+
)
|
|
8051
|
+
.is_ok();
|
|
8052
|
+
// A fence is a read of everything it covers: the committer must be able to
|
|
8053
|
+
// read all of it, exactly as capturing it required.
|
|
8054
|
+
for fence in &input.transaction.fences {
|
|
8055
|
+
for collection in fence
|
|
8056
|
+
.reads
|
|
8057
|
+
.records
|
|
8058
|
+
.iter()
|
|
8059
|
+
.map(|record| record.collection.as_str())
|
|
8060
|
+
.chain(
|
|
8061
|
+
fence
|
|
8062
|
+
.reads
|
|
8063
|
+
.predicates
|
|
8064
|
+
.iter()
|
|
8065
|
+
.map(|predicate| predicate.collection.as_str()),
|
|
8066
|
+
)
|
|
8067
|
+
{
|
|
8068
|
+
if !read_basis_readable(&state, &principal, &contract, collection) {
|
|
8069
|
+
return Err(state_contract_error(
|
|
8070
|
+
feltdb::state_contract::read_basis_denied(collection),
|
|
8071
|
+
));
|
|
8072
|
+
}
|
|
8073
|
+
}
|
|
8074
|
+
}
|
|
7932
8075
|
input.transaction.authorization = state_authorization_legacy(
|
|
7933
8076
|
&principal,
|
|
7934
8077
|
&tenant,
|
|
@@ -7940,6 +8083,13 @@ async fn execute_canonical_transaction(
|
|
|
7940
8083
|
"state:denied"
|
|
7941
8084
|
},
|
|
7942
8085
|
);
|
|
8086
|
+
if authorized && substrate_authorized && !substrate.is_empty() {
|
|
8087
|
+
input
|
|
8088
|
+
.transaction
|
|
8089
|
+
.authorization
|
|
8090
|
+
.capabilities
|
|
8091
|
+
.insert("composition:write".into());
|
|
8092
|
+
}
|
|
7943
8093
|
let _transaction_permit = state
|
|
7944
8094
|
.state_transaction_admission
|
|
7945
8095
|
.acquire()
|
|
@@ -8529,6 +8679,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8529
8679
|
if manage_backup()? {
|
|
8530
8680
|
return Ok(());
|
|
8531
8681
|
}
|
|
8682
|
+
if manage_doctor()? {
|
|
8683
|
+
return Ok(());
|
|
8684
|
+
}
|
|
8532
8685
|
if manage_application_runtime()? {
|
|
8533
8686
|
return Ok(());
|
|
8534
8687
|
}
|
|
@@ -9033,6 +9186,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9033
9186
|
"/v1/transactions",
|
|
9034
9187
|
axum::routing::post(execute_canonical_transaction),
|
|
9035
9188
|
)
|
|
9189
|
+
.route(
|
|
9190
|
+
"/v1/transactions/read-basis",
|
|
9191
|
+
axum::routing::post(capture_canonical_read_basis),
|
|
9192
|
+
)
|
|
9036
9193
|
.route(
|
|
9037
9194
|
"/v1/revision/recover",
|
|
9038
9195
|
axum::routing::post(execute_revision_recovery),
|
|
@@ -9309,6 +9466,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9309
9466
|
axum::routing::delete(delete_connection_control),
|
|
9310
9467
|
)
|
|
9311
9468
|
.route("/transactions", axum::routing::post(commit_transaction))
|
|
9469
|
+
.route(
|
|
9470
|
+
"/transactions/read-basis",
|
|
9471
|
+
axum::routing::post(capture_read_basis),
|
|
9472
|
+
)
|
|
9312
9473
|
.route("/query", axum::routing::post(execute_bounded_query))
|
|
9313
9474
|
.route("/revision", get(get_revision))
|
|
9314
9475
|
.route(
|
|
@@ -9628,6 +9789,8 @@ async fn authenticate(
|
|
|
9628
9789
|
|| path == "/v1/state/conflicts"
|
|
9629
9790
|
|| path == "/v1/state/reconciliation/plan"
|
|
9630
9791
|
|| path == "/v1/experimental/semantic-decisions"
|
|
9792
|
+
|| path == "/transactions/read-basis"
|
|
9793
|
+
|| path == "/v1/transactions/read-basis"
|
|
9631
9794
|
{
|
|
9632
9795
|
// Describing state is reading it. These are POSTs because they take
|
|
9633
9796
|
// three revision identities and a plan, not because they change
|
|
@@ -9989,8 +10152,16 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
9989
10152
|
match outcome {
|
|
9990
10153
|
Ok(Ok(Ok(health))) => {
|
|
9991
10154
|
let ready = health.is_nominal();
|
|
10155
|
+
let recovering = matches!(
|
|
10156
|
+
state.cluster.proposal().map(|value| value.phase),
|
|
10157
|
+
Some(ProposalPhase::Preparing | ProposalPhase::Prepared)
|
|
10158
|
+
);
|
|
9992
10159
|
let body = Json(json!({
|
|
9993
10160
|
"ready": ready,
|
|
10161
|
+
// The explicit health model. A running server has already
|
|
10162
|
+
// refused a corrupt or incompatible log at open, so `corrupt`
|
|
10163
|
+
// is reported offline by `feltdb-server doctor`, never here.
|
|
10164
|
+
"state": if !ready { "degraded" } else if recovering { "recovering" } else { "healthy" },
|
|
9994
10165
|
"storage": health.storage.label(),
|
|
9995
10166
|
"durable_format": health.durable_format.to_string(),
|
|
9996
10167
|
"probe_ms": waited_ms,
|
|
@@ -10006,6 +10177,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10006
10177
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10007
10178
|
Json(json!({
|
|
10008
10179
|
"ready": false,
|
|
10180
|
+
"state": "unavailable",
|
|
10009
10181
|
"error": error.to_string(),
|
|
10010
10182
|
"code": "STORAGE_UNAVAILABLE",
|
|
10011
10183
|
"probe_ms": waited_ms,
|
|
@@ -10016,6 +10188,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10016
10188
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10017
10189
|
Json(json!({
|
|
10018
10190
|
"ready": false,
|
|
10191
|
+
"state": "unavailable",
|
|
10019
10192
|
"error": error.to_string(),
|
|
10020
10193
|
"code": "STORAGE_PROBE_FAILED",
|
|
10021
10194
|
"probe_ms": waited_ms,
|
|
@@ -10026,6 +10199,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10026
10199
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10027
10200
|
Json(json!({
|
|
10028
10201
|
"ready": false,
|
|
10202
|
+
"state": "unavailable",
|
|
10029
10203
|
"error": "storage probe exceeded the server deadline",
|
|
10030
10204
|
"code": "STORAGE_STALLED",
|
|
10031
10205
|
"probe_ms": waited_ms,
|
|
@@ -10370,7 +10544,6 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10370
10544
|
if arguments.get(1).map(String::as_str) != Some("backup") {
|
|
10371
10545
|
return Ok(false);
|
|
10372
10546
|
}
|
|
10373
|
-
let command = arguments.get(2).map(String::as_str).unwrap_or("");
|
|
10374
10547
|
let option = |name: &str| {
|
|
10375
10548
|
arguments
|
|
10376
10549
|
.iter()
|
|
@@ -10378,27 +10551,105 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10378
10551
|
.and_then(|index| arguments.get(index + 1))
|
|
10379
10552
|
.cloned()
|
|
10380
10553
|
};
|
|
10554
|
+
let json_output = arguments.iter().any(|value| value == "--json");
|
|
10555
|
+
// `backup <path>` is `backup create --output <path>`; `backup verify <path>`
|
|
10556
|
+
// is `backup verify --archive <path>`. The flag forms remain supported.
|
|
10557
|
+
let positional = |from: usize| {
|
|
10558
|
+
let mut index = from;
|
|
10559
|
+
while let Some(value) = arguments.get(index) {
|
|
10560
|
+
if value == "--json" {
|
|
10561
|
+
index += 1;
|
|
10562
|
+
} else if value.starts_with("--") {
|
|
10563
|
+
index += 2;
|
|
10564
|
+
} else {
|
|
10565
|
+
return Some(value.clone());
|
|
10566
|
+
}
|
|
10567
|
+
}
|
|
10568
|
+
None
|
|
10569
|
+
};
|
|
10570
|
+
let (command, target) = match arguments.get(2).map(String::as_str) {
|
|
10571
|
+
Some(command @ ("create" | "verify" | "restore")) => (command, positional(3)),
|
|
10572
|
+
Some(value) if !value.starts_with("--") => ("create", Some(value.to_string())),
|
|
10573
|
+
_ if option("--output").is_some() && option("--archive").is_none() => ("create", None),
|
|
10574
|
+
_ => ("", None),
|
|
10575
|
+
};
|
|
10576
|
+
let usage = "usage: feltdb-server backup [create] <output> | backup verify <archive> | backup restore --archive <archive> --output <directory> [--at-ms timestamp] (options: --data path --keys path --audit path --json)";
|
|
10577
|
+
let started = Instant::now();
|
|
10578
|
+
let emit = |report: &feltdb_server::backup::BackupReport, human: String| {
|
|
10579
|
+
if json_output {
|
|
10580
|
+
println!("{}", serde_json::to_string_pretty(report).unwrap_or_default());
|
|
10581
|
+
} else if report.status == "ok" {
|
|
10582
|
+
println!("{human}");
|
|
10583
|
+
} else {
|
|
10584
|
+
eprintln!(
|
|
10585
|
+
"Backup {} failed: {} ({})",
|
|
10586
|
+
report.operation,
|
|
10587
|
+
report.path,
|
|
10588
|
+
report.error.as_deref().unwrap_or("unknown error")
|
|
10589
|
+
);
|
|
10590
|
+
}
|
|
10591
|
+
};
|
|
10381
10592
|
match command {
|
|
10382
10593
|
"create" => {
|
|
10383
10594
|
let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
10384
10595
|
let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
|
|
10385
10596
|
let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
|
|
10386
|
-
let output =
|
|
10387
|
-
let
|
|
10388
|
-
|
|
10597
|
+
let output = option("--output").or(target).ok_or("backup create requires an output path")?;
|
|
10598
|
+
let output = feltdb_server::backup::normalize_path(std::path::Path::new(&output))?;
|
|
10599
|
+
let data = feltdb_server::backup::normalize_path(&data)?;
|
|
10600
|
+
if !data.exists() {
|
|
10601
|
+
return Err(format!("backup source does not exist: {}", data.display()).into());
|
|
10602
|
+
}
|
|
10603
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10604
|
+
data: Some(data.display().to_string()),
|
|
10605
|
+
state_sha256: None,
|
|
10606
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10607
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10608
|
+
};
|
|
10609
|
+
feltdb_server::backup::create_bundle_with_source(&output, &server_artifacts(&data, &keys, &audit), &[], source)?;
|
|
10610
|
+
// A backup is reported as successful only after it verifies
|
|
10611
|
+
// independently of the code path that wrote it.
|
|
10612
|
+
let mut report = feltdb_server::backup::verification_report(&output, started, "create");
|
|
10613
|
+
if report.status == "ok" {
|
|
10614
|
+
if let Err(error) = verify_restorable(&output) {
|
|
10615
|
+
report.status = "failed".into();
|
|
10616
|
+
report.verification = "failed".into();
|
|
10617
|
+
report.error = Some(error.to_string());
|
|
10618
|
+
}
|
|
10619
|
+
}
|
|
10620
|
+
let human = format!("Backup created: {} (id {}, format {}, {} files, {} bytes, verified, {} ms)", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, report.duration_ms);
|
|
10621
|
+
emit(&report, human);
|
|
10622
|
+
if report.status != "ok" {
|
|
10623
|
+
std::process::exit(1);
|
|
10624
|
+
}
|
|
10389
10625
|
}
|
|
10390
10626
|
"verify" => {
|
|
10391
|
-
let archive =
|
|
10392
|
-
let
|
|
10393
|
-
|
|
10627
|
+
let archive = option("--archive").or(target).ok_or("backup verify requires an archive path")?;
|
|
10628
|
+
let mut report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
|
|
10629
|
+
let mut restorable = None;
|
|
10630
|
+
if report.status == "ok" {
|
|
10631
|
+
match verify_restorable(std::path::Path::new(&report.path)) {
|
|
10632
|
+
Ok(detail) => restorable = Some(detail),
|
|
10633
|
+
Err(error) => {
|
|
10634
|
+
report.status = "failed".into();
|
|
10635
|
+
report.verification = "failed".into();
|
|
10636
|
+
report.error = Some(error.to_string());
|
|
10637
|
+
}
|
|
10638
|
+
}
|
|
10639
|
+
}
|
|
10640
|
+
let human = format!("Backup verified: {} (id {}, format {}, {} files, {} bytes{})", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, restorable.map(|detail| format!(", {detail}")).unwrap_or_default());
|
|
10641
|
+
emit(&report, human);
|
|
10642
|
+
if report.status != "ok" {
|
|
10643
|
+
std::process::exit(1);
|
|
10644
|
+
}
|
|
10394
10645
|
}
|
|
10395
10646
|
"restore" => {
|
|
10396
|
-
let archive = PathBuf::from(option("--archive").ok_or("backup restore requires --archive")?);
|
|
10647
|
+
let archive = PathBuf::from(option("--archive").or(target).ok_or("backup restore requires --archive")?);
|
|
10397
10648
|
let archive = match option("--at-ms") {
|
|
10398
10649
|
Some(value) => select_bundle_at(&archive, value.parse().map_err(|_| "backup restore --at-ms must be an unsigned millisecond timestamp")?)?,
|
|
10399
10650
|
None => archive,
|
|
10400
10651
|
};
|
|
10401
|
-
let output =
|
|
10652
|
+
let output = feltdb_server::backup::normalize_path(std::path::Path::new(&option("--output").ok_or("backup restore requires --output")?))?;
|
|
10402
10653
|
let manifest = restore_bundle(&archive, &output)?;
|
|
10403
10654
|
let snapshot_path = output.join("files/database.snapshot.json");
|
|
10404
10655
|
if snapshot_path.exists() {
|
|
@@ -10406,13 +10657,199 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10406
10657
|
let database = FeltDb::open(output.join("files/state.log"))?;
|
|
10407
10658
|
database.install_snapshot(snapshot)?;
|
|
10408
10659
|
}
|
|
10409
|
-
|
|
10660
|
+
let mut report = feltdb_server::backup::verification_report(&archive, started, "restore");
|
|
10661
|
+
report.path = output.display().to_string();
|
|
10662
|
+
emit(&report, format!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len()));
|
|
10663
|
+
if report.status != "ok" {
|
|
10664
|
+
std::process::exit(1);
|
|
10665
|
+
}
|
|
10410
10666
|
}
|
|
10411
|
-
_ => return Err(
|
|
10667
|
+
_ => return Err(usage.into()),
|
|
10412
10668
|
}
|
|
10413
10669
|
Ok(true)
|
|
10414
10670
|
}
|
|
10415
10671
|
|
|
10672
|
+
/// `feltdb-server doctor` — what an operator needs to know, offline.
|
|
10673
|
+
///
|
|
10674
|
+
/// Answers, for a data path: is the database healthy; is the durable log
|
|
10675
|
+
/// valid; what state/version is it in; is recovery pending; can a backup be
|
|
10676
|
+
/// created; can that backup be restored; which server and format versions are
|
|
10677
|
+
/// active. It never opens the live files: the state is bundled exactly as
|
|
10678
|
+
/// `backup create` would, and every probe — open, read, write — runs against a
|
|
10679
|
+
/// private restored copy. `--backup <archive>` additionally verifies an
|
|
10680
|
+
/// existing bundle.
|
|
10681
|
+
///
|
|
10682
|
+
/// The health state is one of `healthy`, `recovering`, `degraded`,
|
|
10683
|
+
/// `unavailable`, or `corrupt`, and `healthy` requires every probe to pass:
|
|
10684
|
+
/// a readable durable log in a supported format, a clean replay, a working
|
|
10685
|
+
/// read, a working write, and a restorable backup.
|
|
10686
|
+
fn manage_doctor() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10687
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
10688
|
+
if arguments.get(1).map(String::as_str) != Some("doctor") {
|
|
10689
|
+
return Ok(false);
|
|
10690
|
+
}
|
|
10691
|
+
let option = |name: &str| {
|
|
10692
|
+
arguments
|
|
10693
|
+
.iter()
|
|
10694
|
+
.position(|value| value == name)
|
|
10695
|
+
.and_then(|index| arguments.get(index + 1))
|
|
10696
|
+
.cloned()
|
|
10697
|
+
};
|
|
10698
|
+
let json_output = arguments.iter().any(|value| value == "--json");
|
|
10699
|
+
let data = feltdb_server::backup::normalize_path(&PathBuf::from(
|
|
10700
|
+
option("--data").unwrap_or_else(|| "./data/feltdb.log".into()),
|
|
10701
|
+
))?;
|
|
10702
|
+
let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
|
|
10703
|
+
let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
|
|
10704
|
+
|
|
10705
|
+
let mut checks: Vec<Value> = Vec::new();
|
|
10706
|
+
let mut check = |name: &str, ok: bool, detail: String| {
|
|
10707
|
+
checks.push(json!({ "name": name, "status": if ok { "PASS" } else { "FAIL" }, "detail": detail }));
|
|
10708
|
+
ok
|
|
10709
|
+
};
|
|
10710
|
+
let scratch = std::env::temp_dir().join(format!(
|
|
10711
|
+
"feltdb-doctor-{}-{}",
|
|
10712
|
+
std::process::id(),
|
|
10713
|
+
rand::random::<u64>()
|
|
10714
|
+
));
|
|
10715
|
+
let bundle = scratch.join("bundle");
|
|
10716
|
+
let restored = scratch.join("restored");
|
|
10717
|
+
let mut state = "healthy";
|
|
10718
|
+
let mut facts = json!({
|
|
10719
|
+
"data": data.display().to_string(),
|
|
10720
|
+
"server_version": env!("CARGO_PKG_VERSION"),
|
|
10721
|
+
"supported_durable_format": feltdb::DURABLE_FORMAT_VERSION,
|
|
10722
|
+
"backup_format": feltdb_server::backup::BACKUP_FORMAT,
|
|
10723
|
+
});
|
|
10724
|
+
|
|
10725
|
+
if !check("data present", data.exists(), data.display().to_string()) {
|
|
10726
|
+
state = "unavailable";
|
|
10727
|
+
} else {
|
|
10728
|
+
std::fs::create_dir_all(&scratch)?;
|
|
10729
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10730
|
+
data: Some(data.display().to_string()),
|
|
10731
|
+
state_sha256: None,
|
|
10732
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10733
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10734
|
+
};
|
|
10735
|
+
let bundled = feltdb_server::backup::create_bundle_with_source(&bundle, &server_artifacts(&data, &keys, &audit), &[], source);
|
|
10736
|
+
if !check("backup can be created", bundled.is_ok(), bundled.as_ref().map(|manifest| format!("{} files", manifest.entries.len())).unwrap_or_else(|error| error.to_string())) {
|
|
10737
|
+
state = "unavailable";
|
|
10738
|
+
} else {
|
|
10739
|
+
let restorable = restore_bundle(&bundle, &restored);
|
|
10740
|
+
check("backup can be restored", restorable.is_ok(), restorable.as_ref().map(|_| "restored to a private copy".to_string()).unwrap_or_else(|error| error.to_string()));
|
|
10741
|
+
match FeltDb::open(restored.join("files/state.log")) {
|
|
10742
|
+
Err(error) => {
|
|
10743
|
+
let corrupt = matches!(error, feltdb::FlowError::CorruptLogLine(_) | feltdb::FlowError::IncompatibleFormat(_));
|
|
10744
|
+
check("durable log valid", false, error.to_string());
|
|
10745
|
+
state = if corrupt { "corrupt" } else { "unavailable" };
|
|
10746
|
+
}
|
|
10747
|
+
Ok(database) => {
|
|
10748
|
+
let health = database.health();
|
|
10749
|
+
facts["durable_format"] = json!(health.durable_format.to_string());
|
|
10750
|
+
facts["storage"] = json!(health.storage.label());
|
|
10751
|
+
facts["recovery"] = json!(health.recovery.to_string());
|
|
10752
|
+
facts["sequence"] = json!(database.sequence().unwrap_or(0));
|
|
10753
|
+
check("durable log valid", true, format!("durable format {}", health.durable_format));
|
|
10754
|
+
if !check("replay clean", health.storage.is_clean(), health.recovery.to_string()) {
|
|
10755
|
+
state = "degraded";
|
|
10756
|
+
}
|
|
10757
|
+
let read = database.list_collection_page("_flow_capabilities", None, 1);
|
|
10758
|
+
if !check("read path", read.is_ok(), read.as_ref().map(|_| "bounded read succeeded".to_string()).unwrap_or_else(|error| error.to_string())) {
|
|
10759
|
+
state = "unavailable";
|
|
10760
|
+
}
|
|
10761
|
+
let probe_key = "_feltdb_doctor:probe";
|
|
10762
|
+
let wrote = database
|
|
10763
|
+
.insert(probe_key, json!({ "probe": true }))
|
|
10764
|
+
.and_then(|_| database.get_value(probe_key))
|
|
10765
|
+
.map(|value| value.is_some());
|
|
10766
|
+
if !check("mutation path", matches!(wrote, Ok(true)), match &wrote { Ok(true) => "write and read-back on the private copy".into(), Ok(false) => "write was not readable".into(), Err(error) => error.to_string() }) {
|
|
10767
|
+
state = "unavailable";
|
|
10768
|
+
}
|
|
10769
|
+
}
|
|
10770
|
+
}
|
|
10771
|
+
}
|
|
10772
|
+
if let Ok(cluster) = ClusterStore::load(data.with_extension("cluster.json"), Vec::new()) {
|
|
10773
|
+
let pending = matches!(cluster.proposal().map(|value| value.phase), Some(ProposalPhase::Preparing | ProposalPhase::Prepared));
|
|
10774
|
+
facts["pending_recovery"] = json!(pending);
|
|
10775
|
+
check("no pending membership recovery", !pending, if pending { "a membership change is mid-flight".into() } else { "none".into() });
|
|
10776
|
+
if pending && state == "healthy" {
|
|
10777
|
+
state = "recovering";
|
|
10778
|
+
}
|
|
10779
|
+
}
|
|
10780
|
+
}
|
|
10781
|
+
if let Some(archive) = option("--backup") {
|
|
10782
|
+
let started = Instant::now();
|
|
10783
|
+
let report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
|
|
10784
|
+
let restorable = if report.status == "ok" { verify_restorable(std::path::Path::new(&report.path)).map_err(|error| error.to_string()) } else { Err(report.error.clone().unwrap_or_default()) };
|
|
10785
|
+
check("supplied backup restorable", restorable.is_ok(), restorable.unwrap_or_else(|error| error));
|
|
10786
|
+
facts["backup"] = json!(report);
|
|
10787
|
+
}
|
|
10788
|
+
let _ = std::fs::remove_dir_all(&scratch);
|
|
10789
|
+
let healthy_requires_all = checks.iter().all(|entry| entry["status"] == "PASS");
|
|
10790
|
+
if state == "healthy" && !healthy_requires_all {
|
|
10791
|
+
state = "degraded";
|
|
10792
|
+
}
|
|
10793
|
+
let report = json!({ "command": "doctor", "state": state, "facts": facts, "checks": checks });
|
|
10794
|
+
if json_output {
|
|
10795
|
+
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
10796
|
+
} else {
|
|
10797
|
+
println!("FeltDB doctor: {state}");
|
|
10798
|
+
for entry in report["checks"].as_array().into_iter().flatten() {
|
|
10799
|
+
println!(" {} {}: {}", if entry["status"] == "PASS" { "✓" } else { "✗" }, entry["name"].as_str().unwrap_or(""), entry["detail"].as_str().unwrap_or(""));
|
|
10800
|
+
}
|
|
10801
|
+
}
|
|
10802
|
+
if state != "healthy" {
|
|
10803
|
+
std::process::exit(1);
|
|
10804
|
+
}
|
|
10805
|
+
Ok(true)
|
|
10806
|
+
}
|
|
10807
|
+
|
|
10808
|
+
/// Prove a bundle restores into a database this build can open.
|
|
10809
|
+
///
|
|
10810
|
+
/// Checksums prove the bytes are the ones recorded; they cannot prove the
|
|
10811
|
+
/// recorded bytes are a FeltDB database in a format this build understands.
|
|
10812
|
+
/// This restores into a private temporary directory, opens it, and reads it —
|
|
10813
|
+
/// the same path `backup restore` and a subsequent server start take — and
|
|
10814
|
+
/// never touches the bundle or any live database.
|
|
10815
|
+
fn verify_restorable(archive: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
|
|
10816
|
+
let manifest = verify_bundle(archive)?;
|
|
10817
|
+
let has_log = manifest.entries.iter().any(|entry| entry.path == "files/state.log");
|
|
10818
|
+
let has_snapshot = manifest
|
|
10819
|
+
.entries
|
|
10820
|
+
.iter()
|
|
10821
|
+
.any(|entry| entry.path == "files/database.snapshot.json");
|
|
10822
|
+
if !has_log && !has_snapshot {
|
|
10823
|
+
return Err("backup holds no authoritative state (neither state.log nor database.snapshot.json)".into());
|
|
10824
|
+
}
|
|
10825
|
+
let scratch = std::env::temp_dir().join(format!(
|
|
10826
|
+
"feltdb-backup-verify-{}-{}",
|
|
10827
|
+
std::process::id(),
|
|
10828
|
+
rand::random::<u64>()
|
|
10829
|
+
));
|
|
10830
|
+
let restored = scratch.join("restored");
|
|
10831
|
+
let result = (|| -> Result<String, Box<dyn std::error::Error>> {
|
|
10832
|
+
std::fs::create_dir_all(&scratch)?;
|
|
10833
|
+
restore_bundle(archive, &restored)?;
|
|
10834
|
+
let database = FeltDb::open(restored.join("files/state.log"))?;
|
|
10835
|
+
if has_snapshot {
|
|
10836
|
+
let snapshot: DatabaseSnapshot = serde_json::from_slice(&std::fs::read(
|
|
10837
|
+
restored.join("files/database.snapshot.json"),
|
|
10838
|
+
)?)?;
|
|
10839
|
+
database.install_snapshot(snapshot)?;
|
|
10840
|
+
}
|
|
10841
|
+
let health = database.health();
|
|
10842
|
+
Ok(format!(
|
|
10843
|
+
"restorable: durable format {}, storage {}, sequence {}",
|
|
10844
|
+
health.durable_format,
|
|
10845
|
+
health.storage.label(),
|
|
10846
|
+
database.sequence()?
|
|
10847
|
+
))
|
|
10848
|
+
})();
|
|
10849
|
+
let _ = std::fs::remove_dir_all(&scratch);
|
|
10850
|
+
result
|
|
10851
|
+
}
|
|
10852
|
+
|
|
10416
10853
|
fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10417
10854
|
let arguments: Vec<String> = std::env::args().collect();
|
|
10418
10855
|
if arguments.get(1).map(String::as_str) != Some("app") {
|
|
@@ -10515,12 +10952,24 @@ async fn create_online_backup(
|
|
|
10515
10952
|
.cloned()
|
|
10516
10953
|
.collect();
|
|
10517
10954
|
let output = PathBuf::from(request.output);
|
|
10955
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10956
|
+
data: state
|
|
10957
|
+
.backup_artifacts
|
|
10958
|
+
.iter()
|
|
10959
|
+
.find(|(label, _)| label == "state.log")
|
|
10960
|
+
.and_then(|(_, path)| feltdb_server::backup::normalize_path(path).ok())
|
|
10961
|
+
.map(|path| path.display().to_string()),
|
|
10962
|
+
state_sha256: None,
|
|
10963
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10964
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10965
|
+
};
|
|
10518
10966
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
10519
10967
|
let _guard = guard;
|
|
10520
|
-
|
|
10968
|
+
feltdb_server::backup::create_bundle_with_source(
|
|
10521
10969
|
&output,
|
|
10522
10970
|
&artifacts,
|
|
10523
10971
|
&[("database.snapshot.json".to_string(), snapshot_bytes)],
|
|
10972
|
+
source,
|
|
10524
10973
|
)
|
|
10525
10974
|
})
|
|
10526
10975
|
.await
|
|
@@ -13584,6 +14033,28 @@ struct AtomicTxRequest {
|
|
|
13584
14033
|
/// exactly as before.
|
|
13585
14034
|
#[serde(default)]
|
|
13586
14035
|
preconditions: Vec<AtomicTxPreconditionRequest>,
|
|
14036
|
+
/// Read bases this authority issued (`POST /transactions/read-basis`),
|
|
14037
|
+
/// re-evaluated inside the commit lock. Absent means no fence.
|
|
14038
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
14039
|
+
fences: Vec<feltdb::ReadBasis>,
|
|
14040
|
+
}
|
|
14041
|
+
|
|
14042
|
+
/// Capture a read basis: what the authority holds, now, for a set of records
|
|
14043
|
+
/// and equality predicates. Reading, so it requires `state:read`.
|
|
14044
|
+
async fn capture_read_basis(
|
|
14045
|
+
State(state): State<AppState>,
|
|
14046
|
+
Json(reads): Json<feltdb::ReadSet>,
|
|
14047
|
+
) -> Result<Json<feltdb::ReadBasis>, ApiError> {
|
|
14048
|
+
state
|
|
14049
|
+
.db
|
|
14050
|
+
.read_basis(&reads)
|
|
14051
|
+
.map(Json)
|
|
14052
|
+
.map_err(|error| match error {
|
|
14053
|
+
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
14054
|
+
ApiError(StatusCode::BAD_REQUEST, message)
|
|
14055
|
+
}
|
|
14056
|
+
other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
|
|
14057
|
+
})
|
|
13587
14058
|
}
|
|
13588
14059
|
|
|
13589
14060
|
#[derive(Serialize)]
|
|
@@ -13645,6 +14116,8 @@ async fn commit_transaction(
|
|
|
13645
14116
|
let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
|
|
13646
14117
|
let mut seen = std::collections::HashSet::new();
|
|
13647
14118
|
|
|
14119
|
+
let fences = request.fences.clone();
|
|
14120
|
+
|
|
13648
14121
|
// Transaction-level preconditions first, on records the transaction need
|
|
13649
14122
|
// not write at all.
|
|
13650
14123
|
for condition in &request.preconditions {
|
|
@@ -13722,12 +14195,13 @@ async fn commit_transaction(
|
|
|
13722
14195
|
let operations = mutations.len();
|
|
13723
14196
|
let commit = state
|
|
13724
14197
|
.db
|
|
13725
|
-
.
|
|
14198
|
+
.apply_atomic_transaction_fenced(
|
|
13726
14199
|
&request.transaction_id,
|
|
13727
14200
|
Some(&payload_hash),
|
|
13728
14201
|
None,
|
|
13729
14202
|
&[],
|
|
13730
14203
|
&record_preconditions,
|
|
14204
|
+
&fences,
|
|
13731
14205
|
&mutations,
|
|
13732
14206
|
None,
|
|
13733
14207
|
)
|
|
@@ -13747,6 +14221,9 @@ async fn commit_transaction(
|
|
|
13747
14221
|
"failure": *failure,
|
|
13748
14222
|
}),
|
|
13749
14223
|
),
|
|
14224
|
+
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
14225
|
+
ApiError(StatusCode::BAD_REQUEST, message)
|
|
14226
|
+
}
|
|
13750
14227
|
FlowError::CapabilityError(message)
|
|
13751
14228
|
if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
|
|
13752
14229
|
{
|