@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
|
@@ -6883,7 +6883,8 @@ async fn application_openapi() -> Json<Value> {
|
|
|
6883
6883
|
},
|
|
6884
6884
|
"security": [{ "bearer": [] }],
|
|
6885
6885
|
"paths": {
|
|
6886
|
-
"/health": { "get": { "security": [], "responses": { "200": { "description": "
|
|
6886
|
+
"/health": { "get": { "security": [], "responses": { "200": { "description": "Process liveness" } } } },
|
|
6887
|
+
"/health/ready": { "get": { "security": [], "responses": { "200": { "description": "Storage and scoped-operation readiness" }, "503": { "description": "Not ready" } } } },
|
|
6887
6888
|
"/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
|
|
6888
6889
|
"/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
|
|
6889
6890
|
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
@@ -8091,6 +8092,8 @@ struct Config {
|
|
|
8091
8092
|
peers: Vec<String>,
|
|
8092
8093
|
peer_token_env: String,
|
|
8093
8094
|
sync_interval: Duration,
|
|
8095
|
+
max_in_flight: usize,
|
|
8096
|
+
request_deadline: Duration,
|
|
8094
8097
|
audit: PathBuf,
|
|
8095
8098
|
allowed_origins: Vec<axum::http::HeaderValue>,
|
|
8096
8099
|
worker_enabled: bool,
|
|
@@ -8119,6 +8122,11 @@ impl Config {
|
|
|
8119
8122
|
let mut peers = Vec::new();
|
|
8120
8123
|
let mut peer_token_env = "FELTDB_PEER_TOKEN".to_string();
|
|
8121
8124
|
let mut sync_interval = Duration::from_secs(2);
|
|
8125
|
+
// Concurrency past this point does not buy throughput: the database
|
|
8126
|
+
// serializes every read and write on one lock. It only decides whether
|
|
8127
|
+
// an overload is shed quickly or queued invisibly.
|
|
8128
|
+
let mut max_in_flight = 64usize;
|
|
8129
|
+
let mut request_deadline = Duration::from_secs(10);
|
|
8122
8130
|
let mut audit = None;
|
|
8123
8131
|
let mut allowed_origins = std::env::var("FELTDB_ALLOWED_ORIGINS")
|
|
8124
8132
|
.ok()
|
|
@@ -8165,6 +8173,23 @@ impl Config {
|
|
|
8165
8173
|
}
|
|
8166
8174
|
sync_interval = Duration::from_millis(milliseconds);
|
|
8167
8175
|
}
|
|
8176
|
+
"--max-in-flight" => {
|
|
8177
|
+
max_in_flight = value()?
|
|
8178
|
+
.parse()
|
|
8179
|
+
.map_err(|_| "invalid --max-in-flight".to_string())?;
|
|
8180
|
+
if max_in_flight == 0 {
|
|
8181
|
+
return Err("--max-in-flight must be at least 1".to_string());
|
|
8182
|
+
}
|
|
8183
|
+
}
|
|
8184
|
+
"--request-deadline-ms" => {
|
|
8185
|
+
let milliseconds: u64 = value()?
|
|
8186
|
+
.parse()
|
|
8187
|
+
.map_err(|_| "invalid --request-deadline-ms".to_string())?;
|
|
8188
|
+
if milliseconds < 100 {
|
|
8189
|
+
return Err("--request-deadline-ms must be at least 100".to_string());
|
|
8190
|
+
}
|
|
8191
|
+
request_deadline = Duration::from_millis(milliseconds);
|
|
8192
|
+
}
|
|
8168
8193
|
"--audit" => audit = Some(PathBuf::from(value()?)),
|
|
8169
8194
|
"--allow-origin" => allowed_origins.push(
|
|
8170
8195
|
value()?
|
|
@@ -8204,6 +8229,8 @@ impl Config {
|
|
|
8204
8229
|
peers,
|
|
8205
8230
|
peer_token_env,
|
|
8206
8231
|
sync_interval,
|
|
8232
|
+
max_in_flight,
|
|
8233
|
+
request_deadline,
|
|
8207
8234
|
audit,
|
|
8208
8235
|
allowed_origins,
|
|
8209
8236
|
worker_enabled,
|
|
@@ -8357,10 +8384,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8357
8384
|
let audit_handle = AuditLog::new(config.audit.clone());
|
|
8358
8385
|
let state = AppState {
|
|
8359
8386
|
started_at: Instant::now(),
|
|
8387
|
+
admission: Arc::new(tokio::sync::Semaphore::new(config.max_in_flight)),
|
|
8388
|
+
request_deadline: config.request_deadline,
|
|
8360
8389
|
ids: Arc::new(AtomicU64::new(db.sequence()?)),
|
|
8361
8390
|
db,
|
|
8362
8391
|
namespace: Arc::from(config.namespace.clone()),
|
|
8363
8392
|
auth_enabled: config.auth_enabled,
|
|
8393
|
+
// Argon2 is deliberately memory-hard. Bound concurrent legacy/session
|
|
8394
|
+
// verification so a burst waits asynchronously instead of exhausting
|
|
8395
|
+
// a small production VM with one allocation per request.
|
|
8396
|
+
authentication_workers: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8364
8397
|
keys: KeyStore::load(&config.keys)?,
|
|
8365
8398
|
metrics: Metrics::default(),
|
|
8366
8399
|
cluster: ClusterStore::load(
|
|
@@ -9063,6 +9096,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9063
9096
|
|
|
9064
9097
|
let mut app = Router::new()
|
|
9065
9098
|
.route("/health", get(health))
|
|
9099
|
+
.route("/health/ready", get(health_ready))
|
|
9066
9100
|
.route("/v1/health", get(health))
|
|
9067
9101
|
.route("/v1/openapi.json", get(application_openapi))
|
|
9068
9102
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
@@ -9086,6 +9120,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9086
9120
|
)
|
|
9087
9121
|
.merge(protected)
|
|
9088
9122
|
.merge(query_execution_diagnostics_router())
|
|
9123
|
+
.layer(middleware::from_fn_with_state(state.clone(), admit_request))
|
|
9089
9124
|
.layer(middleware::from_fn(protocol_version))
|
|
9090
9125
|
.layer(middleware::from_fn(attribute_request))
|
|
9091
9126
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
@@ -9171,16 +9206,52 @@ async fn authenticate(
|
|
|
9171
9206
|
.headers()
|
|
9172
9207
|
.get(AUTHORIZATION)
|
|
9173
9208
|
.and_then(|header| header.to_str().ok())
|
|
9174
|
-
.and_then(|header| header.strip_prefix("Bearer "))
|
|
9175
|
-
|
|
9209
|
+
.and_then(|header| header.strip_prefix("Bearer "))
|
|
9210
|
+
.map(str::to_owned);
|
|
9211
|
+
let keys = state.keys.clone();
|
|
9212
|
+
let identities = state.identities.clone();
|
|
9213
|
+
let namespace = state.namespace.clone();
|
|
9214
|
+
let authentication_worker = match state.authentication_workers.clone().acquire_owned().await
|
|
9215
|
+
{
|
|
9216
|
+
Ok(permit) => permit,
|
|
9217
|
+
Err(error) => {
|
|
9218
|
+
tracing::error!(%error, "authentication worker limit closed");
|
|
9219
|
+
return ApiError(
|
|
9220
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9221
|
+
"authentication temporarily unavailable".into(),
|
|
9222
|
+
)
|
|
9223
|
+
.into_response();
|
|
9224
|
+
}
|
|
9225
|
+
};
|
|
9226
|
+
let (machine, actor) = match tokio::task::spawn_blocking(move || {
|
|
9227
|
+
let _authentication_worker = authentication_worker;
|
|
9228
|
+
let machine = token
|
|
9229
|
+
.as_deref()
|
|
9230
|
+
.and_then(|token| keys.authenticate(token, &namespace));
|
|
9231
|
+
let actor = if machine.is_none() {
|
|
9232
|
+
token
|
|
9233
|
+
.as_deref()
|
|
9234
|
+
.and_then(|token| identities.authenticate_session(token))
|
|
9235
|
+
} else {
|
|
9236
|
+
None
|
|
9237
|
+
};
|
|
9238
|
+
(machine, actor)
|
|
9239
|
+
})
|
|
9240
|
+
.await
|
|
9241
|
+
{
|
|
9242
|
+
Ok(authentication) => authentication,
|
|
9243
|
+
Err(error) => {
|
|
9244
|
+
tracing::error!(%error, "bearer authentication worker failed");
|
|
9245
|
+
return ApiError(
|
|
9246
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9247
|
+
"authentication temporarily unavailable".into(),
|
|
9248
|
+
)
|
|
9249
|
+
.into_response();
|
|
9250
|
+
}
|
|
9251
|
+
};
|
|
9176
9252
|
// A valid service key is already authoritative. Do not feed that
|
|
9177
9253
|
// high-entropy bearer token through the Argon2-backed human-session
|
|
9178
9254
|
// verifier as well; doing so blocks the request worker needlessly.
|
|
9179
|
-
let actor = if machine.is_none() {
|
|
9180
|
-
token.and_then(|token| state.identities.authenticate_session(token))
|
|
9181
|
-
} else {
|
|
9182
|
-
None
|
|
9183
|
-
};
|
|
9184
9255
|
let human = request
|
|
9185
9256
|
.headers()
|
|
9186
9257
|
.get(COOKIE)
|
|
@@ -9431,6 +9502,234 @@ async fn attribute_request(request: Request<axum::body::Body>, next: Next) -> Re
|
|
|
9431
9502
|
next.run(request).await
|
|
9432
9503
|
}
|
|
9433
9504
|
|
|
9505
|
+
/// The overall status, from the conditions that actually bear on it.
|
|
9506
|
+
///
|
|
9507
|
+
/// Storage participates. Before it did not, so a database that had discarded an
|
|
9508
|
+
/// incomplete final append reported exactly what a cleanly replayed one did —
|
|
9509
|
+
/// which is the difference between an operator learning their last write was
|
|
9510
|
+
/// lost and never finding out.
|
|
9511
|
+
fn overall_status(
|
|
9512
|
+
membership_healthy: bool,
|
|
9513
|
+
clock_healthy: bool,
|
|
9514
|
+
storage: &feltdb::StorageHealth,
|
|
9515
|
+
) -> &'static str {
|
|
9516
|
+
if membership_healthy && clock_healthy && storage.is_clean() {
|
|
9517
|
+
"healthy"
|
|
9518
|
+
} else {
|
|
9519
|
+
"degraded"
|
|
9520
|
+
}
|
|
9521
|
+
}
|
|
9522
|
+
|
|
9523
|
+
#[cfg(test)]
|
|
9524
|
+
mod admission_tests {
|
|
9525
|
+
use super::overloaded;
|
|
9526
|
+
use axum::http::StatusCode;
|
|
9527
|
+
use axum::response::IntoResponse;
|
|
9528
|
+
|
|
9529
|
+
/// An overload is refused with a status and a hint, not a hang.
|
|
9530
|
+
///
|
|
9531
|
+
/// The managed incident produced client-side timeouts and upstream 502s
|
|
9532
|
+
/// because the server had no inbound deadline and no admission bound: a
|
|
9533
|
+
/// request that could not proceed simply waited. A caller cannot act on
|
|
9534
|
+
/// that. It can act on this.
|
|
9535
|
+
#[test]
|
|
9536
|
+
fn an_overload_is_an_explicit_refusal_with_a_retry_hint() {
|
|
9537
|
+
let response = overloaded("server is at its in-flight request limit").into_response();
|
|
9538
|
+
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
9539
|
+
assert_eq!(
|
|
9540
|
+
response
|
|
9541
|
+
.headers()
|
|
9542
|
+
.get(axum::http::header::RETRY_AFTER)
|
|
9543
|
+
.and_then(|value| value.to_str().ok()),
|
|
9544
|
+
Some("1"),
|
|
9545
|
+
"a caller is told when to come back"
|
|
9546
|
+
);
|
|
9547
|
+
}
|
|
9548
|
+
|
|
9549
|
+
/// 503 rather than 500: the request was refused, not mishandled.
|
|
9550
|
+
#[test]
|
|
9551
|
+
fn an_overload_is_not_reported_as_a_server_fault() {
|
|
9552
|
+
let response = overloaded("request exceeded the server deadline").into_response();
|
|
9553
|
+
assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
9554
|
+
assert!(response.status().is_server_error());
|
|
9555
|
+
}
|
|
9556
|
+
}
|
|
9557
|
+
|
|
9558
|
+
#[cfg(test)]
|
|
9559
|
+
mod health_status_tests {
|
|
9560
|
+
use super::overall_status;
|
|
9561
|
+
use feltdb::StorageHealth;
|
|
9562
|
+
|
|
9563
|
+
const RECOVERED: StorageHealth = StorageHealth::RecoveredIncompleteWrite {
|
|
9564
|
+
byte_offset: 128,
|
|
9565
|
+
discarded_bytes: 40,
|
|
9566
|
+
};
|
|
9567
|
+
|
|
9568
|
+
/// Storage participates in the overall status.
|
|
9569
|
+
///
|
|
9570
|
+
/// It did not before: a database that had discarded an incomplete final
|
|
9571
|
+
/// append reported exactly what a cleanly replayed one did, so the
|
|
9572
|
+
/// distinction the durable-log work established never reached an operator.
|
|
9573
|
+
#[test]
|
|
9574
|
+
fn a_recovered_database_is_reported_degraded() {
|
|
9575
|
+
assert_eq!(overall_status(true, true, &StorageHealth::Clean), "healthy");
|
|
9576
|
+
assert_eq!(overall_status(true, true, &RECOVERED), "degraded");
|
|
9577
|
+
}
|
|
9578
|
+
|
|
9579
|
+
/// And the conditions that already counted still do, so this narrows
|
|
9580
|
+
/// nothing.
|
|
9581
|
+
#[test]
|
|
9582
|
+
fn membership_and_clock_still_count() {
|
|
9583
|
+
assert_eq!(
|
|
9584
|
+
overall_status(false, true, &StorageHealth::Clean),
|
|
9585
|
+
"degraded"
|
|
9586
|
+
);
|
|
9587
|
+
assert_eq!(
|
|
9588
|
+
overall_status(true, false, &StorageHealth::Clean),
|
|
9589
|
+
"degraded"
|
|
9590
|
+
);
|
|
9591
|
+
assert_eq!(overall_status(false, false, &RECOVERED), "degraded");
|
|
9592
|
+
}
|
|
9593
|
+
|
|
9594
|
+
/// The reported storage value names an observation rather than asserting
|
|
9595
|
+
/// the property that is still unproven.
|
|
9596
|
+
#[test]
|
|
9597
|
+
fn the_storage_label_is_an_observation_not_a_promise() {
|
|
9598
|
+
assert_eq!(StorageHealth::Clean.label(), "clean");
|
|
9599
|
+
assert_eq!(RECOVERED.label(), "recovered-incomplete-write");
|
|
9600
|
+
for label in [StorageHealth::Clean.label(), RECOVERED.label()] {
|
|
9601
|
+
assert_ne!(label, "durable", "health must not claim durability");
|
|
9602
|
+
}
|
|
9603
|
+
}
|
|
9604
|
+
}
|
|
9605
|
+
|
|
9606
|
+
/// Shed load at the door, and abandon work the caller is no longer waiting for.
|
|
9607
|
+
///
|
|
9608
|
+
/// Two failures this replaces, both observed in production:
|
|
9609
|
+
///
|
|
9610
|
+
/// - **No inbound deadline.** A slow operation ran until the *client* gave up,
|
|
9611
|
+
/// and then kept running, so the load that caused the stall outlived every
|
|
9612
|
+
/// request that reported it.
|
|
9613
|
+
/// - **No admission bound.** Requests queued on the database's single lock
|
|
9614
|
+
/// until an upstream proxy returned 502. Concurrency past the bound buys no
|
|
9615
|
+
/// throughput — every read and write serializes on that lock — so all it
|
|
9616
|
+
/// decides is whether an overload is refused quickly or hidden.
|
|
9617
|
+
///
|
|
9618
|
+
/// Both now produce an explicit `503` with `Retry-After`, which is a signal a
|
|
9619
|
+
/// caller can act on rather than a timeout it has to interpret.
|
|
9620
|
+
async fn admit_request(
|
|
9621
|
+
State(state): State<AppState>,
|
|
9622
|
+
request: axum::extract::Request,
|
|
9623
|
+
next: Next,
|
|
9624
|
+
) -> axum::response::Response {
|
|
9625
|
+
let Ok(_permit) = state.admission.clone().try_acquire_owned() else {
|
|
9626
|
+
state.metrics.request_shed();
|
|
9627
|
+
return overloaded("server is at its in-flight request limit");
|
|
9628
|
+
};
|
|
9629
|
+
|
|
9630
|
+
match tokio::time::timeout(state.request_deadline, next.run(request)).await {
|
|
9631
|
+
Ok(response) => response,
|
|
9632
|
+
Err(_) => {
|
|
9633
|
+
state.metrics.request_deadline_exceeded();
|
|
9634
|
+
overloaded("request exceeded the server deadline")
|
|
9635
|
+
}
|
|
9636
|
+
}
|
|
9637
|
+
}
|
|
9638
|
+
|
|
9639
|
+
/// A refusal a caller can act on: an explicit status, a stable code, and a hint.
|
|
9640
|
+
fn overloaded(detail: &str) -> axum::response::Response {
|
|
9641
|
+
let mut response = (
|
|
9642
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9643
|
+
Json(json!({
|
|
9644
|
+
"error": detail,
|
|
9645
|
+
"code": "SERVICE_OVERLOADED",
|
|
9646
|
+
})),
|
|
9647
|
+
)
|
|
9648
|
+
.into_response();
|
|
9649
|
+
response.headers_mut().insert(
|
|
9650
|
+
axum::http::header::RETRY_AFTER,
|
|
9651
|
+
axum::http::HeaderValue::from_static("1"),
|
|
9652
|
+
);
|
|
9653
|
+
response
|
|
9654
|
+
}
|
|
9655
|
+
|
|
9656
|
+
/// Readiness: whether this server can actually serve a scoped operation.
|
|
9657
|
+
///
|
|
9658
|
+
/// `/health` answers "is this process alive". It reads cluster state and the
|
|
9659
|
+
/// lease clock and never touches storage, so it cannot fail for a database
|
|
9660
|
+
/// reason — which is exactly how a deployment reported `200 healthy` while
|
|
9661
|
+
/// every scoped read timed out.
|
|
9662
|
+
///
|
|
9663
|
+
/// This endpoint takes the database lock and performs a real bounded read, off
|
|
9664
|
+
/// the runtime and under the same deadline a request gets. **A server whose
|
|
9665
|
+
/// scoped operations are stalled reports `503` here.**
|
|
9666
|
+
async fn health_ready(State(state): State<AppState>) -> axum::response::Response {
|
|
9667
|
+
let probe = state.clone();
|
|
9668
|
+
let started = Instant::now();
|
|
9669
|
+
let outcome = tokio::time::timeout(
|
|
9670
|
+
state.request_deadline,
|
|
9671
|
+
tokio::task::spawn_blocking(move || {
|
|
9672
|
+
// A real scoped operation: acquire the state lock and read through
|
|
9673
|
+
// it. Bounded, so a large database does not make readiness slow.
|
|
9674
|
+
let health = probe.db.health();
|
|
9675
|
+
let _ = probe
|
|
9676
|
+
.db
|
|
9677
|
+
.list_collection_page("_flow_capabilities", None, 1)?;
|
|
9678
|
+
Ok::<_, feltdb::FlowError>(health)
|
|
9679
|
+
}),
|
|
9680
|
+
)
|
|
9681
|
+
.await;
|
|
9682
|
+
|
|
9683
|
+
let waited_ms = started.elapsed().as_millis() as u64;
|
|
9684
|
+
match outcome {
|
|
9685
|
+
Ok(Ok(Ok(health))) => {
|
|
9686
|
+
let ready = health.is_nominal();
|
|
9687
|
+
let body = Json(json!({
|
|
9688
|
+
"ready": ready,
|
|
9689
|
+
"storage": health.storage.label(),
|
|
9690
|
+
"durable_format": health.durable_format.to_string(),
|
|
9691
|
+
"probe_ms": waited_ms,
|
|
9692
|
+
}));
|
|
9693
|
+
if ready {
|
|
9694
|
+
(StatusCode::OK, body).into_response()
|
|
9695
|
+
} else {
|
|
9696
|
+
// Usable, but not in the condition a clean open produces.
|
|
9697
|
+
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
|
9698
|
+
}
|
|
9699
|
+
}
|
|
9700
|
+
Ok(Ok(Err(error))) => (
|
|
9701
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9702
|
+
Json(json!({
|
|
9703
|
+
"ready": false,
|
|
9704
|
+
"error": error.to_string(),
|
|
9705
|
+
"code": "STORAGE_UNAVAILABLE",
|
|
9706
|
+
"probe_ms": waited_ms,
|
|
9707
|
+
})),
|
|
9708
|
+
)
|
|
9709
|
+
.into_response(),
|
|
9710
|
+
Ok(Err(error)) => (
|
|
9711
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9712
|
+
Json(json!({
|
|
9713
|
+
"ready": false,
|
|
9714
|
+
"error": error.to_string(),
|
|
9715
|
+
"code": "STORAGE_PROBE_FAILED",
|
|
9716
|
+
"probe_ms": waited_ms,
|
|
9717
|
+
})),
|
|
9718
|
+
)
|
|
9719
|
+
.into_response(),
|
|
9720
|
+
Err(_) => (
|
|
9721
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9722
|
+
Json(json!({
|
|
9723
|
+
"ready": false,
|
|
9724
|
+
"error": "storage probe exceeded the server deadline",
|
|
9725
|
+
"code": "STORAGE_STALLED",
|
|
9726
|
+
"probe_ms": waited_ms,
|
|
9727
|
+
})),
|
|
9728
|
+
)
|
|
9729
|
+
.into_response(),
|
|
9730
|
+
}
|
|
9731
|
+
}
|
|
9732
|
+
|
|
9434
9733
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>> {
|
|
9435
9734
|
let membership = match state.cluster.proposal().map(|value| value.phase) {
|
|
9436
9735
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
@@ -9438,16 +9737,16 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
9438
9737
|
_ => "healthy",
|
|
9439
9738
|
};
|
|
9440
9739
|
let clock = state.lease_clock.is_healthy();
|
|
9740
|
+
// Observed, not asserted. An incompatible or corrupt database never opens,
|
|
9741
|
+
// so a running server has already established its format; what remains to
|
|
9742
|
+
// report is whether replay discarded anything.
|
|
9743
|
+
let database = state.db.health();
|
|
9441
9744
|
Json(HealthResponse {
|
|
9442
|
-
status:
|
|
9443
|
-
"healthy"
|
|
9444
|
-
} else {
|
|
9445
|
-
"degraded"
|
|
9446
|
-
},
|
|
9745
|
+
status: overall_status(membership == "healthy", clock, &database.storage),
|
|
9447
9746
|
version: env!("CARGO_PKG_VERSION"),
|
|
9448
9747
|
git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
9449
9748
|
runtime: "self-hosted",
|
|
9450
|
-
storage:
|
|
9749
|
+
storage: database.storage.label(),
|
|
9451
9750
|
fabric: "healthy",
|
|
9452
9751
|
execution: if state.worker_enabled {
|
|
9453
9752
|
"autonomous"
|
|
@@ -9488,22 +9787,33 @@ async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagn
|
|
|
9488
9787
|
}
|
|
9489
9788
|
|
|
9490
9789
|
async fn readiness(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
9491
|
-
|
|
9492
|
-
|
|
9790
|
+
let readiness_probe = state.readiness_probe.clone();
|
|
9791
|
+
let db = state.db.clone();
|
|
9792
|
+
let (state_version, storage) = tokio::task::spawn_blocking(move || {
|
|
9793
|
+
if let Some(parent) = readiness_probe.parent() {
|
|
9794
|
+
std::fs::create_dir_all(parent)
|
|
9795
|
+
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9796
|
+
}
|
|
9797
|
+
let mut probe = OpenOptions::new()
|
|
9798
|
+
.create(true)
|
|
9799
|
+
.write(true)
|
|
9800
|
+
.truncate(true)
|
|
9801
|
+
.open(readiness_probe.as_ref())
|
|
9493
9802
|
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
.
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
|
|
9505
|
-
|
|
9506
|
-
|
|
9803
|
+
probe
|
|
9804
|
+
.write_all(b"feltdb-ready-v1\n")
|
|
9805
|
+
.and_then(|_| probe.sync_all())
|
|
9806
|
+
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9807
|
+
Ok::<_, ApiError>((db.sequence()?, db.instance_id()?))
|
|
9808
|
+
})
|
|
9809
|
+
.await
|
|
9810
|
+
.map_err(|error| {
|
|
9811
|
+
tracing::error!(%error, "readiness worker failed");
|
|
9812
|
+
ApiError(
|
|
9813
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9814
|
+
"readiness check temporarily unavailable".into(),
|
|
9815
|
+
)
|
|
9816
|
+
})??;
|
|
9507
9817
|
Ok(Json(json!({
|
|
9508
9818
|
"status": "ready",
|
|
9509
9819
|
"authority": "rust",
|
|
@@ -10072,9 +10382,25 @@ fn start_peer_sessions(
|
|
|
10072
10382
|
}
|
|
10073
10383
|
}
|
|
10074
10384
|
}
|
|
10075
|
-
|
|
10076
|
-
|
|
10077
|
-
|
|
10385
|
+
// Policy-driven, and off the runtime. Compaction rewrites the log
|
|
10386
|
+
// while holding the lock that serializes every read and write, so
|
|
10387
|
+
// running it on every tick stopped the world on every tick — and
|
|
10388
|
+
// running it inline blocked a runtime worker while it did.
|
|
10389
|
+
let compactor = state.clone();
|
|
10390
|
+
let compaction_peers = peers.clone();
|
|
10391
|
+
match tokio::task::spawn_blocking(move || {
|
|
10392
|
+
compactor.db.maybe_compact_operation_log(&compaction_peers)
|
|
10393
|
+
})
|
|
10394
|
+
.await
|
|
10395
|
+
{
|
|
10396
|
+
Ok(Ok(outcome)) => {
|
|
10397
|
+
state.metrics.operations_compacted(outcome.pruned() as u64);
|
|
10398
|
+
if outcome.rewrote_log() {
|
|
10399
|
+
tracing::debug!(pruned = outcome.pruned(), "durable log rewritten");
|
|
10400
|
+
}
|
|
10401
|
+
}
|
|
10402
|
+
Ok(Err(error)) => tracing::warn!(%error, "operation compaction failed"),
|
|
10403
|
+
Err(error) => tracing::warn!(%error, "operation compaction task failed"),
|
|
10078
10404
|
}
|
|
10079
10405
|
tokio::time::sleep(interval).await;
|
|
10080
10406
|
}
|
|
@@ -10395,10 +10721,10 @@ fn execute_builtin_capability(
|
|
|
10395
10721
|
Ok(Value::Array(
|
|
10396
10722
|
state
|
|
10397
10723
|
.db
|
|
10398
|
-
.
|
|
10724
|
+
.query_collection(collection, Some(limit), |row| {
|
|
10725
|
+
row.value.to_string().to_lowercase().contains(&needle)
|
|
10726
|
+
})?
|
|
10399
10727
|
.into_iter()
|
|
10400
|
-
.filter(|row| row.value.to_string().to_lowercase().contains(&needle))
|
|
10401
|
-
.take(limit)
|
|
10402
10728
|
.map(|row| row.value)
|
|
10403
10729
|
.collect(),
|
|
10404
10730
|
))
|
|
@@ -11439,11 +11765,11 @@ async fn get_provenance(
|
|
|
11439
11765
|
Path((collection, id)): Path<(String, String)>,
|
|
11440
11766
|
) -> Result<Json<Value>, ApiError> {
|
|
11441
11767
|
let key = record_key(&collection, &id)?;
|
|
11768
|
+
// A keyed lookup. This used to materialize the whole collection — cloning
|
|
11769
|
+
// every row under the global state lock — to find one record.
|
|
11442
11770
|
let row = state
|
|
11443
11771
|
.db
|
|
11444
|
-
.
|
|
11445
|
-
.into_iter()
|
|
11446
|
-
.find(|row| row.key == key)
|
|
11772
|
+
.get_collection_record(&collection, &key)?
|
|
11447
11773
|
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "record not found".to_string()))?;
|
|
11448
11774
|
let operation = row.operation.as_ref();
|
|
11449
11775
|
Ok(Json(json!({
|
|
@@ -11473,12 +11799,14 @@ async fn search_collection(
|
|
|
11473
11799
|
));
|
|
11474
11800
|
}
|
|
11475
11801
|
let needle = request.query.to_lowercase();
|
|
11802
|
+
// Borrows each row and stops at the limit, rather than cloning the whole
|
|
11803
|
+
// collection and filtering afterwards. Only matches are cloned.
|
|
11476
11804
|
let records = state
|
|
11477
11805
|
.db
|
|
11478
|
-
.
|
|
11806
|
+
.query_collection(&collection, Some(request.limit), |row| {
|
|
11807
|
+
row.value.to_string().to_lowercase().contains(&needle)
|
|
11808
|
+
})?
|
|
11479
11809
|
.into_iter()
|
|
11480
|
-
.filter(|row| row.value.to_string().to_lowercase().contains(&needle))
|
|
11481
|
-
.take(request.limit)
|
|
11482
11810
|
.map(record_response)
|
|
11483
11811
|
.collect();
|
|
11484
11812
|
Ok(Json(records))
|
|
@@ -21,6 +21,8 @@ struct Inner {
|
|
|
21
21
|
peer_sync_successes: AtomicU64,
|
|
22
22
|
peer_sync_failures: AtomicU64,
|
|
23
23
|
operations_compacted: AtomicU64,
|
|
24
|
+
requests_shed: AtomicU64,
|
|
25
|
+
request_deadlines_exceeded: AtomicU64,
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
#[derive(Serialize)]
|
|
@@ -35,6 +37,12 @@ pub struct MetricsSnapshot {
|
|
|
35
37
|
pub peer_sync_successes: u64,
|
|
36
38
|
pub peer_sync_failures: u64,
|
|
37
39
|
pub operations_compacted: u64,
|
|
40
|
+
/// Requests refused at the door because the server was already at its
|
|
41
|
+
/// in-flight limit. Non-zero means load is being shed rather than queued.
|
|
42
|
+
pub requests_shed: u64,
|
|
43
|
+
/// Requests the server abandoned for exceeding its own deadline. Non-zero
|
|
44
|
+
/// means scoped operations are stalling, whatever `/health` says.
|
|
45
|
+
pub request_deadlines_exceeded: u64,
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
impl Metrics {
|
|
@@ -65,6 +73,14 @@ impl Metrics {
|
|
|
65
73
|
.sync_operations_received
|
|
66
74
|
.fetch_add(count, Ordering::Relaxed);
|
|
67
75
|
}
|
|
76
|
+
pub fn request_shed(&self) {
|
|
77
|
+
self.inner.requests_shed.fetch_add(1, Ordering::Relaxed);
|
|
78
|
+
}
|
|
79
|
+
pub fn request_deadline_exceeded(&self) {
|
|
80
|
+
self.inner
|
|
81
|
+
.request_deadlines_exceeded
|
|
82
|
+
.fetch_add(1, Ordering::Relaxed);
|
|
83
|
+
}
|
|
68
84
|
pub fn peer_sync_success(&self) {
|
|
69
85
|
self.inner
|
|
70
86
|
.peer_sync_successes
|
|
@@ -92,6 +108,11 @@ impl Metrics {
|
|
|
92
108
|
peer_sync_successes: self.inner.peer_sync_successes.load(Ordering::Relaxed),
|
|
93
109
|
peer_sync_failures: self.inner.peer_sync_failures.load(Ordering::Relaxed),
|
|
94
110
|
operations_compacted: self.inner.operations_compacted.load(Ordering::Relaxed),
|
|
111
|
+
requests_shed: self.inner.requests_shed.load(Ordering::Relaxed),
|
|
112
|
+
request_deadlines_exceeded: self
|
|
113
|
+
.inner
|
|
114
|
+
.request_deadlines_exceeded
|
|
115
|
+
.load(Ordering::Relaxed),
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
}
|