create-feltdb 0.5.0 → 0.5.2
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/application.rs +183 -8
- package/dist/server-source/crates/feltdb/src/lib.rs +173 -0
- package/dist/server-source/crates/feltdb/src/managed_cas_tests.rs +231 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +314 -46
- package/dist/server-source/crates/feltdb-server/src/request_telemetry.rs +1 -1
- package/dist/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +219 -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.5.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.2';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -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
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
mod acceptance_tests;
|
|
3
3
|
#[cfg(test)]
|
|
4
4
|
mod admission_contract_tests;
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
mod managed_cas_tests;
|
|
5
7
|
mod acquisition;
|
|
6
8
|
pub mod admission;
|
|
7
9
|
pub mod application;
|
|
@@ -329,6 +331,32 @@ pub struct AtomicCommit {
|
|
|
329
331
|
pub rows: Vec<StoredRow>,
|
|
330
332
|
pub duplicate: bool,
|
|
331
333
|
}
|
|
334
|
+
|
|
335
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
336
|
+
pub enum JsonCasResult {
|
|
337
|
+
Updated {
|
|
338
|
+
current_version: u64,
|
|
339
|
+
current_epoch: u64,
|
|
340
|
+
value: Value,
|
|
341
|
+
},
|
|
342
|
+
VersionConflict {
|
|
343
|
+
current_version: u64,
|
|
344
|
+
current_epoch: u64,
|
|
345
|
+
},
|
|
346
|
+
AuthorityConflict {
|
|
347
|
+
current_version: u64,
|
|
348
|
+
current_epoch: u64,
|
|
349
|
+
},
|
|
350
|
+
LeaseConflict {
|
|
351
|
+
current_version: u64,
|
|
352
|
+
current_epoch: u64,
|
|
353
|
+
},
|
|
354
|
+
ReplicationConflict {
|
|
355
|
+
current_version: u64,
|
|
356
|
+
current_epoch: u64,
|
|
357
|
+
},
|
|
358
|
+
NotFound,
|
|
359
|
+
}
|
|
332
360
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
333
361
|
struct TransactionLogRecord {
|
|
334
362
|
record_type: String,
|
|
@@ -478,6 +506,151 @@ impl FeltDb {
|
|
|
478
506
|
Ok(())
|
|
479
507
|
}
|
|
480
508
|
|
|
509
|
+
/// Atomically compare the logical record version and authority epoch before
|
|
510
|
+
/// durably replacing a JSON object. Failed predicates append no operation
|
|
511
|
+
/// and emit no change event.
|
|
512
|
+
pub fn compare_and_set_json(
|
|
513
|
+
&self,
|
|
514
|
+
key: &str,
|
|
515
|
+
expected_version: u64,
|
|
516
|
+
expected_epoch: Option<u64>,
|
|
517
|
+
expected_lease_id: Option<&str>,
|
|
518
|
+
reject_if_diverged: bool,
|
|
519
|
+
mut value: Value,
|
|
520
|
+
) -> Result<JsonCasResult> {
|
|
521
|
+
let capability = key
|
|
522
|
+
.split_once(':')
|
|
523
|
+
.map(|(cap, _)| cap)
|
|
524
|
+
.unwrap_or("default")
|
|
525
|
+
.to_string();
|
|
526
|
+
let rust_type = type_name::<Value>().to_string();
|
|
527
|
+
let (result, event) = {
|
|
528
|
+
let mut inner = self.inner.lock().expect("lock poisoned");
|
|
529
|
+
let Some(current) = inner.rows.get(&capability).and_then(|rows| rows.get(key)) else {
|
|
530
|
+
return Ok(JsonCasResult::NotFound);
|
|
531
|
+
};
|
|
532
|
+
let current_version = current
|
|
533
|
+
.value
|
|
534
|
+
.get("__version")
|
|
535
|
+
.and_then(Value::as_u64)
|
|
536
|
+
.unwrap_or(1);
|
|
537
|
+
let current_epoch = current
|
|
538
|
+
.value
|
|
539
|
+
.pointer("/authority/epoch")
|
|
540
|
+
.and_then(Value::as_u64)
|
|
541
|
+
.unwrap_or(0);
|
|
542
|
+
if current_version != expected_version {
|
|
543
|
+
return Ok(JsonCasResult::VersionConflict {
|
|
544
|
+
current_version,
|
|
545
|
+
current_epoch,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if expected_epoch.is_some_and(|expected| expected != current_epoch) {
|
|
549
|
+
return Ok(JsonCasResult::AuthorityConflict {
|
|
550
|
+
current_version,
|
|
551
|
+
current_epoch,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
let persisted_lease = current.value.get("lease").filter(|lease| !lease.is_null());
|
|
555
|
+
let lease_matches = match (persisted_lease, expected_lease_id) {
|
|
556
|
+
(None, None) => true,
|
|
557
|
+
(None, Some(_)) => false,
|
|
558
|
+
(Some(lease), Some(expected_id)) => {
|
|
559
|
+
lease.get("leaseId").and_then(Value::as_str) == Some(expected_id)
|
|
560
|
+
&& lease
|
|
561
|
+
.get("expiresAt")
|
|
562
|
+
.and_then(Value::as_u64)
|
|
563
|
+
.is_some_and(|expires_at| expires_at > now_ms() as u64)
|
|
564
|
+
}
|
|
565
|
+
(Some(lease), None) => lease
|
|
566
|
+
.get("expiresAt")
|
|
567
|
+
.and_then(Value::as_u64)
|
|
568
|
+
.is_some_and(|expires_at| expires_at <= now_ms() as u64),
|
|
569
|
+
};
|
|
570
|
+
if !lease_matches {
|
|
571
|
+
return Ok(JsonCasResult::LeaseConflict {
|
|
572
|
+
current_version,
|
|
573
|
+
current_epoch,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if reject_if_diverged && capability == "_cells" {
|
|
577
|
+
let replica_key = key
|
|
578
|
+
.strip_prefix("_cells:")
|
|
579
|
+
.map(|suffix| format!("_replicas:{suffix}"));
|
|
580
|
+
let divergent = replica_key.as_ref().and_then(|replica_key| {
|
|
581
|
+
inner
|
|
582
|
+
.rows
|
|
583
|
+
.get("_replicas")
|
|
584
|
+
.and_then(|rows| rows.get(replica_key))
|
|
585
|
+
.and_then(|row| row.value.get("divergent"))
|
|
586
|
+
.and_then(Value::as_bool)
|
|
587
|
+
}) == Some(true);
|
|
588
|
+
if divergent {
|
|
589
|
+
return Ok(JsonCasResult::ReplicationConflict {
|
|
590
|
+
current_version,
|
|
591
|
+
current_epoch,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let object = value.as_object_mut().ok_or_else(|| {
|
|
597
|
+
FlowError::CapabilityError("CAS value must be a JSON object".to_string())
|
|
598
|
+
})?;
|
|
599
|
+
object.insert("__version".to_string(), Value::from(expected_version + 1));
|
|
600
|
+
|
|
601
|
+
inner.sequence += 1;
|
|
602
|
+
inner.sync_state.increment_vector_clock();
|
|
603
|
+
let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
|
|
604
|
+
let operation = Operation::update(
|
|
605
|
+
inner.sequence,
|
|
606
|
+
inner.instance_id.clone(),
|
|
607
|
+
inner.sequence,
|
|
608
|
+
key.to_string(),
|
|
609
|
+
value.clone(),
|
|
610
|
+
rust_type.clone(),
|
|
611
|
+
capability.clone(),
|
|
612
|
+
)
|
|
613
|
+
.with_vector_clock(vector_clock);
|
|
614
|
+
let row = StoredRow {
|
|
615
|
+
capability: capability.clone(),
|
|
616
|
+
key: key.to_string(),
|
|
617
|
+
rust_type: rust_type.clone(),
|
|
618
|
+
value: value.clone(),
|
|
619
|
+
unix_ms: now_ms(),
|
|
620
|
+
content_hash: Some(operation.content_hash.clone()),
|
|
621
|
+
flow_ref: None,
|
|
622
|
+
deleted: false,
|
|
623
|
+
operation: Some(operation.clone()),
|
|
624
|
+
};
|
|
625
|
+
append_event(&inner.path, &row)?;
|
|
626
|
+
inner.change_log.add_operation(operation);
|
|
627
|
+
inner
|
|
628
|
+
.rows
|
|
629
|
+
.entry(capability.clone())
|
|
630
|
+
.or_default()
|
|
631
|
+
.insert(key.to_string(), row.clone());
|
|
632
|
+
let event = ChangeEvent {
|
|
633
|
+
capability,
|
|
634
|
+
key: key.to_string(),
|
|
635
|
+
rust_type,
|
|
636
|
+
unix_ms: row.unix_ms,
|
|
637
|
+
};
|
|
638
|
+
(
|
|
639
|
+
JsonCasResult::Updated {
|
|
640
|
+
current_version: expected_version + 1,
|
|
641
|
+
current_epoch: value
|
|
642
|
+
.pointer("/authority/epoch")
|
|
643
|
+
.and_then(Value::as_u64)
|
|
644
|
+
.unwrap_or(current_epoch),
|
|
645
|
+
value,
|
|
646
|
+
},
|
|
647
|
+
event,
|
|
648
|
+
)
|
|
649
|
+
};
|
|
650
|
+
let _ = self.event_tx.send(event);
|
|
651
|
+
Ok(result)
|
|
652
|
+
}
|
|
653
|
+
|
|
481
654
|
/// Delete a record with vector clock tracking
|
|
482
655
|
pub fn delete(&self, key: &str) -> Result<()> {
|
|
483
656
|
let capability = key
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
use std::{fs, path::PathBuf};
|
|
2
|
+
|
|
3
|
+
use serde_json::json;
|
|
4
|
+
|
|
5
|
+
use crate::{FeltDb, JsonCasResult};
|
|
6
|
+
|
|
7
|
+
fn database(name: &str) -> (FeltDb, PathBuf) {
|
|
8
|
+
let path = std::env::temp_dir().join(format!(
|
|
9
|
+
"feltdb-managed-cas-{name}-{}-{}.log",
|
|
10
|
+
std::process::id(),
|
|
11
|
+
std::time::SystemTime::now()
|
|
12
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
13
|
+
.unwrap()
|
|
14
|
+
.as_nanos()
|
|
15
|
+
));
|
|
16
|
+
(FeltDb::open(&path).unwrap(), path)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[test]
|
|
20
|
+
fn version_and_epoch_are_one_atomic_predicate() {
|
|
21
|
+
let (db, path) = database("predicate");
|
|
22
|
+
let key = "_cells:cell-1";
|
|
23
|
+
db.insert(
|
|
24
|
+
key,
|
|
25
|
+
json!({
|
|
26
|
+
"id": "cell-1",
|
|
27
|
+
"version": 42,
|
|
28
|
+
"value": "before",
|
|
29
|
+
"authority": { "owner": "A", "epoch": 7, "lifecycle": "active" },
|
|
30
|
+
"__version": 1
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.unwrap();
|
|
34
|
+
|
|
35
|
+
let stale_epoch = db
|
|
36
|
+
.compare_and_set_json(
|
|
37
|
+
key,
|
|
38
|
+
1,
|
|
39
|
+
Some(6),
|
|
40
|
+
None,
|
|
41
|
+
false,
|
|
42
|
+
json!({
|
|
43
|
+
"id": "cell-1",
|
|
44
|
+
"version": 42,
|
|
45
|
+
"value": "stale",
|
|
46
|
+
"authority": { "owner": "stale", "epoch": 7, "lifecycle": "active" }
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
.unwrap();
|
|
50
|
+
assert_eq!(
|
|
51
|
+
stale_epoch,
|
|
52
|
+
JsonCasResult::AuthorityConflict {
|
|
53
|
+
current_version: 1,
|
|
54
|
+
current_epoch: 7,
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
|
|
58
|
+
|
|
59
|
+
let updated = db
|
|
60
|
+
.compare_and_set_json(
|
|
61
|
+
key,
|
|
62
|
+
1,
|
|
63
|
+
Some(7),
|
|
64
|
+
None,
|
|
65
|
+
false,
|
|
66
|
+
json!({
|
|
67
|
+
"id": "cell-1",
|
|
68
|
+
"version": 42,
|
|
69
|
+
"value": "after",
|
|
70
|
+
"authority": { "owner": "B", "epoch": 8, "lifecycle": "active" }
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
.unwrap();
|
|
74
|
+
assert!(matches!(
|
|
75
|
+
updated,
|
|
76
|
+
JsonCasResult::Updated {
|
|
77
|
+
current_version: 2,
|
|
78
|
+
current_epoch: 8,
|
|
79
|
+
..
|
|
80
|
+
}
|
|
81
|
+
));
|
|
82
|
+
|
|
83
|
+
let stale_version = db
|
|
84
|
+
.compare_and_set_json(key, 1, Some(8), None, false, json!({ "value": "lost" }))
|
|
85
|
+
.unwrap();
|
|
86
|
+
assert_eq!(
|
|
87
|
+
stale_version,
|
|
88
|
+
JsonCasResult::VersionConflict {
|
|
89
|
+
current_version: 2,
|
|
90
|
+
current_epoch: 8,
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
let final_value = db.get_value(key).unwrap().unwrap();
|
|
94
|
+
assert_eq!(final_value["value"], "after");
|
|
95
|
+
assert_eq!(final_value["__version"], 2);
|
|
96
|
+
assert_eq!(final_value["authority"]["epoch"], 8);
|
|
97
|
+
|
|
98
|
+
drop(db);
|
|
99
|
+
let _ = fs::remove_file(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn lease_is_checked_with_the_server_clock_inside_cas() {
|
|
104
|
+
let (db, path) = database("lease");
|
|
105
|
+
let key = "_cells:leased";
|
|
106
|
+
let future = crate::now_ms() as u64 + 60_000;
|
|
107
|
+
db.insert(
|
|
108
|
+
key,
|
|
109
|
+
json!({
|
|
110
|
+
"value": "before",
|
|
111
|
+
"authority": { "epoch": 3 },
|
|
112
|
+
"lease": { "leaseId": "lease-good", "expiresAt": future },
|
|
113
|
+
"__version": 1
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
.unwrap();
|
|
117
|
+
|
|
118
|
+
for expected_lease_id in [None, Some("lease-wrong")] {
|
|
119
|
+
assert_eq!(
|
|
120
|
+
db.compare_and_set_json(
|
|
121
|
+
key,
|
|
122
|
+
1,
|
|
123
|
+
Some(3),
|
|
124
|
+
expected_lease_id,
|
|
125
|
+
false,
|
|
126
|
+
json!({ "value": "rejected", "authority": { "epoch": 3 } }),
|
|
127
|
+
)
|
|
128
|
+
.unwrap(),
|
|
129
|
+
JsonCasResult::LeaseConflict {
|
|
130
|
+
current_version: 1,
|
|
131
|
+
current_epoch: 3,
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assert!(matches!(
|
|
138
|
+
db.compare_and_set_json(
|
|
139
|
+
key,
|
|
140
|
+
1,
|
|
141
|
+
Some(3),
|
|
142
|
+
Some("lease-good"),
|
|
143
|
+
false,
|
|
144
|
+
json!({ "value": "accepted", "authority": { "epoch": 3 }, "lease": { "leaseId": "lease-good", "expiresAt": future } }),
|
|
145
|
+
)
|
|
146
|
+
.unwrap(),
|
|
147
|
+
JsonCasResult::Updated { .. }
|
|
148
|
+
));
|
|
149
|
+
|
|
150
|
+
let expired_key = "_cells:expired";
|
|
151
|
+
db.insert(
|
|
152
|
+
expired_key,
|
|
153
|
+
json!({
|
|
154
|
+
"value": "before",
|
|
155
|
+
"authority": { "epoch": 3 },
|
|
156
|
+
"lease": { "leaseId": "lease-expired", "expiresAt": 1 },
|
|
157
|
+
"__version": 1
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
.unwrap();
|
|
161
|
+
assert_eq!(
|
|
162
|
+
db.compare_and_set_json(
|
|
163
|
+
expired_key,
|
|
164
|
+
1,
|
|
165
|
+
Some(3),
|
|
166
|
+
Some("lease-expired"),
|
|
167
|
+
false,
|
|
168
|
+
json!({ "value": "rejected", "authority": { "epoch": 3 } }),
|
|
169
|
+
)
|
|
170
|
+
.unwrap(),
|
|
171
|
+
JsonCasResult::LeaseConflict {
|
|
172
|
+
current_version: 1,
|
|
173
|
+
current_epoch: 3,
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
assert_eq!(db.get_value(expired_key).unwrap().unwrap()["value"], "before");
|
|
177
|
+
|
|
178
|
+
drop(db);
|
|
179
|
+
let _ = fs::remove_file(path);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn divergent_replica_blocks_promotion_inside_cas() {
|
|
184
|
+
let (db, path) = database("divergence");
|
|
185
|
+
let cell_key = "_cells:replica";
|
|
186
|
+
let replica_key = "_replicas:replica";
|
|
187
|
+
db.insert(
|
|
188
|
+
cell_key,
|
|
189
|
+
json!({
|
|
190
|
+
"version": 42,
|
|
191
|
+
"value": "A",
|
|
192
|
+
"authority": { "owner": "A", "epoch": 7 },
|
|
193
|
+
"lease": null,
|
|
194
|
+
"__version": 42
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
.unwrap();
|
|
198
|
+
db.insert(
|
|
199
|
+
replica_key,
|
|
200
|
+
json!({ "version": 42, "value": "B", "divergent": true }),
|
|
201
|
+
)
|
|
202
|
+
.unwrap();
|
|
203
|
+
let sequence_before = db.sequence().unwrap();
|
|
204
|
+
let value_before = db.get_value(cell_key).unwrap().unwrap();
|
|
205
|
+
|
|
206
|
+
assert_eq!(
|
|
207
|
+
db.compare_and_set_json(
|
|
208
|
+
cell_key,
|
|
209
|
+
42,
|
|
210
|
+
Some(7),
|
|
211
|
+
None,
|
|
212
|
+
true,
|
|
213
|
+
json!({
|
|
214
|
+
"version": 43,
|
|
215
|
+
"value": "A",
|
|
216
|
+
"authority": { "owner": "B", "epoch": 8 },
|
|
217
|
+
"lease": null
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
.unwrap(),
|
|
221
|
+
JsonCasResult::ReplicationConflict {
|
|
222
|
+
current_version: 42,
|
|
223
|
+
current_epoch: 7,
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
assert_eq!(db.sequence().unwrap(), sequence_before);
|
|
227
|
+
assert_eq!(db.get_value(cell_key).unwrap().unwrap(), value_before);
|
|
228
|
+
|
|
229
|
+
drop(db);
|
|
230
|
+
let _ = fs::remove_file(path);
|
|
231
|
+
}
|