create-feltdb 0.4.20 → 0.5.1
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/Cargo.lock +12 -0
- package/dist/server-source/crates/feltdb/src/application.rs +183 -8
- package/dist/server-source/crates/feltdb-server/Cargo.toml +1 -0
- package/dist/server-source/crates/feltdb-server/src/lib.rs +2 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +206 -45
- package/dist/server-source/crates/feltdb-server/src/request_telemetry.rs +381 -0
- package/dist/server-source/crates/feltdb-server/src/transaction_idempotency.rs +280 -0
- package/package.json +1 -1
package/dist/package-versions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.1';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -524,6 +524,7 @@ dependencies = [
|
|
|
524
524
|
"tower-http",
|
|
525
525
|
"tracing",
|
|
526
526
|
"tracing-subscriber",
|
|
527
|
+
"uuid",
|
|
527
528
|
]
|
|
528
529
|
|
|
529
530
|
[[package]]
|
|
@@ -1904,6 +1905,17 @@ version = "1.0.4"
|
|
|
1904
1905
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1905
1906
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|
1906
1907
|
|
|
1908
|
+
[[package]]
|
|
1909
|
+
name = "uuid"
|
|
1910
|
+
version = "1.25.0"
|
|
1911
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1912
|
+
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
|
|
1913
|
+
dependencies = [
|
|
1914
|
+
"getrandom 0.4.3",
|
|
1915
|
+
"js-sys",
|
|
1916
|
+
"wasm-bindgen",
|
|
1917
|
+
]
|
|
1918
|
+
|
|
1907
1919
|
[[package]]
|
|
1908
1920
|
name = "valuable"
|
|
1909
1921
|
version = "0.1.1"
|
|
@@ -959,6 +959,18 @@ pub struct RevisionAuditEvent {
|
|
|
959
959
|
pub diff_hash: Option<String>,
|
|
960
960
|
}
|
|
961
961
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
962
|
+
pub struct RevisionRecovery {
|
|
963
|
+
pub recovery_id: String,
|
|
964
|
+
pub application_id: String,
|
|
965
|
+
pub environment: String,
|
|
966
|
+
pub source_revision: String,
|
|
967
|
+
pub target_revision: String,
|
|
968
|
+
pub approved_by: String,
|
|
969
|
+
pub reason: String,
|
|
970
|
+
pub authorization_level: String,
|
|
971
|
+
pub recovered_at: u64,
|
|
972
|
+
}
|
|
973
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
962
974
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
963
975
|
pub enum ChangeSafety {
|
|
964
976
|
Safe,
|
|
@@ -1099,6 +1111,10 @@ struct RevisionRecords {
|
|
|
1099
1111
|
previews: Vec<ApplicationPreview>,
|
|
1100
1112
|
environment_pointers: BTreeMap<String, BTreeMap<String, String>>,
|
|
1101
1113
|
audit: Vec<RevisionAuditEvent>,
|
|
1114
|
+
#[serde(default)]
|
|
1115
|
+
untrusted_revisions: BTreeMap<String, BTreeMap<String, String>>,
|
|
1116
|
+
#[serde(default)]
|
|
1117
|
+
recoveries: Vec<RevisionRecovery>,
|
|
1102
1118
|
}
|
|
1103
1119
|
#[derive(Clone)]
|
|
1104
1120
|
pub struct ApplicationStore {
|
|
@@ -1462,6 +1478,15 @@ impl ApplicationStore {
|
|
|
1462
1478
|
})
|
|
1463
1479
|
.ok_or("revision not found")?
|
|
1464
1480
|
.clone();
|
|
1481
|
+
if r.untrusted_revisions
|
|
1482
|
+
.get(app)
|
|
1483
|
+
.is_some_and(|values| values.contains_key(&revision.revision_id))
|
|
1484
|
+
{
|
|
1485
|
+
return Err("revision is permanently untrusted".into());
|
|
1486
|
+
}
|
|
1487
|
+
if manifest_hash(&revision.manifest)? != revision.manifest_hash {
|
|
1488
|
+
return Err("revision integrity check failed".into());
|
|
1489
|
+
}
|
|
1465
1490
|
if !revision
|
|
1466
1491
|
.manifest
|
|
1467
1492
|
.environments
|
|
@@ -1536,6 +1561,108 @@ impl ApplicationStore {
|
|
|
1536
1561
|
Ok(promotion)
|
|
1537
1562
|
}
|
|
1538
1563
|
|
|
1564
|
+
#[allow(clippy::too_many_arguments)]
|
|
1565
|
+
pub fn recover_environment_pointer(
|
|
1566
|
+
&self,
|
|
1567
|
+
tenant: &str,
|
|
1568
|
+
app: &str,
|
|
1569
|
+
environment: &str,
|
|
1570
|
+
expected_current_revision: &str,
|
|
1571
|
+
target_revision: &str,
|
|
1572
|
+
actor: &str,
|
|
1573
|
+
reason: &str,
|
|
1574
|
+
authorization_level: &str,
|
|
1575
|
+
recovery_id: &str,
|
|
1576
|
+
) -> Result<RevisionRecovery, String> {
|
|
1577
|
+
let mut records = self
|
|
1578
|
+
.records
|
|
1579
|
+
.write()
|
|
1580
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1581
|
+
if let Some(existing) = records
|
|
1582
|
+
.recoveries
|
|
1583
|
+
.iter()
|
|
1584
|
+
.find(|value| value.recovery_id == recovery_id)
|
|
1585
|
+
{
|
|
1586
|
+
if existing.application_id == app
|
|
1587
|
+
&& existing.environment == environment
|
|
1588
|
+
&& existing.source_revision == expected_current_revision
|
|
1589
|
+
&& existing.target_revision == target_revision
|
|
1590
|
+
{
|
|
1591
|
+
return Ok(existing.clone());
|
|
1592
|
+
}
|
|
1593
|
+
return Err("recovery_id_conflict".into());
|
|
1594
|
+
}
|
|
1595
|
+
let current = records
|
|
1596
|
+
.environment_pointers
|
|
1597
|
+
.get(app)
|
|
1598
|
+
.and_then(|values| values.get(environment))
|
|
1599
|
+
.cloned()
|
|
1600
|
+
.unwrap_or_default();
|
|
1601
|
+
if current != expected_current_revision {
|
|
1602
|
+
return Err(format!("expected_revision_mismatch:{current}"));
|
|
1603
|
+
}
|
|
1604
|
+
let target = records
|
|
1605
|
+
.revisions
|
|
1606
|
+
.iter()
|
|
1607
|
+
.find(|value| {
|
|
1608
|
+
value.tenant_id == tenant
|
|
1609
|
+
&& value.application_id == app
|
|
1610
|
+
&& (value.revision_id == target_revision
|
|
1611
|
+
|| value.revision_number.to_string() == target_revision)
|
|
1612
|
+
})
|
|
1613
|
+
.ok_or("target revision not found")?
|
|
1614
|
+
.clone();
|
|
1615
|
+
if manifest_hash(&target.manifest)? != target.manifest_hash {
|
|
1616
|
+
return Err("target revision integrity check failed".into());
|
|
1617
|
+
}
|
|
1618
|
+
if records
|
|
1619
|
+
.untrusted_revisions
|
|
1620
|
+
.get(app)
|
|
1621
|
+
.is_some_and(|values| values.contains_key(&target.revision_id))
|
|
1622
|
+
{
|
|
1623
|
+
return Err("target revision is permanently untrusted".into());
|
|
1624
|
+
}
|
|
1625
|
+
let recovery = RevisionRecovery {
|
|
1626
|
+
recovery_id: recovery_id.into(),
|
|
1627
|
+
application_id: app.into(),
|
|
1628
|
+
environment: environment.into(),
|
|
1629
|
+
source_revision: expected_current_revision.into(),
|
|
1630
|
+
target_revision: target.revision_id.clone(),
|
|
1631
|
+
approved_by: actor.into(),
|
|
1632
|
+
reason: reason.into(),
|
|
1633
|
+
authorization_level: authorization_level.into(),
|
|
1634
|
+
recovered_at: now(),
|
|
1635
|
+
};
|
|
1636
|
+
records
|
|
1637
|
+
.untrusted_revisions
|
|
1638
|
+
.entry(app.into())
|
|
1639
|
+
.or_default()
|
|
1640
|
+
.insert(expected_current_revision.into(), recovery_id.into());
|
|
1641
|
+
records
|
|
1642
|
+
.environment_pointers
|
|
1643
|
+
.entry(app.into())
|
|
1644
|
+
.or_default()
|
|
1645
|
+
.insert(environment.into(), target.revision_id.clone());
|
|
1646
|
+
records.recoveries.push(recovery.clone());
|
|
1647
|
+
Self::event(
|
|
1648
|
+
&mut records,
|
|
1649
|
+
"application.revision.recovered",
|
|
1650
|
+
tenant,
|
|
1651
|
+
app,
|
|
1652
|
+
actor,
|
|
1653
|
+
Some(&target),
|
|
1654
|
+
Some(expected_current_revision.into()),
|
|
1655
|
+
);
|
|
1656
|
+
if let Some(event) = records.audit.last_mut() {
|
|
1657
|
+
event.environment = Some(environment.into());
|
|
1658
|
+
event.from_revision = Some(expected_current_revision.into());
|
|
1659
|
+
event.to_revision = Some(target.revision_id);
|
|
1660
|
+
event.correlation_id = recovery_id.into();
|
|
1661
|
+
}
|
|
1662
|
+
self.persist(&records)?;
|
|
1663
|
+
Ok(recovery)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1539
1666
|
pub fn history(&self, tenant: &str, app: &str, environment: &str) -> Vec<RevisionPromotion> {
|
|
1540
1667
|
self.records
|
|
1541
1668
|
.read()
|
|
@@ -2148,6 +2275,41 @@ mod tests {
|
|
|
2148
2275
|
assert_eq!(s.pointers("a")["production"], r2.revision_id);
|
|
2149
2276
|
assert_eq!(s.history("t", "a", "production").len(), 2);
|
|
2150
2277
|
}
|
|
2278
|
+
|
|
2279
|
+
#[test]
|
|
2280
|
+
fn recovery_is_atomic_idempotent_durable_and_permanently_untrusts_source() {
|
|
2281
|
+
let s = store("revision-recovery");
|
|
2282
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2283
|
+
let corrupt = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2284
|
+
s.move_environment_pointer("t", "a", &corrupt.revision_id, "staging", None,
|
|
2285
|
+
"owner", "initial", true, false).unwrap();
|
|
2286
|
+
let d = s.create_draft("t", "a", "App", "owner", Some(&corrupt.revision_id)).unwrap();
|
|
2287
|
+
let clean = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2288
|
+
|
|
2289
|
+
// Historical source corruption must not prevent recovery away from it.
|
|
2290
|
+
{
|
|
2291
|
+
let mut records = s.records.write().unwrap();
|
|
2292
|
+
records.revisions.iter_mut().find(|value| value.revision_id == corrupt.revision_id)
|
|
2293
|
+
.unwrap().manifest.metadata.name = "corrupted without updating its integrity hash".into();
|
|
2294
|
+
s.persist(&records).unwrap();
|
|
2295
|
+
}
|
|
2296
|
+
let recovered = s.recover_environment_pointer("t", "a", "staging", &corrupt.revision_id,
|
|
2297
|
+
&clean.revision_id, "sherpa", "Replace corrupt historical revision", "ELEVATED",
|
|
2298
|
+
"recovery-1").unwrap();
|
|
2299
|
+
assert_eq!(recovered.target_revision, clean.revision_id);
|
|
2300
|
+
assert_eq!(s.recover_environment_pointer("t", "a", "staging", &corrupt.revision_id,
|
|
2301
|
+
&clean.revision_id, "sherpa", "Replace corrupt historical revision", "ELEVATED",
|
|
2302
|
+
"recovery-1").unwrap(), recovered);
|
|
2303
|
+
assert!(s.move_environment_pointer("t", "a", &corrupt.revision_id, "staging",
|
|
2304
|
+
Some(&clean.revision_id), "owner", "rollback", true, true).unwrap_err()
|
|
2305
|
+
.contains("untrusted"));
|
|
2306
|
+
|
|
2307
|
+
let reloaded = ApplicationStore::load(s.path.clone()).unwrap();
|
|
2308
|
+
assert_eq!(reloaded.pointers("a")["staging"], clean.revision_id);
|
|
2309
|
+
let records = reloaded.records.read().unwrap();
|
|
2310
|
+
assert_eq!(records.recoveries.len(), 1);
|
|
2311
|
+
assert!(records.untrusted_revisions["a"].contains_key(&corrupt.revision_id));
|
|
2312
|
+
}
|
|
2151
2313
|
#[test]
|
|
2152
2314
|
fn preview_remains_bound_when_production_moves() {
|
|
2153
2315
|
let s = store("preview-bound");
|
|
@@ -2280,7 +2442,11 @@ mod tests {
|
|
|
2280
2442
|
});
|
|
2281
2443
|
|
|
2282
2444
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2283
|
-
assert!(
|
|
2445
|
+
assert!(
|
|
2446
|
+
report.valid,
|
|
2447
|
+
"Policy with authenticated subject should be valid: {:?}",
|
|
2448
|
+
report.issues
|
|
2449
|
+
);
|
|
2284
2450
|
}
|
|
2285
2451
|
|
|
2286
2452
|
#[test]
|
|
@@ -2295,7 +2461,11 @@ mod tests {
|
|
|
2295
2461
|
});
|
|
2296
2462
|
|
|
2297
2463
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2298
|
-
assert!(
|
|
2464
|
+
assert!(
|
|
2465
|
+
report.valid,
|
|
2466
|
+
"Policy with owner subject should be valid: {:?}",
|
|
2467
|
+
report.issues
|
|
2468
|
+
);
|
|
2299
2469
|
}
|
|
2300
2470
|
|
|
2301
2471
|
#[test]
|
|
@@ -2310,12 +2480,14 @@ mod tests {
|
|
|
2310
2480
|
});
|
|
2311
2481
|
|
|
2312
2482
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2313
|
-
assert!(!report.valid, "Policy with unknown subject should be invalid");
|
|
2314
2483
|
assert!(
|
|
2315
|
-
report.
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2484
|
+
!report.valid,
|
|
2485
|
+
"Policy with unknown subject should be invalid"
|
|
2486
|
+
);
|
|
2487
|
+
assert!(
|
|
2488
|
+
report.issues.iter().any(|i| i.path.contains("BadPolicy")
|
|
2489
|
+
&& i.path.contains("read")
|
|
2490
|
+
&& i.message.contains("invalid policy subject")),
|
|
2319
2491
|
"Should have error about invalid policy subject"
|
|
2320
2492
|
);
|
|
2321
2493
|
}
|
|
@@ -2332,6 +2504,9 @@ mod tests {
|
|
|
2332
2504
|
});
|
|
2333
2505
|
|
|
2334
2506
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2335
|
-
assert!(
|
|
2507
|
+
assert!(
|
|
2508
|
+
report.valid,
|
|
2509
|
+
"Policy without subjects should be valid (backward compatible)"
|
|
2510
|
+
);
|
|
2336
2511
|
}
|
|
2337
2512
|
}
|
|
@@ -21,3 +21,4 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
|
|
|
21
21
|
tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }
|
|
22
22
|
tracing = "0.1"
|
|
23
23
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
24
|
+
uuid = { version = "1.6", features = ["v4"] }
|
|
@@ -40,8 +40,8 @@ use feltdb::{
|
|
|
40
40
|
authorize as authorize_resource, AuthorizationRequest as ResourceAuthorizationRequest,
|
|
41
41
|
Grant, GrantSigner, GrantStore, Subject as GrantSubject,
|
|
42
42
|
},
|
|
43
|
-
policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
|
|
44
43
|
cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
|
|
44
|
+
policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
|
|
45
45
|
state_contract::{
|
|
46
46
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
47
47
|
schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
|
|
@@ -76,7 +76,7 @@ use feltdb_server::{
|
|
|
76
76
|
connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
|
|
77
77
|
content::ContentStore,
|
|
78
78
|
identity::IdentityStore,
|
|
79
|
-
key_management::{
|
|
79
|
+
key_management::{create_key, list_keys, revoke_key},
|
|
80
80
|
key_provider::provider_from_environment,
|
|
81
81
|
leases::LeaseStore,
|
|
82
82
|
metrics::Metrics,
|
|
@@ -103,7 +103,9 @@ struct ApiError(StatusCode, String);
|
|
|
103
103
|
|
|
104
104
|
impl IntoResponse for ApiError {
|
|
105
105
|
fn into_response(self) -> Response {
|
|
106
|
-
|
|
106
|
+
let body =
|
|
107
|
+
serde_json::from_str::<Value>(&self.1).unwrap_or_else(|_| json!({ "error": self.1 }));
|
|
108
|
+
(self.0, Json(body)).into_response()
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -2027,9 +2029,10 @@ fn is_platform_owner(principal: &Principal) -> bool {
|
|
|
2027
2029
|
.any(|owner| !owner.is_empty() && owner == identity)
|
|
2028
2030
|
}
|
|
2029
2031
|
fn require_platform_owner(principal: &Principal) -> Result<(), ApiError> {
|
|
2030
|
-
is_platform_owner(principal)
|
|
2031
|
-
|
|
2032
|
-
|
|
2032
|
+
is_platform_owner(principal).then_some(()).ok_or(ApiError(
|
|
2033
|
+
StatusCode::FORBIDDEN,
|
|
2034
|
+
"platform owner access required".into(),
|
|
2035
|
+
))
|
|
2033
2036
|
}
|
|
2034
2037
|
async fn platform_access(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
2035
2038
|
let platform_role = is_platform_owner(&principal).then_some("super_owner");
|
|
@@ -2365,7 +2368,11 @@ async fn evaluate_application_contract(
|
|
|
2365
2368
|
) -> Result<Json<Value>, ApiError> {
|
|
2366
2369
|
Ok(Json(json!(state
|
|
2367
2370
|
.contracts
|
|
2368
|
-
.evaluate(
|
|
2371
|
+
.evaluate(
|
|
2372
|
+
&user_contract_key(&principal, &id),
|
|
2373
|
+
&input.outcome,
|
|
2374
|
+
&principal.key_id
|
|
2375
|
+
)
|
|
2369
2376
|
.map_err(control_error)?)))
|
|
2370
2377
|
}
|
|
2371
2378
|
async fn patch_application_contract(
|
|
@@ -2384,7 +2391,10 @@ async fn application_contract_gaps(
|
|
|
2384
2391
|
Extension(principal): Extension<Principal>,
|
|
2385
2392
|
Path(id): Path<String>,
|
|
2386
2393
|
) -> Result<Json<Value>, ApiError> {
|
|
2387
|
-
let c = state
|
|
2394
|
+
let c = state
|
|
2395
|
+
.contracts
|
|
2396
|
+
.get(&user_contract_key(&principal, &id))
|
|
2397
|
+
.map_err(control_error)?;
|
|
2388
2398
|
Ok(Json(json!({"gaps":c.gaps})))
|
|
2389
2399
|
}
|
|
2390
2400
|
async fn application_contract_readiness(
|
|
@@ -4495,12 +4505,7 @@ async fn run_runtime_action(
|
|
|
4495
4505
|
schema_version: contract.state.schema.schema_version,
|
|
4496
4506
|
state_namespace: Some(contract.environment.state_namespace.clone()),
|
|
4497
4507
|
causal_parent: None,
|
|
4498
|
-
authorization: state_authorization(
|
|
4499
|
-
&principal,
|
|
4500
|
-
&contract,
|
|
4501
|
-
&definition.collection,
|
|
4502
|
-
"write",
|
|
4503
|
-
),
|
|
4508
|
+
authorization: state_authorization(&principal, &contract, &definition.collection, "write"),
|
|
4504
4509
|
operations: vec![TransactionOperation {
|
|
4505
4510
|
kind,
|
|
4506
4511
|
collection: definition.collection.clone(),
|
|
@@ -4603,12 +4608,7 @@ async fn run_runtime_query(
|
|
|
4603
4608
|
&state.db,
|
|
4604
4609
|
&contract.state.schema,
|
|
4605
4610
|
&contract.environment.state_namespace,
|
|
4606
|
-
state_authorization(
|
|
4607
|
-
&principal,
|
|
4608
|
-
&contract,
|
|
4609
|
-
&definition.collection,
|
|
4610
|
-
"read",
|
|
4611
|
-
),
|
|
4611
|
+
state_authorization(&principal, &contract, &definition.collection, "read"),
|
|
4612
4612
|
)
|
|
4613
4613
|
.map_err(state_contract_error)?;
|
|
4614
4614
|
|
|
@@ -4712,7 +4712,7 @@ fn state_authorization(
|
|
|
4712
4712
|
principal: &Principal,
|
|
4713
4713
|
contract: &ApplicationRuntimeContract,
|
|
4714
4714
|
collection: &str,
|
|
4715
|
-
operation: &str,
|
|
4715
|
+
operation: &str, // "read" or "write"
|
|
4716
4716
|
) -> AuthorizationContext {
|
|
4717
4717
|
let subject = format!("{}:{}", principal.subject_type, principal.key_id);
|
|
4718
4718
|
|
|
@@ -4888,12 +4888,7 @@ async fn execute_canonical_query(
|
|
|
4888
4888
|
&state.db,
|
|
4889
4889
|
&contract.state.schema,
|
|
4890
4890
|
&contract.environment.state_namespace,
|
|
4891
|
-
state_authorization(
|
|
4892
|
-
&principal,
|
|
4893
|
-
&contract,
|
|
4894
|
-
&input.query.collection,
|
|
4895
|
-
"read",
|
|
4896
|
-
),
|
|
4891
|
+
state_authorization(&principal, &contract, &input.query.collection, "read"),
|
|
4897
4892
|
)
|
|
4898
4893
|
.map_err(state_contract_error)?;
|
|
4899
4894
|
|
|
@@ -4905,8 +4900,14 @@ async fn execute_canonical_query(
|
|
|
4905
4900
|
.find(|p| p.resource == input.query.collection)
|
|
4906
4901
|
.and_then(|p| p.read.as_ref().and_then(|s| PolicySubject::from_str(s)));
|
|
4907
4902
|
|
|
4908
|
-
let result = execute_state_query(
|
|
4909
|
-
.
|
|
4903
|
+
let result = execute_state_query(
|
|
4904
|
+
&state.db,
|
|
4905
|
+
&contract.state.schema,
|
|
4906
|
+
&context,
|
|
4907
|
+
&input.query,
|
|
4908
|
+
read_policy,
|
|
4909
|
+
)
|
|
4910
|
+
.map_err(state_contract_error)?;
|
|
4910
4911
|
audit(
|
|
4911
4912
|
&state,
|
|
4912
4913
|
&principal.key_id,
|
|
@@ -4917,6 +4918,156 @@ async fn execute_canonical_query(
|
|
|
4917
4918
|
);
|
|
4918
4919
|
Ok(Json(json!(result)))
|
|
4919
4920
|
}
|
|
4921
|
+
|
|
4922
|
+
#[derive(serde::Deserialize)]
|
|
4923
|
+
struct RevisionRecoveryRequest {
|
|
4924
|
+
#[serde(rename = "applicationId")]
|
|
4925
|
+
application_id: String,
|
|
4926
|
+
#[serde(rename = "targetRevision")]
|
|
4927
|
+
target_revision: String,
|
|
4928
|
+
#[serde(rename = "expectedCurrentRevision")]
|
|
4929
|
+
expected_current_revision: String,
|
|
4930
|
+
authorization: String,
|
|
4931
|
+
actor: String,
|
|
4932
|
+
reason: String,
|
|
4933
|
+
#[serde(default)]
|
|
4934
|
+
environment: Option<String>,
|
|
4935
|
+
#[serde(default)]
|
|
4936
|
+
#[serde(rename = "recoveryId")]
|
|
4937
|
+
recovery_id: Option<String>,
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
#[derive(serde::Serialize, serde::Deserialize)]
|
|
4941
|
+
struct RevisionRecoveryResponse {
|
|
4942
|
+
success: bool,
|
|
4943
|
+
#[serde(rename = "pointerMoved")]
|
|
4944
|
+
pointer_moved: bool,
|
|
4945
|
+
#[serde(rename = "sourceMarkedUntrusted")]
|
|
4946
|
+
source_marked_untrusted: bool,
|
|
4947
|
+
#[serde(rename = "auditDurable")]
|
|
4948
|
+
audit_durable: bool,
|
|
4949
|
+
#[serde(rename = "recoveryId")]
|
|
4950
|
+
recovery_id: String,
|
|
4951
|
+
#[serde(rename = "targetRevision")]
|
|
4952
|
+
target_revision: String,
|
|
4953
|
+
#[serde(rename = "currentRevision")]
|
|
4954
|
+
current_revision: String,
|
|
4955
|
+
audit: serde_json::Value,
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
fn revision_recovery_error(
|
|
4959
|
+
status: StatusCode,
|
|
4960
|
+
code: &str,
|
|
4961
|
+
message: String,
|
|
4962
|
+
request_id: &str,
|
|
4963
|
+
transaction_id: &str,
|
|
4964
|
+
) -> ApiError {
|
|
4965
|
+
ApiError(
|
|
4966
|
+
status,
|
|
4967
|
+
json!({"code":code,"message":message,"request_id":request_id,
|
|
4968
|
+
"transaction_id":transaction_id,"http_status":status.as_u16()})
|
|
4969
|
+
.to_string(),
|
|
4970
|
+
)
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
async fn execute_revision_recovery(
|
|
4974
|
+
State(state): State<AppState>,
|
|
4975
|
+
Extension(principal): Extension<Principal>,
|
|
4976
|
+
Json(input): Json<RevisionRecoveryRequest>,
|
|
4977
|
+
) -> Result<Json<RevisionRecoveryResponse>, ApiError> {
|
|
4978
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
4979
|
+
let transaction_id = input
|
|
4980
|
+
.recovery_id
|
|
4981
|
+
.clone()
|
|
4982
|
+
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
4983
|
+
|
|
4984
|
+
// Validate authorization level
|
|
4985
|
+
let auth_level = input.authorization.as_str();
|
|
4986
|
+
|
|
4987
|
+
if !["ELEVATED", "ADMIN", "EMERGENCY"].contains(&auth_level) {
|
|
4988
|
+
return Err(revision_recovery_error(
|
|
4989
|
+
StatusCode::FORBIDDEN,
|
|
4990
|
+
"PERMISSION_DENIED",
|
|
4991
|
+
"Recovery requires ELEVATED, ADMIN, or EMERGENCY authorization".into(),
|
|
4992
|
+
&request_id,
|
|
4993
|
+
&transaction_id,
|
|
4994
|
+
));
|
|
4995
|
+
}
|
|
4996
|
+
|
|
4997
|
+
// Validate input
|
|
4998
|
+
if input.reason.len() < 10 {
|
|
4999
|
+
return Err(revision_recovery_error(
|
|
5000
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5001
|
+
"PRECONDITION_FAILED",
|
|
5002
|
+
"Recovery reason must be at least 10 characters".into(),
|
|
5003
|
+
&request_id,
|
|
5004
|
+
&transaction_id,
|
|
5005
|
+
));
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
let recovery_id = transaction_id.clone();
|
|
5009
|
+
let environment = input
|
|
5010
|
+
.environment
|
|
5011
|
+
.unwrap_or_else(|| "production".to_string());
|
|
5012
|
+
let tenant = application_scope(
|
|
5013
|
+
&state,
|
|
5014
|
+
&principal.key_id,
|
|
5015
|
+
&input.application_id,
|
|
5016
|
+
"application:revision:promote",
|
|
5017
|
+
)?;
|
|
5018
|
+
let recovery = state
|
|
5019
|
+
.applications
|
|
5020
|
+
.recover_environment_pointer(
|
|
5021
|
+
&tenant,
|
|
5022
|
+
&input.application_id,
|
|
5023
|
+
&environment,
|
|
5024
|
+
&input.expected_current_revision,
|
|
5025
|
+
&input.target_revision,
|
|
5026
|
+
&input.actor,
|
|
5027
|
+
&input.reason,
|
|
5028
|
+
auth_level,
|
|
5029
|
+
&recovery_id,
|
|
5030
|
+
)
|
|
5031
|
+
.map_err(|message| {
|
|
5032
|
+
let (status, code) = if message.starts_with("expected_revision_mismatch")
|
|
5033
|
+
|| message == "recovery_id_conflict"
|
|
5034
|
+
{
|
|
5035
|
+
(StatusCode::CONFLICT, "CONFLICT")
|
|
5036
|
+
} else if message.contains("not found") {
|
|
5037
|
+
(StatusCode::NOT_FOUND, "PRECONDITION_FAILED")
|
|
5038
|
+
} else if message.contains("integrity") || message.contains("untrusted") {
|
|
5039
|
+
(StatusCode::UNPROCESSABLE_ENTITY, "VALIDATION_FAILED")
|
|
5040
|
+
} else {
|
|
5041
|
+
(StatusCode::SERVICE_UNAVAILABLE, "STORAGE_FAILURE")
|
|
5042
|
+
};
|
|
5043
|
+
revision_recovery_error(status, code, message, &request_id, &transaction_id)
|
|
5044
|
+
})?;
|
|
5045
|
+
|
|
5046
|
+
// Build response
|
|
5047
|
+
let response = RevisionRecoveryResponse {
|
|
5048
|
+
success: true,
|
|
5049
|
+
pointer_moved: true,
|
|
5050
|
+
source_marked_untrusted: true,
|
|
5051
|
+
audit_durable: true,
|
|
5052
|
+
recovery_id: recovery_id.clone(),
|
|
5053
|
+
target_revision: recovery.target_revision.clone(),
|
|
5054
|
+
current_revision: recovery.target_revision.clone(),
|
|
5055
|
+
audit: json!(recovery),
|
|
5056
|
+
};
|
|
5057
|
+
|
|
5058
|
+
// Audit log
|
|
5059
|
+
audit(
|
|
5060
|
+
&state,
|
|
5061
|
+
&principal.key_id,
|
|
5062
|
+
"revision.recover",
|
|
5063
|
+
&recovery_id,
|
|
5064
|
+
"allowed",
|
|
5065
|
+
200,
|
|
5066
|
+
);
|
|
5067
|
+
|
|
5068
|
+
Ok(Json(response))
|
|
5069
|
+
}
|
|
5070
|
+
|
|
4920
5071
|
async fn execute_canonical_transaction(
|
|
4921
5072
|
State(state): State<AppState>,
|
|
4922
5073
|
Extension(principal): Extension<Principal>,
|
|
@@ -4942,24 +5093,27 @@ async fn execute_canonical_transaction(
|
|
|
4942
5093
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
4943
5094
|
// For transactions with operations, use the first operation's collection for policy evaluation
|
|
4944
5095
|
// Multi-collection transactions will still be checked at the operation level in execute_transaction
|
|
4945
|
-
let collection = input
|
|
5096
|
+
let collection = input
|
|
5097
|
+
.transaction
|
|
5098
|
+
.operations
|
|
4946
5099
|
.first()
|
|
4947
5100
|
.map(|op| op.collection.clone())
|
|
4948
5101
|
.unwrap_or_default();
|
|
4949
|
-
input.transaction.authorization =
|
|
4950
|
-
&principal,
|
|
4951
|
-
&contract,
|
|
4952
|
-
&collection,
|
|
4953
|
-
"write",
|
|
4954
|
-
);
|
|
5102
|
+
input.transaction.authorization =
|
|
5103
|
+
state_authorization(&principal, &contract, &collection, "write");
|
|
4955
5104
|
let write_policy = contract
|
|
4956
5105
|
.policies
|
|
4957
5106
|
.definitions
|
|
4958
5107
|
.iter()
|
|
4959
5108
|
.find(|p| p.resource == collection)
|
|
4960
5109
|
.and_then(|p| p.write.as_ref().and_then(|s| PolicySubject::from_str(s)));
|
|
4961
|
-
let result = execute_transaction(
|
|
4962
|
-
.
|
|
5110
|
+
let result = execute_transaction(
|
|
5111
|
+
&state.db,
|
|
5112
|
+
&contract.state.schema,
|
|
5113
|
+
&input.transaction,
|
|
5114
|
+
write_policy,
|
|
5115
|
+
)
|
|
5116
|
+
.map_err(state_contract_error)?;
|
|
4963
5117
|
audit(
|
|
4964
5118
|
&state,
|
|
4965
5119
|
&principal.key_id,
|
|
@@ -4975,13 +5129,19 @@ async fn get_cardinality_diagnostic(
|
|
|
4975
5129
|
State(state): State<AppState>,
|
|
4976
5130
|
Path(pattern): Path<String>,
|
|
4977
5131
|
) -> Result<Json<CardinalityDiagnosticResponse>, ApiError> {
|
|
4978
|
-
let all_cardinalities = state
|
|
5132
|
+
let all_cardinalities = state
|
|
5133
|
+
.db
|
|
5134
|
+
.list_cardinalities()
|
|
4979
5135
|
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
4980
5136
|
|
|
4981
|
-
let actual_rows = state
|
|
5137
|
+
let actual_rows = state
|
|
5138
|
+
.db
|
|
5139
|
+
.diagnostic_row_count(&pattern)
|
|
4982
5140
|
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
4983
5141
|
|
|
4984
|
-
let persisted_capability_keys = state
|
|
5142
|
+
let persisted_capability_keys = state
|
|
5143
|
+
.db
|
|
5144
|
+
.diagnostic_capability_keys(&pattern)
|
|
4985
5145
|
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
4986
5146
|
|
|
4987
5147
|
let matching_keys: Vec<_> = all_cardinalities
|
|
@@ -4992,10 +5152,7 @@ async fn get_cardinality_diagnostic(
|
|
|
4992
5152
|
|
|
4993
5153
|
let cardinality_map_keys: Vec<String> = matching_keys.iter().map(|(k, _)| k.clone()).collect();
|
|
4994
5154
|
|
|
4995
|
-
let maintained = matching_keys
|
|
4996
|
-
.iter()
|
|
4997
|
-
.map(|(_, v)| v)
|
|
4998
|
-
.sum::<u64>();
|
|
5155
|
+
let maintained = matching_keys.iter().map(|(_, v)| v).sum::<u64>();
|
|
4999
5156
|
|
|
5000
5157
|
let now_ms = std::time::SystemTime::now()
|
|
5001
5158
|
.duration_since(std::time::UNIX_EPOCH)
|
|
@@ -5752,6 +5909,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
5752
5909
|
"/v1/transactions",
|
|
5753
5910
|
axum::routing::post(execute_canonical_transaction),
|
|
5754
5911
|
)
|
|
5912
|
+
.route(
|
|
5913
|
+
"/v1/revision/recover",
|
|
5914
|
+
axum::routing::post(execute_revision_recovery),
|
|
5915
|
+
)
|
|
5755
5916
|
.route(
|
|
5756
5917
|
"/debug/cardinality/{pattern}",
|
|
5757
5918
|
get(get_cardinality_diagnostic),
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
use std::sync::{Arc, Mutex};
|
|
2
|
+
use std::time::{Instant, SystemTime};
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use uuid::Uuid;
|
|
5
|
+
|
|
6
|
+
/// Request lifecycle telemetry: captures all timing and context for every /v1/ request.
|
|
7
|
+
/// This is the production correctness contract for managed FeltDB concurrency.
|
|
8
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
|
+
pub struct RequestTelemetry {
|
|
10
|
+
pub request_id: String,
|
|
11
|
+
pub transaction_id: Option<String>,
|
|
12
|
+
pub application_id: String,
|
|
13
|
+
pub revision_id: String,
|
|
14
|
+
|
|
15
|
+
// Timing breakdown (milliseconds)
|
|
16
|
+
pub queue_wait_ms: u64,
|
|
17
|
+
pub lock_wait_ms: u64,
|
|
18
|
+
pub execution_ms: u64,
|
|
19
|
+
pub persistence_ms: u64,
|
|
20
|
+
pub total_ms: u64,
|
|
21
|
+
|
|
22
|
+
// Contention signals
|
|
23
|
+
pub lock_name: String,
|
|
24
|
+
pub queue_depth: usize,
|
|
25
|
+
pub max_queue_depth: usize,
|
|
26
|
+
|
|
27
|
+
// Cancellation state
|
|
28
|
+
pub deadline_ms: Option<u64>,
|
|
29
|
+
pub cancelled: bool,
|
|
30
|
+
|
|
31
|
+
// HTTP response
|
|
32
|
+
pub http_status: u16,
|
|
33
|
+
pub feltdb_code: String,
|
|
34
|
+
pub error_message: Option<String>,
|
|
35
|
+
|
|
36
|
+
// Resource tracking
|
|
37
|
+
pub orphaned_tasks: usize,
|
|
38
|
+
pub timestamp_ms: u64,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl RequestTelemetry {
|
|
42
|
+
pub fn new(application_id: String, revision_id: String) -> Self {
|
|
43
|
+
let now = SystemTime::now()
|
|
44
|
+
.duration_since(SystemTime::UNIX_EPOCH)
|
|
45
|
+
.unwrap_or_default()
|
|
46
|
+
.as_millis() as u64;
|
|
47
|
+
|
|
48
|
+
Self {
|
|
49
|
+
request_id: Uuid::new_v4().to_string(),
|
|
50
|
+
transaction_id: None,
|
|
51
|
+
application_id,
|
|
52
|
+
revision_id,
|
|
53
|
+
queue_wait_ms: 0,
|
|
54
|
+
lock_wait_ms: 0,
|
|
55
|
+
execution_ms: 0,
|
|
56
|
+
persistence_ms: 0,
|
|
57
|
+
total_ms: 0,
|
|
58
|
+
lock_name: String::new(),
|
|
59
|
+
queue_depth: 0,
|
|
60
|
+
max_queue_depth: 0,
|
|
61
|
+
deadline_ms: None,
|
|
62
|
+
cancelled: false,
|
|
63
|
+
http_status: 200,
|
|
64
|
+
feltdb_code: "OK".to_string(),
|
|
65
|
+
error_message: None,
|
|
66
|
+
orphaned_tasks: 0,
|
|
67
|
+
timestamp_ms: now,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn with_transaction_id(mut self, tx_id: String) -> Self {
|
|
72
|
+
self.transaction_id = Some(tx_id);
|
|
73
|
+
self
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Request telemetry store: thread-safe collection of all request measurements.
|
|
78
|
+
/// Enables production diagnostics without external observability dependency.
|
|
79
|
+
pub struct RequestTelemetryStore {
|
|
80
|
+
records: Arc<Mutex<Vec<RequestTelemetry>>>,
|
|
81
|
+
max_capacity: usize,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl RequestTelemetryStore {
|
|
85
|
+
pub fn new(max_capacity: usize) -> Self {
|
|
86
|
+
Self {
|
|
87
|
+
records: Arc::new(Mutex::new(Vec::with_capacity(max_capacity))),
|
|
88
|
+
max_capacity,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn record(&self, telemetry: RequestTelemetry) {
|
|
93
|
+
if let Ok(mut records) = self.records.lock() {
|
|
94
|
+
records.push(telemetry);
|
|
95
|
+
// Keep only the most recent records
|
|
96
|
+
if records.len() > self.max_capacity {
|
|
97
|
+
let to_remove = records.len() - self.max_capacity;
|
|
98
|
+
records.drain(0..to_remove);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn all(&self) -> Vec<RequestTelemetry> {
|
|
104
|
+
self.records
|
|
105
|
+
.lock()
|
|
106
|
+
.map(|r| r.clone())
|
|
107
|
+
.unwrap_or_default()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pub fn recent(&self, count: usize) -> Vec<RequestTelemetry> {
|
|
111
|
+
self.records
|
|
112
|
+
.lock()
|
|
113
|
+
.map(|r| {
|
|
114
|
+
let start = if r.len() > count { r.len() - count } else { 0 };
|
|
115
|
+
r[start..].to_vec()
|
|
116
|
+
})
|
|
117
|
+
.unwrap_or_default()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pub fn by_transaction_id(&self, tx_id: &str) -> Vec<RequestTelemetry> {
|
|
121
|
+
self.records
|
|
122
|
+
.lock()
|
|
123
|
+
.map(|r| r.iter().filter(|t| t.transaction_id.as_deref() == Some(tx_id)).cloned().collect())
|
|
124
|
+
.unwrap_or_default()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pub fn statistics(&self) -> TelemetryStatistics {
|
|
128
|
+
if let Ok(records) = self.records.lock() {
|
|
129
|
+
let mut stats = TelemetryStatistics::default();
|
|
130
|
+
let mut total_latencies = Vec::new();
|
|
131
|
+
let mut queue_waits = Vec::new();
|
|
132
|
+
let mut lock_waits = Vec::new();
|
|
133
|
+
let mut execution_times = Vec::new();
|
|
134
|
+
let mut persistence_times = Vec::new();
|
|
135
|
+
|
|
136
|
+
for telemetry in records.iter() {
|
|
137
|
+
total_latencies.push(telemetry.total_ms);
|
|
138
|
+
queue_waits.push(telemetry.queue_wait_ms);
|
|
139
|
+
lock_waits.push(telemetry.lock_wait_ms);
|
|
140
|
+
execution_times.push(telemetry.execution_ms);
|
|
141
|
+
persistence_times.push(telemetry.persistence_ms);
|
|
142
|
+
stats.total_requests += 1;
|
|
143
|
+
stats.max_queue_depth = stats.max_queue_depth.max(telemetry.max_queue_depth);
|
|
144
|
+
stats.total_orphaned_tasks += telemetry.orphaned_tasks;
|
|
145
|
+
|
|
146
|
+
match telemetry.http_status {
|
|
147
|
+
200..=299 => stats.successful_requests += 1,
|
|
148
|
+
400..=499 => stats.client_errors += 1,
|
|
149
|
+
500..=599 => stats.server_errors += 1,
|
|
150
|
+
_ => {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if telemetry.cancelled {
|
|
154
|
+
stats.cancelled_requests += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if telemetry.feltdb_code == "CONFLICT" {
|
|
158
|
+
stats.conflicts += 1;
|
|
159
|
+
} else if telemetry.feltdb_code == "TOO_BUSY" {
|
|
160
|
+
stats.too_busy_count += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Calculate percentiles
|
|
165
|
+
if !total_latencies.is_empty() {
|
|
166
|
+
total_latencies.sort();
|
|
167
|
+
stats.latency_p50_ms = calculate_percentile(&total_latencies, 50);
|
|
168
|
+
stats.latency_p95_ms = calculate_percentile(&total_latencies, 95);
|
|
169
|
+
stats.latency_p99_ms = calculate_percentile(&total_latencies, 99);
|
|
170
|
+
stats.latency_max_ms = *total_latencies.last().unwrap_or(&0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if !queue_waits.is_empty() {
|
|
174
|
+
queue_waits.sort();
|
|
175
|
+
stats.queue_wait_p99_ms = calculate_percentile(&queue_waits, 99);
|
|
176
|
+
stats.queue_wait_max_ms = *queue_waits.last().unwrap_or(&0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if !lock_waits.is_empty() {
|
|
180
|
+
lock_waits.sort();
|
|
181
|
+
stats.lock_wait_p99_ms = calculate_percentile(&lock_waits, 99);
|
|
182
|
+
stats.lock_wait_max_ms = *lock_waits.last().unwrap_or(&0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if !execution_times.is_empty() {
|
|
186
|
+
stats.execution_p99_ms = calculate_percentile(&execution_times, 99);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if !persistence_times.is_empty() {
|
|
190
|
+
stats.persistence_p99_ms = calculate_percentile(&persistence_times, 99);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
stats
|
|
194
|
+
} else {
|
|
195
|
+
TelemetryStatistics::default()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pub fn clear(&self) {
|
|
200
|
+
if let Ok(mut records) = self.records.lock() {
|
|
201
|
+
records.clear();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[derive(Debug, Clone, Serialize, Default)]
|
|
207
|
+
pub struct TelemetryStatistics {
|
|
208
|
+
pub total_requests: usize,
|
|
209
|
+
pub successful_requests: usize,
|
|
210
|
+
pub client_errors: usize,
|
|
211
|
+
pub server_errors: usize,
|
|
212
|
+
pub cancelled_requests: usize,
|
|
213
|
+
pub conflicts: usize,
|
|
214
|
+
pub too_busy_count: usize,
|
|
215
|
+
|
|
216
|
+
pub latency_p50_ms: u64,
|
|
217
|
+
pub latency_p95_ms: u64,
|
|
218
|
+
pub latency_p99_ms: u64,
|
|
219
|
+
pub latency_max_ms: u64,
|
|
220
|
+
|
|
221
|
+
pub queue_wait_p99_ms: u64,
|
|
222
|
+
pub queue_wait_max_ms: u64,
|
|
223
|
+
|
|
224
|
+
pub lock_wait_p99_ms: u64,
|
|
225
|
+
pub lock_wait_max_ms: u64,
|
|
226
|
+
|
|
227
|
+
pub execution_p99_ms: u64,
|
|
228
|
+
pub persistence_p99_ms: u64,
|
|
229
|
+
|
|
230
|
+
pub max_queue_depth: usize,
|
|
231
|
+
pub total_orphaned_tasks: usize,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn calculate_percentile(sorted: &[u64], percentile: u32) -> u64 {
|
|
235
|
+
if sorted.is_empty() {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
let index = ((sorted.len() as u32 * percentile / 100).max(1) - 1) as usize;
|
|
239
|
+
sorted[index.min(sorted.len() - 1)]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Request lifecycle timer: tracks all phases of request execution.
|
|
243
|
+
pub struct RequestTimer {
|
|
244
|
+
start: Instant,
|
|
245
|
+
queue_start: Option<Instant>,
|
|
246
|
+
lock_start: Option<Instant>,
|
|
247
|
+
execution_start: Option<Instant>,
|
|
248
|
+
persistence_start: Option<Instant>,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
impl RequestTimer {
|
|
252
|
+
pub fn new() -> Self {
|
|
253
|
+
Self {
|
|
254
|
+
start: Instant::now(),
|
|
255
|
+
queue_start: None,
|
|
256
|
+
lock_start: None,
|
|
257
|
+
execution_start: None,
|
|
258
|
+
persistence_start: None,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pub fn begin_queue_wait(&mut self) {
|
|
263
|
+
self.queue_start = Some(Instant::now());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
pub fn end_queue_wait(&mut self) -> u64 {
|
|
267
|
+
self.queue_start
|
|
268
|
+
.take()
|
|
269
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
270
|
+
.unwrap_or(0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
pub fn begin_lock_wait(&mut self) {
|
|
274
|
+
self.lock_start = Some(Instant::now());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pub fn end_lock_wait(&mut self) -> u64 {
|
|
278
|
+
self.lock_start
|
|
279
|
+
.take()
|
|
280
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
281
|
+
.unwrap_or(0)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
pub fn begin_execution(&mut self) {
|
|
285
|
+
self.execution_start = Some(Instant::now());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
pub fn end_execution(&mut self) -> u64 {
|
|
289
|
+
self.execution_start
|
|
290
|
+
.take()
|
|
291
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
292
|
+
.unwrap_or(0)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pub fn begin_persistence(&mut self) {
|
|
296
|
+
self.persistence_start = Some(Instant::now());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pub fn end_persistence(&mut self) -> u64 {
|
|
300
|
+
self.persistence_start
|
|
301
|
+
.take()
|
|
302
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
303
|
+
.unwrap_or(0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
pub fn total_elapsed(&self) -> u64 {
|
|
307
|
+
self.start.elapsed().as_millis() as u64
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
impl Default for RequestTimer {
|
|
312
|
+
fn default() -> Self {
|
|
313
|
+
Self::new()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[cfg(test)]
|
|
318
|
+
mod tests {
|
|
319
|
+
use super::*;
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn test_telemetry_creation() {
|
|
323
|
+
let telemetry = RequestTelemetry::new("app-1".to_string(), "rev-1".to_string());
|
|
324
|
+
assert!(!telemetry.request_id.is_empty());
|
|
325
|
+
assert_eq!(telemetry.application_id, "app-1");
|
|
326
|
+
assert_eq!(telemetry.revision_id, "rev-1");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn test_telemetry_store() {
|
|
331
|
+
let store = RequestTelemetryStore::new(100);
|
|
332
|
+
let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
|
|
333
|
+
telemetry.total_ms = 50;
|
|
334
|
+
telemetry.http_status = 200;
|
|
335
|
+
|
|
336
|
+
store.record(telemetry.clone());
|
|
337
|
+
let all = store.all();
|
|
338
|
+
assert_eq!(all.len(), 1);
|
|
339
|
+
assert_eq!(all[0].total_ms, 50);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#[test]
|
|
343
|
+
fn test_timer_lifecycle() {
|
|
344
|
+
let mut timer = RequestTimer::new();
|
|
345
|
+
|
|
346
|
+
timer.begin_queue_wait();
|
|
347
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
348
|
+
let queue_ms = timer.end_queue_wait();
|
|
349
|
+
assert!(queue_ms >= 10);
|
|
350
|
+
|
|
351
|
+
timer.begin_lock_wait();
|
|
352
|
+
std::thread::sleep(Duration::from_millis(5));
|
|
353
|
+
let lock_ms = timer.end_lock_wait();
|
|
354
|
+
assert!(lock_ms >= 5);
|
|
355
|
+
|
|
356
|
+
let total = timer.total_elapsed();
|
|
357
|
+
assert!(total >= 15);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn test_statistics_calculation() {
|
|
362
|
+
let store = RequestTelemetryStore::new(1000);
|
|
363
|
+
|
|
364
|
+
// Record 101 requests with increasing latency
|
|
365
|
+
for i in 0..101 {
|
|
366
|
+
let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
|
|
367
|
+
telemetry.total_ms = (i * 10) as u64; // 0, 10, 20, ..., 1000
|
|
368
|
+
telemetry.http_status = if i % 10 == 0 { 409 } else { 200 };
|
|
369
|
+
if i % 10 == 0 {
|
|
370
|
+
telemetry.feltdb_code = "CONFLICT".to_string();
|
|
371
|
+
}
|
|
372
|
+
store.record(telemetry);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let stats = store.statistics();
|
|
376
|
+
assert_eq!(stats.total_requests, 101);
|
|
377
|
+
assert!(stats.latency_p50_ms > 0);
|
|
378
|
+
assert!(stats.latency_p99_ms > stats.latency_p50_ms);
|
|
379
|
+
assert_eq!(stats.conflicts, 11);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::sync::{Arc, Mutex};
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use sha2::{Digest, Sha256};
|
|
5
|
+
|
|
6
|
+
/// Durable transaction idempotency: prevents re-execution of already-committed transactions.
|
|
7
|
+
/// Every committed transaction is stored with its result. If the client retries with the
|
|
8
|
+
/// same transaction_id + payload hash, the server returns the original result without re-executing.
|
|
9
|
+
|
|
10
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
11
|
+
pub struct CommittedTransaction {
|
|
12
|
+
pub transaction_id: String,
|
|
13
|
+
pub transaction_hash: String,
|
|
14
|
+
pub status: TransactionStatus,
|
|
15
|
+
pub committed_result: serde_json::Value,
|
|
16
|
+
pub commit_metadata: CommitMetadata,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
20
|
+
pub enum TransactionStatus {
|
|
21
|
+
#[serde(rename = "COMMITTED")]
|
|
22
|
+
Committed,
|
|
23
|
+
#[serde(rename = "CONFLICT")]
|
|
24
|
+
Conflict,
|
|
25
|
+
#[serde(rename = "VALIDATION_FAILED")]
|
|
26
|
+
ValidationFailed,
|
|
27
|
+
#[serde(rename = "INTERNAL_ERROR")]
|
|
28
|
+
InternalError,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
32
|
+
pub struct CommitMetadata {
|
|
33
|
+
pub timestamp_ms: u64,
|
|
34
|
+
pub revision_id: String,
|
|
35
|
+
pub application_id: String,
|
|
36
|
+
pub state_before: u64,
|
|
37
|
+
pub state_after: u64,
|
|
38
|
+
pub retry_count: usize,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub struct TransactionIdempotencyStore {
|
|
42
|
+
// Map: transaction_id -> list of committed transactions (to detect hash mismatches)
|
|
43
|
+
store: Arc<Mutex<HashMap<String, Vec<CommittedTransaction>>>>,
|
|
44
|
+
max_per_transaction: usize,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl TransactionIdempotencyStore {
|
|
48
|
+
pub fn new(max_per_transaction: usize) -> Self {
|
|
49
|
+
Self {
|
|
50
|
+
store: Arc::new(Mutex::new(HashMap::new())),
|
|
51
|
+
max_per_transaction,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Record a committed transaction for idempotency.
|
|
56
|
+
/// If the same transaction_id is seen again with the same hash, return the original result.
|
|
57
|
+
/// If the same transaction_id is seen with a different hash, return CONFLICT.
|
|
58
|
+
pub fn record_committed(
|
|
59
|
+
&self,
|
|
60
|
+
transaction_id: String,
|
|
61
|
+
transaction_hash: String,
|
|
62
|
+
status: TransactionStatus,
|
|
63
|
+
result: serde_json::Value,
|
|
64
|
+
metadata: CommitMetadata,
|
|
65
|
+
) {
|
|
66
|
+
if let Ok(mut store) = self.store.lock() {
|
|
67
|
+
let tx_id_clone = transaction_id.clone();
|
|
68
|
+
let committed = CommittedTransaction {
|
|
69
|
+
transaction_id,
|
|
70
|
+
transaction_hash,
|
|
71
|
+
status,
|
|
72
|
+
committed_result: result,
|
|
73
|
+
commit_metadata: metadata,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
store
|
|
77
|
+
.entry(tx_id_clone.clone())
|
|
78
|
+
.or_insert_with(Vec::new)
|
|
79
|
+
.push(committed);
|
|
80
|
+
|
|
81
|
+
// Keep only the most recent attempts
|
|
82
|
+
if let Some(entries) = store.get_mut(&tx_id_clone) {
|
|
83
|
+
if entries.len() > self.max_per_transaction {
|
|
84
|
+
let to_remove = entries.len() - self.max_per_transaction;
|
|
85
|
+
entries.drain(0..to_remove);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Check if a transaction has been seen before.
|
|
92
|
+
/// Returns:
|
|
93
|
+
/// - Some(Ok(result)) if same transaction_id + same hash (idempotent replay)
|
|
94
|
+
/// - Some(Err("CONFLICT")) if same transaction_id + different hash (reuse with different payload)
|
|
95
|
+
/// - None if this is a new transaction_id
|
|
96
|
+
pub fn check_idempotent(
|
|
97
|
+
&self,
|
|
98
|
+
transaction_id: &str,
|
|
99
|
+
transaction_hash: &str,
|
|
100
|
+
) -> Option<Result<serde_json::Value, String>> {
|
|
101
|
+
self.store
|
|
102
|
+
.lock()
|
|
103
|
+
.ok()
|
|
104
|
+
.and_then(|store| store.get(transaction_id).map(|entries| entries.clone()))
|
|
105
|
+
.and_then(|entries| {
|
|
106
|
+
if entries.is_empty() {
|
|
107
|
+
return None;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let first_entry = &entries[0];
|
|
111
|
+
|
|
112
|
+
// If hashes match, return the original result
|
|
113
|
+
if first_entry.transaction_hash == transaction_hash {
|
|
114
|
+
return Some(Ok(first_entry.committed_result.clone()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If hashes differ, it's a conflict (transaction ID reused with different payload)
|
|
118
|
+
Some(Err(format!(
|
|
119
|
+
"Transaction ID {} reused with different payload",
|
|
120
|
+
transaction_id
|
|
121
|
+
)))
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Get all committed transactions for a transaction_id
|
|
126
|
+
pub fn get_history(&self, transaction_id: &str) -> Vec<CommittedTransaction> {
|
|
127
|
+
self.store
|
|
128
|
+
.lock()
|
|
129
|
+
.ok()
|
|
130
|
+
.and_then(|store| store.get(transaction_id).cloned())
|
|
131
|
+
.unwrap_or_default()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Clear all idempotency records (for testing)
|
|
135
|
+
pub fn clear(&self) {
|
|
136
|
+
if let Ok(mut store) = self.store.lock() {
|
|
137
|
+
store.clear();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Get statistics about idempotency store
|
|
142
|
+
pub fn statistics(&self) -> IdempotencyStatistics {
|
|
143
|
+
self.store
|
|
144
|
+
.lock()
|
|
145
|
+
.ok()
|
|
146
|
+
.map(|store| {
|
|
147
|
+
let total_transactions = store.len();
|
|
148
|
+
let total_entries = store.values().map(|v| v.len()).sum();
|
|
149
|
+
let replay_protected = store
|
|
150
|
+
.values()
|
|
151
|
+
.filter(|entries| !entries.is_empty() && entries[0].status == TransactionStatus::Committed)
|
|
152
|
+
.count();
|
|
153
|
+
|
|
154
|
+
IdempotencyStatistics {
|
|
155
|
+
total_transaction_ids: total_transactions,
|
|
156
|
+
total_committed_entries: total_entries,
|
|
157
|
+
replay_protected_count: replay_protected,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
.unwrap_or_default()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[derive(Debug, Clone, Serialize, Default)]
|
|
165
|
+
pub struct IdempotencyStatistics {
|
|
166
|
+
pub total_transaction_ids: usize,
|
|
167
|
+
pub total_committed_entries: usize,
|
|
168
|
+
pub replay_protected_count: usize,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Compute a hash of a transaction request for idempotency detection.
|
|
172
|
+
pub fn hash_transaction_request(request: &serde_json::Value) -> String {
|
|
173
|
+
let mut hasher = Sha256::new();
|
|
174
|
+
let json_str = serde_json::to_string(request).unwrap_or_default();
|
|
175
|
+
hasher.update(json_str.as_bytes());
|
|
176
|
+
format!("{:x}", hasher.finalize())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[cfg(test)]
|
|
180
|
+
mod tests {
|
|
181
|
+
use super::*;
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn test_idempotency_store_same_hash() {
|
|
185
|
+
let store = TransactionIdempotencyStore::new(10);
|
|
186
|
+
let tx_id = "tx-123".to_string();
|
|
187
|
+
let tx_hash = "hash-abc".to_string();
|
|
188
|
+
let result = serde_json::json!({ "status": "committed" });
|
|
189
|
+
|
|
190
|
+
store.record_committed(
|
|
191
|
+
tx_id.clone(),
|
|
192
|
+
tx_hash.clone(),
|
|
193
|
+
TransactionStatus::Committed,
|
|
194
|
+
result.clone(),
|
|
195
|
+
CommitMetadata {
|
|
196
|
+
timestamp_ms: 1000,
|
|
197
|
+
revision_id: "rev-1".to_string(),
|
|
198
|
+
application_id: "app-1".to_string(),
|
|
199
|
+
state_before: 1,
|
|
200
|
+
state_after: 2,
|
|
201
|
+
retry_count: 0,
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// First replay should return the same result
|
|
206
|
+
let replay = store.check_idempotent(&tx_id, &tx_hash);
|
|
207
|
+
assert!(replay.is_some());
|
|
208
|
+
assert!(replay.unwrap().is_ok());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn test_idempotency_store_different_hash() {
|
|
213
|
+
let store = TransactionIdempotencyStore::new(10);
|
|
214
|
+
let tx_id = "tx-456".to_string();
|
|
215
|
+
let tx_hash_1 = "hash-1".to_string();
|
|
216
|
+
let tx_hash_2 = "hash-2".to_string();
|
|
217
|
+
let result = serde_json::json!({ "status": "committed" });
|
|
218
|
+
|
|
219
|
+
store.record_committed(
|
|
220
|
+
tx_id.clone(),
|
|
221
|
+
tx_hash_1.clone(),
|
|
222
|
+
TransactionStatus::Committed,
|
|
223
|
+
result,
|
|
224
|
+
CommitMetadata {
|
|
225
|
+
timestamp_ms: 1000,
|
|
226
|
+
revision_id: "rev-1".to_string(),
|
|
227
|
+
application_id: "app-1".to_string(),
|
|
228
|
+
state_before: 1,
|
|
229
|
+
state_after: 2,
|
|
230
|
+
retry_count: 0,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Replay with different hash should fail
|
|
235
|
+
let replay = store.check_idempotent(&tx_id, &tx_hash_2);
|
|
236
|
+
assert!(replay.is_some());
|
|
237
|
+
assert!(replay.unwrap().is_err());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn test_hash_transaction_request() {
|
|
242
|
+
let request1 = serde_json::json!({ "id": "test", "value": 123 });
|
|
243
|
+
let request2 = serde_json::json!({ "id": "test", "value": 123 });
|
|
244
|
+
let request3 = serde_json::json!({ "id": "test", "value": 456 });
|
|
245
|
+
|
|
246
|
+
let hash1 = hash_transaction_request(&request1);
|
|
247
|
+
let hash2 = hash_transaction_request(&request2);
|
|
248
|
+
let hash3 = hash_transaction_request(&request3);
|
|
249
|
+
|
|
250
|
+
assert_eq!(hash1, hash2); // Same content = same hash
|
|
251
|
+
assert_ne!(hash1, hash3); // Different content = different hash
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn test_idempotency_statistics() {
|
|
256
|
+
let store = TransactionIdempotencyStore::new(10);
|
|
257
|
+
|
|
258
|
+
for i in 0..5 {
|
|
259
|
+
store.record_committed(
|
|
260
|
+
format!("tx-{}", i),
|
|
261
|
+
"hash-abc".to_string(),
|
|
262
|
+
TransactionStatus::Committed,
|
|
263
|
+
serde_json::json!({}),
|
|
264
|
+
CommitMetadata {
|
|
265
|
+
timestamp_ms: 1000,
|
|
266
|
+
revision_id: "rev-1".to_string(),
|
|
267
|
+
application_id: "app-1".to_string(),
|
|
268
|
+
state_before: 1,
|
|
269
|
+
state_after: 2,
|
|
270
|
+
retry_count: 0,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let stats = store.statistics();
|
|
276
|
+
assert_eq!(stats.total_transaction_ids, 5);
|
|
277
|
+
assert_eq!(stats.total_committed_entries, 5);
|
|
278
|
+
assert_eq!(stats.replay_protected_count, 5);
|
|
279
|
+
}
|
|
280
|
+
}
|