create-feltdb 0.10.0 → 0.11.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/crates/feltdb/src/lib.rs +105 -12
- package/dist/server-source/crates/feltdb/tests/local_process_ownership.rs +69 -0
- package/dist/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +27 -12
- package/dist/server-source/crates/feltdb-server/src/main.rs +10 -8
- 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.11.1';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -287,12 +287,12 @@ use std::any::type_name;
|
|
|
287
287
|
use std::collections::hash_map::DefaultHasher;
|
|
288
288
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
|
289
289
|
use std::fmt::{Display, Formatter};
|
|
290
|
-
use std::fs::{self, OpenOptions};
|
|
290
|
+
use std::fs::{self, File, OpenOptions};
|
|
291
291
|
use std::hash::{Hash, Hasher};
|
|
292
292
|
use std::io::{BufRead, BufReader, Write};
|
|
293
293
|
use std::ops::Bound::{Excluded, Unbounded};
|
|
294
294
|
use std::path::{Path, PathBuf};
|
|
295
|
-
use std::sync::{Arc, Mutex};
|
|
295
|
+
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|
296
296
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
297
297
|
use tokio::sync::broadcast;
|
|
298
298
|
|
|
@@ -369,8 +369,17 @@ impl From<String> for FlowError {
|
|
|
369
369
|
pub struct FeltDb {
|
|
370
370
|
inner: Arc<Mutex<Inner>>,
|
|
371
371
|
event_tx: broadcast::Sender<ChangeEvent>,
|
|
372
|
+
_ownership: Arc<File>,
|
|
372
373
|
}
|
|
373
374
|
|
|
375
|
+
struct OpenDatabase {
|
|
376
|
+
inner: Weak<Mutex<Inner>>,
|
|
377
|
+
event_tx: broadcast::Sender<ChangeEvent>,
|
|
378
|
+
ownership: Weak<File>,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
static OPEN_DATABASES: OnceLock<Mutex<HashMap<PathBuf, OpenDatabase>>> = OnceLock::new();
|
|
382
|
+
|
|
374
383
|
#[derive(Default)]
|
|
375
384
|
#[allow(dead_code)] // Reserved runtime subsystems are initialized before their public APIs land.
|
|
376
385
|
struct Inner {
|
|
@@ -1968,7 +1977,56 @@ struct RetentionRecord {
|
|
|
1968
1977
|
|
|
1969
1978
|
impl FeltDb {
|
|
1970
1979
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
1971
|
-
let
|
|
1980
|
+
let requested_path = path.as_ref();
|
|
1981
|
+
let parent = requested_path
|
|
1982
|
+
.parent()
|
|
1983
|
+
.filter(|parent| !parent.as_os_str().is_empty())
|
|
1984
|
+
.unwrap_or_else(|| Path::new("."));
|
|
1985
|
+
fs::create_dir_all(parent)?;
|
|
1986
|
+
let path = if requested_path.exists() {
|
|
1987
|
+
fs::canonicalize(requested_path)?
|
|
1988
|
+
} else {
|
|
1989
|
+
let file_name = requested_path.file_name().ok_or_else(|| {
|
|
1990
|
+
std::io::Error::new(
|
|
1991
|
+
std::io::ErrorKind::InvalidInput,
|
|
1992
|
+
"FeltDB database path has no file name",
|
|
1993
|
+
)
|
|
1994
|
+
})?;
|
|
1995
|
+
fs::canonicalize(parent)?.join(file_name)
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
let databases = OPEN_DATABASES.get_or_init(|| Mutex::new(HashMap::new()));
|
|
1999
|
+
let mut databases = databases.lock().expect("open database registry poisoned");
|
|
2000
|
+
if let Some(open) = databases.get(&path) {
|
|
2001
|
+
if let (Some(inner), Some(ownership)) =
|
|
2002
|
+
(open.inner.upgrade(), open.ownership.upgrade())
|
|
2003
|
+
{
|
|
2004
|
+
return Ok(Self {
|
|
2005
|
+
inner,
|
|
2006
|
+
event_tx: open.event_tx.clone(),
|
|
2007
|
+
_ownership: ownership,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
databases.remove(&path);
|
|
2012
|
+
|
|
2013
|
+
let mut ownership_path = path.as_os_str().to_os_string();
|
|
2014
|
+
ownership_path.push(".lock");
|
|
2015
|
+
let ownership = OpenOptions::new()
|
|
2016
|
+
.create(true)
|
|
2017
|
+
.read(true)
|
|
2018
|
+
.write(true)
|
|
2019
|
+
.open(PathBuf::from(ownership_path))?;
|
|
2020
|
+
ownership.try_lock().map_err(|error| match error {
|
|
2021
|
+
std::fs::TryLockError::WouldBlock => std::io::Error::new(
|
|
2022
|
+
std::io::ErrorKind::WouldBlock,
|
|
2023
|
+
format!(
|
|
2024
|
+
"FeltDB database {} is already owned by another process",
|
|
2025
|
+
path.display()
|
|
2026
|
+
),
|
|
2027
|
+
),
|
|
2028
|
+
std::fs::TryLockError::Error(error) => error,
|
|
2029
|
+
})?;
|
|
1972
2030
|
|
|
1973
2031
|
// Fail closed. The format is decided before any record is interpreted
|
|
1974
2032
|
// and before anything is written, so a database this build does not
|
|
@@ -1981,9 +2039,6 @@ impl FeltDb {
|
|
|
1981
2039
|
FormatCompatibility::Compatible(format) => format,
|
|
1982
2040
|
};
|
|
1983
2041
|
|
|
1984
|
-
if let Some(parent) = path.parent() {
|
|
1985
|
-
fs::create_dir_all(parent)?;
|
|
1986
|
-
}
|
|
1987
2042
|
if !path.exists() {
|
|
1988
2043
|
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
|
1989
2044
|
// A new database states its format in its first record, so it never
|
|
@@ -2019,10 +2074,20 @@ impl FeltDb {
|
|
|
2019
2074
|
|
|
2020
2075
|
let (event_tx, _) = broadcast::channel(1024);
|
|
2021
2076
|
|
|
2022
|
-
|
|
2077
|
+
let database = Self {
|
|
2023
2078
|
inner: Arc::new(Mutex::new(inner)),
|
|
2024
2079
|
event_tx,
|
|
2025
|
-
|
|
2080
|
+
_ownership: Arc::new(ownership),
|
|
2081
|
+
};
|
|
2082
|
+
databases.insert(
|
|
2083
|
+
path,
|
|
2084
|
+
OpenDatabase {
|
|
2085
|
+
inner: Arc::downgrade(&database.inner),
|
|
2086
|
+
event_tx: database.event_tx.clone(),
|
|
2087
|
+
ownership: Arc::downgrade(&database._ownership),
|
|
2088
|
+
},
|
|
2089
|
+
);
|
|
2090
|
+
Ok(database)
|
|
2026
2091
|
}
|
|
2027
2092
|
|
|
2028
2093
|
/// Write a self-contained durable backup artifact.
|
|
@@ -2264,10 +2329,21 @@ impl FeltDb {
|
|
|
2264
2329
|
.map(|(cap, _)| cap)
|
|
2265
2330
|
.unwrap_or("default")
|
|
2266
2331
|
.to_string();
|
|
2267
|
-
self.insert_internal(capability, key.to_string(), value)?;
|
|
2332
|
+
self.insert_internal(capability, key.to_string(), value, false)?;
|
|
2268
2333
|
Ok(())
|
|
2269
2334
|
}
|
|
2270
2335
|
|
|
2336
|
+
/// Insert only if the logical record is absent, deciding presence under the
|
|
2337
|
+
/// same lock that appends the operation and mints its first revision.
|
|
2338
|
+
pub fn insert_if_absent<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
2339
|
+
let capability = key
|
|
2340
|
+
.split_once(':')
|
|
2341
|
+
.map(|(cap, _)| cap)
|
|
2342
|
+
.unwrap_or("default")
|
|
2343
|
+
.to_string();
|
|
2344
|
+
self.insert_internal(capability, key.to_string(), value, true)
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2271
2347
|
/// Update an existing record with vector clock tracking
|
|
2272
2348
|
pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
2273
2349
|
let capability = key
|
|
@@ -2480,7 +2556,7 @@ impl FeltDb {
|
|
|
2480
2556
|
}
|
|
2481
2557
|
|
|
2482
2558
|
// Authorized: apply mutation
|
|
2483
|
-
self.insert_internal(capability, key.to_string(), value)?;
|
|
2559
|
+
self.insert_internal(capability, key.to_string(), value, false)?;
|
|
2484
2560
|
Ok(())
|
|
2485
2561
|
}
|
|
2486
2562
|
|
|
@@ -3195,6 +3271,7 @@ impl FeltDb {
|
|
|
3195
3271
|
capability: String,
|
|
3196
3272
|
key: String,
|
|
3197
3273
|
value: T,
|
|
3274
|
+
require_absent: bool,
|
|
3198
3275
|
) -> Result<()> {
|
|
3199
3276
|
let rust_type = type_name::<T>().to_string();
|
|
3200
3277
|
let value_json = serde_json::to_value(value)?;
|
|
@@ -3216,6 +3293,21 @@ impl FeltDb {
|
|
|
3216
3293
|
{
|
|
3217
3294
|
let mut inner = self.state();
|
|
3218
3295
|
|
|
3296
|
+
if require_absent
|
|
3297
|
+
&& inner
|
|
3298
|
+
.rows
|
|
3299
|
+
.get(&capability)
|
|
3300
|
+
.and_then(|bucket| bucket.get(&key))
|
|
3301
|
+
.is_some()
|
|
3302
|
+
{
|
|
3303
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
3304
|
+
PreconditionFailure::Present {
|
|
3305
|
+
collection: capability,
|
|
3306
|
+
key,
|
|
3307
|
+
},
|
|
3308
|
+
)));
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3219
3311
|
// Increment sequence number
|
|
3220
3312
|
inner.sequence += 1;
|
|
3221
3313
|
|
|
@@ -4497,7 +4589,7 @@ impl Capability {
|
|
|
4497
4589
|
key
|
|
4498
4590
|
};
|
|
4499
4591
|
self.db
|
|
4500
|
-
.insert_internal(self.name.clone(), key.clone(), value)?;
|
|
4592
|
+
.insert_internal(self.name.clone(), key.clone(), value, false)?;
|
|
4501
4593
|
Ok(key)
|
|
4502
4594
|
}
|
|
4503
4595
|
|
|
@@ -4507,7 +4599,8 @@ impl Capability {
|
|
|
4507
4599
|
} else {
|
|
4508
4600
|
format!("{}:{key}", self.name)
|
|
4509
4601
|
};
|
|
4510
|
-
self.db
|
|
4602
|
+
self.db
|
|
4603
|
+
.insert_internal(self.name.clone(), full_key, value, false)
|
|
4511
4604
|
}
|
|
4512
4605
|
|
|
4513
4606
|
pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
use std::process::Command;
|
|
2
|
+
|
|
3
|
+
use feltdb::{AtomicMutation, FeltDb};
|
|
4
|
+
use serde_json::json;
|
|
5
|
+
use tempfile::TempDir;
|
|
6
|
+
|
|
7
|
+
const CHILD_ENV: &str = "FELTDB_OWNERSHIP_CHILD";
|
|
8
|
+
const PATH_ENV: &str = "FELTDB_OWNERSHIP_PATH";
|
|
9
|
+
const EXPECT_LOCKED_ENV: &str = "FELTDB_EXPECT_LOCKED";
|
|
10
|
+
|
|
11
|
+
#[test]
|
|
12
|
+
fn ownership_child() {
|
|
13
|
+
if std::env::var_os(CHILD_ENV).is_none() {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
let path = std::env::var(PATH_ENV).unwrap();
|
|
17
|
+
let expect_locked = std::env::var_os(EXPECT_LOCKED_ENV).is_some();
|
|
18
|
+
match FeltDb::open(path) {
|
|
19
|
+
Ok(database) => {
|
|
20
|
+
assert!(!expect_locked, "a second owner opened the database");
|
|
21
|
+
let mutation = AtomicMutation {
|
|
22
|
+
capability: "probe".into(),
|
|
23
|
+
key: "probe:child".into(),
|
|
24
|
+
value: Some(json!({ "owner": "child" })),
|
|
25
|
+
};
|
|
26
|
+
database
|
|
27
|
+
.apply_atomic_transaction("child", None, &[], &[mutation], None)
|
|
28
|
+
.unwrap();
|
|
29
|
+
}
|
|
30
|
+
Err(error) => {
|
|
31
|
+
assert!(expect_locked, "an unowned database failed to open: {error}");
|
|
32
|
+
assert!(error.to_string().contains("already owned"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn child(path: &std::path::Path, expect_locked: bool) -> std::process::ExitStatus {
|
|
38
|
+
let mut command = Command::new(std::env::current_exe().unwrap());
|
|
39
|
+
command
|
|
40
|
+
.arg("--exact")
|
|
41
|
+
.arg("ownership_child")
|
|
42
|
+
.arg("--nocapture")
|
|
43
|
+
.env(CHILD_ENV, "1")
|
|
44
|
+
.env(PATH_ENV, path);
|
|
45
|
+
if expect_locked {
|
|
46
|
+
command.env(EXPECT_LOCKED_ENV, "1");
|
|
47
|
+
}
|
|
48
|
+
command.status().unwrap()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn another_process_cannot_read_write_or_transact_while_owned() {
|
|
53
|
+
let directory = TempDir::new().unwrap();
|
|
54
|
+
let path = directory.path().join("shared.db");
|
|
55
|
+
let _owner = FeltDb::open(&path).unwrap();
|
|
56
|
+
assert!(child(&path, true).success());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn ownership_is_released_when_the_process_exits() {
|
|
61
|
+
let directory = TempDir::new().unwrap();
|
|
62
|
+
let path = directory.path().join("shared.db");
|
|
63
|
+
assert!(child(&path, false).success());
|
|
64
|
+
let database = FeltDb::open(path).unwrap();
|
|
65
|
+
assert_eq!(
|
|
66
|
+
database.get::<serde_json::Value>("probe:child").unwrap(),
|
|
67
|
+
Some(json!({ "owner": "child" }))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -203,7 +203,7 @@ fn each_compaction_rewrites_the_whole_revision_history() {
|
|
|
203
203
|
/// The compacted log is linear in both the revision count and the state size:
|
|
204
204
|
///
|
|
205
205
|
/// ```text
|
|
206
|
-
/// bytes
|
|
206
|
+
/// bytes ≈ header + count × (fixed_overhead + 2 × |content|)
|
|
207
207
|
/// ```
|
|
208
208
|
///
|
|
209
209
|
/// The fixed part is not incidental — it is a 64-character hex identity, a
|
|
@@ -218,10 +218,11 @@ fn each_compaction_rewrites_the_whole_revision_history() {
|
|
|
218
218
|
/// design.
|
|
219
219
|
#[test]
|
|
220
220
|
fn a_revision_costs_a_fixed_overhead_plus_one_copy_of_its_state() {
|
|
221
|
-
let
|
|
221
|
+
let small_ten_log = compacted_log_after_revisions(10, 0);
|
|
222
|
+
let small_ten = small_ten_log.len();
|
|
222
223
|
let small_twenty = compacted_log_after_revisions(20, 0).len();
|
|
223
|
-
let
|
|
224
|
-
let
|
|
224
|
+
let padded_hundred_log = compacted_log_after_revisions(10, 100);
|
|
225
|
+
let padded_thousand_log = compacted_log_after_revisions(10, 1000);
|
|
225
226
|
|
|
226
227
|
// Ten more revisions of a 16-byte state cost ten more per-revision records.
|
|
227
228
|
// Indices 10..19 are one digit wider than 0..9, hence the ten-byte term.
|
|
@@ -239,17 +240,31 @@ fn a_revision_costs_a_fixed_overhead_plus_one_copy_of_its_state() {
|
|
|
239
240
|
);
|
|
240
241
|
|
|
241
242
|
// Content is represented in both the revision and its canonical
|
|
242
|
-
// transaction attribution
|
|
243
|
-
//
|
|
243
|
+
// transaction attribution. Count those copies directly instead of
|
|
244
|
+
// requiring unrelated encoded metadata to have byte-for-byte identical
|
|
245
|
+
// size across platforms and runs.
|
|
244
246
|
assert_eq!(
|
|
245
|
-
|
|
246
|
-
10 * 2
|
|
247
|
-
"state appears in
|
|
247
|
+
padded_hundred_log.matches("x".repeat(100).as_str()).count(),
|
|
248
|
+
10 * 2,
|
|
249
|
+
"each state appears in its revision and canonical transaction attribution"
|
|
248
250
|
);
|
|
249
251
|
assert_eq!(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
padded_thousand_log
|
|
253
|
+
.matches("x".repeat(1000).as_str())
|
|
254
|
+
.count(),
|
|
255
|
+
10 * 2,
|
|
256
|
+
"the two-copy relationship remains direct at a kilobyte of state"
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
let hundred_growth = padded_hundred_log.len() - small_ten;
|
|
260
|
+
let thousand_growth = padded_thousand_log.len() - small_ten;
|
|
261
|
+
assert!(
|
|
262
|
+
hundred_growth.abs_diff(10 * 2 * 100) <= 100,
|
|
263
|
+
"the measured 100-byte content term was {hundred_growth} bytes"
|
|
264
|
+
);
|
|
265
|
+
assert!(
|
|
266
|
+
thousand_growth.abs_diff(10 * 2 * 1000) <= 1_000,
|
|
267
|
+
"the measured 1,000-byte content term was {thousand_growth} bytes"
|
|
253
268
|
);
|
|
254
269
|
}
|
|
255
270
|
|
|
@@ -13536,17 +13536,19 @@ async fn create_record(
|
|
|
13536
13536
|
.take()
|
|
13537
13537
|
.unwrap_or_else(|| state.ids.fetch_add(1, Ordering::Relaxed).to_string());
|
|
13538
13538
|
let key = record_key(&collection, &id)?;
|
|
13539
|
-
if state.db.get_value(&key)?.is_some() {
|
|
13540
|
-
return Err(ApiError(
|
|
13541
|
-
StatusCode::CONFLICT,
|
|
13542
|
-
"record already exists".to_string(),
|
|
13543
|
-
));
|
|
13544
|
-
}
|
|
13545
13539
|
record
|
|
13546
13540
|
.value
|
|
13547
13541
|
.insert("id".to_string(), Value::String(id.clone()));
|
|
13548
13542
|
let value = Value::Object(record.value);
|
|
13549
|
-
state
|
|
13543
|
+
state
|
|
13544
|
+
.db
|
|
13545
|
+
.insert_if_absent(&key, &value)
|
|
13546
|
+
.map_err(|error| match error {
|
|
13547
|
+
FlowError::PreconditionFailed(_) => {
|
|
13548
|
+
ApiError(StatusCode::CONFLICT, "record already exists".to_string())
|
|
13549
|
+
}
|
|
13550
|
+
other => ApiError(StatusCode::CONFLICT, other.to_string()),
|
|
13551
|
+
})?;
|
|
13550
13552
|
Ok((
|
|
13551
13553
|
StatusCode::CREATED,
|
|
13552
13554
|
Json(RecordResponse {
|
|
@@ -14700,7 +14702,7 @@ async fn control_plane_record_provenance(
|
|
|
14700
14702
|
"attribution": {
|
|
14701
14703
|
"actor": transactions.first().and_then(|attribution| attribution.subject.clone()),
|
|
14702
14704
|
"authority": row.operation.as_ref().map(|operation| operation.instance_id.clone()),
|
|
14703
|
-
"source": if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
|
|
14705
|
+
"source": if capability == collection && row.operation.is_some() { "authority" } else if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
|
|
14704
14706
|
},
|
|
14705
14707
|
})))
|
|
14706
14708
|
}
|