@feltdb/core 0.9.0 → 0.10.0
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/authority-scope.d.ts +2 -0
- package/dist/authority-scope.d.ts.map +1 -1
- package/dist/authority-scope.js +7 -2
- package/dist/cli/commands.js +19 -21
- package/dist/cli/development-session.js +16 -0
- package/dist/cli/studio-artifact.js +33 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
- package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
- package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
- package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
- package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
- package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +443 -77
- package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
- package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
- package/dist/db.d.ts +14 -7
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +50 -26
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +4 -2
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +4 -0
- package/dist/error-codes.d.ts +12 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +20 -2
- package/dist/feltdb.d.ts +4 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +10 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +18 -0
- package/dist/http-db.d.ts +7 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +83 -15
- package/dist/index-core.d.ts +1 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +1 -0
- package/dist/operation-admission.d.ts +2 -0
- package/dist/operation-admission.d.ts.map +1 -1
- package/dist/public-contract.d.ts +112 -0
- package/dist/public-contract.d.ts.map +1 -0
- package/dist/public-contract.js +125 -0
- package/dist/studio/app.d.ts +23 -0
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/ContractInspector.d.ts +5 -0
- package/dist/studio/components/ContractInspector.d.ts.map +1 -1
- package/dist/studio/components/SettingsPanel.d.ts +4 -1
- package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +10 -0
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +3 -0
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/State.d.ts.map +1 -1
- package/dist/studio/control-plane/index.d.ts +4 -2
- package/dist/studio/control-plane/index.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
- package/dist/studio/index.js +96 -75
- package/dist/studio/utils/api.d.ts +1 -1
- package/dist/studio/utils/api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +12 -1
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +1 -1
- package/dist/studio/utils/index.js +16 -16
- package/dist/studio/utils/service-api.d.ts +8 -0
- package/dist/studio/utils/service-api.d.ts.map +1 -1
- package/dist/studio/utils/service-api.js +15 -12
- package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
- package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
- package/dist/studio-app/feltdb-studio.json +6 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +9 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +6 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts +8 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +136 -1
- package/package.json +2 -2
- package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
- package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
- package/dist/studio-app/assets/index-CjELc-ul.js +0 -30
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
//! Durable workload authority. Workers execute; this state machine decides.
|
|
2
|
-
use crate::
|
|
2
|
+
use crate::{
|
|
3
|
+
authorization::{SignedGrant, Subject},
|
|
4
|
+
AtomicMutation, FeltDb, FlowError, RecordPrecondition,
|
|
5
|
+
};
|
|
6
|
+
use rand::{distributions::Alphanumeric, Rng};
|
|
3
7
|
use serde::{Deserialize, Serialize};
|
|
4
8
|
use serde_json::Value;
|
|
5
9
|
use sha2::{Digest, Sha256};
|
|
6
10
|
use std::collections::{BTreeMap, BTreeSet};
|
|
7
|
-
use std::
|
|
8
|
-
use std::path::{Path, PathBuf};
|
|
11
|
+
use std::path::Path;
|
|
9
12
|
pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
|
|
13
|
+
pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
|
|
10
14
|
|
|
11
15
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
12
16
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
@@ -174,6 +178,8 @@ pub struct Workload {
|
|
|
174
178
|
pub input: Value,
|
|
175
179
|
pub input_hash: String,
|
|
176
180
|
pub requested_by: Subject,
|
|
181
|
+
#[serde(default)]
|
|
182
|
+
pub session_id: Option<String>,
|
|
177
183
|
pub authorization_snapshot: SignedGrant,
|
|
178
184
|
pub capability_snapshot: BTreeSet<String>,
|
|
179
185
|
pub connection_scopes: BTreeSet<String>,
|
|
@@ -228,6 +234,8 @@ pub struct CreateWorkload {
|
|
|
228
234
|
pub trigger: String,
|
|
229
235
|
pub input: Value,
|
|
230
236
|
pub requested_by: Subject,
|
|
237
|
+
#[serde(default)]
|
|
238
|
+
pub session_id: Option<String>,
|
|
231
239
|
pub authorization_snapshot: SignedGrant,
|
|
232
240
|
pub capability_snapshot: BTreeSet<String>,
|
|
233
241
|
#[serde(default)]
|
|
@@ -277,12 +285,21 @@ impl std::fmt::Display for WorkloadError {
|
|
|
277
285
|
}
|
|
278
286
|
impl std::error::Error for WorkloadError {}
|
|
279
287
|
|
|
280
|
-
#[derive(
|
|
288
|
+
#[derive(Clone)]
|
|
281
289
|
pub struct WorkloadStore {
|
|
282
|
-
|
|
283
|
-
path: PathBuf,
|
|
284
|
-
#[serde(default)]
|
|
290
|
+
db: FeltDb,
|
|
285
291
|
pub workloads: BTreeMap<String, Workload>,
|
|
292
|
+
idempotency: BTreeMap<String, String>,
|
|
293
|
+
result_ids: BTreeMap<String, String>,
|
|
294
|
+
/// Rebuildable cache of FeltDB-owned document versions. This is never
|
|
295
|
+
/// serialized and never advances independently of a canonical commit.
|
|
296
|
+
document_versions: BTreeMap<String, u64>,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[derive(Serialize, Deserialize)]
|
|
300
|
+
struct LegacyWorkloadStore {
|
|
301
|
+
#[serde(default)]
|
|
302
|
+
workloads: BTreeMap<String, Workload>,
|
|
286
303
|
#[serde(default)]
|
|
287
304
|
idempotency: BTreeMap<String, String>,
|
|
288
305
|
#[serde(default)]
|
|
@@ -290,29 +307,151 @@ pub struct WorkloadStore {
|
|
|
290
307
|
#[serde(default)]
|
|
291
308
|
event_sequence: u64,
|
|
292
309
|
}
|
|
310
|
+
|
|
293
311
|
impl WorkloadStore {
|
|
294
312
|
pub fn load(path: impl AsRef<Path>) -> Result<Self, WorkloadError> {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
313
|
+
Self::from_db(FeltDb::open(path).map_err(io)?)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
|
|
317
|
+
let mut workloads = BTreeMap::new();
|
|
318
|
+
let mut document_versions = BTreeMap::new();
|
|
319
|
+
for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
|
|
320
|
+
let version = row
|
|
321
|
+
.value
|
|
322
|
+
.get("__version")
|
|
323
|
+
.and_then(Value::as_u64)
|
|
324
|
+
// Canonical FeltDB CAS defines a pre-versioned document as
|
|
325
|
+
// version 1. The next guarded mutation writes version 2, which
|
|
326
|
+
// upgrades rows created before Work adopted RecordPrecondition.
|
|
327
|
+
.unwrap_or(1);
|
|
328
|
+
let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
|
|
329
|
+
document_versions.insert(workload.workload_id.clone(), version);
|
|
330
|
+
workloads.insert(workload.workload_id.clone(), workload);
|
|
331
|
+
}
|
|
332
|
+
let mut idempotency = BTreeMap::new();
|
|
333
|
+
let mut result_ids = BTreeMap::new();
|
|
334
|
+
for workload in workloads.values() {
|
|
335
|
+
idempotency.insert(
|
|
336
|
+
format!(
|
|
337
|
+
"{}:{}:{}",
|
|
338
|
+
workload.tenant_id, workload.application_id, workload.idempotency_key
|
|
339
|
+
),
|
|
340
|
+
workload.workload_id.clone(),
|
|
341
|
+
);
|
|
342
|
+
if let Some(result) = &workload.result {
|
|
343
|
+
result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
|
|
344
|
+
}
|
|
304
345
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
346
|
+
Ok(Self {
|
|
347
|
+
db,
|
|
348
|
+
workloads,
|
|
349
|
+
idempotency,
|
|
350
|
+
result_ids,
|
|
351
|
+
document_versions,
|
|
352
|
+
})
|
|
308
353
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
354
|
+
|
|
355
|
+
pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
|
|
356
|
+
let bytes = std::fs::read(path).map_err(io)?;
|
|
357
|
+
let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
|
|
358
|
+
let mut store = Self::from_db(db)?;
|
|
359
|
+
for (id, workload) in legacy.workloads {
|
|
360
|
+
if id != workload.workload_id {
|
|
361
|
+
return Err(WorkloadError::new(
|
|
362
|
+
"WORKLOAD_MIGRATION_CONFLICT",
|
|
363
|
+
format!(
|
|
364
|
+
"legacy key {id} does not match workload identity {}",
|
|
365
|
+
workload.workload_id
|
|
366
|
+
),
|
|
367
|
+
));
|
|
368
|
+
}
|
|
369
|
+
if let Some(existing) = store.workloads.get(&id) {
|
|
370
|
+
if serde_json::to_value(existing).map_err(io)?
|
|
371
|
+
!= serde_json::to_value(&workload).map_err(io)?
|
|
372
|
+
{
|
|
373
|
+
return Err(WorkloadError::new(
|
|
374
|
+
"WORKLOAD_MIGRATION_CONFLICT",
|
|
375
|
+
format!("canonical workload {id} differs from legacy source"),
|
|
376
|
+
));
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
store.workloads.insert(id.clone(), workload);
|
|
381
|
+
store.persist_workload(&id, "workload_migration")?;
|
|
312
382
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
383
|
+
// The maps are verification evidence, not migrated authorities.
|
|
384
|
+
let rebuilt = Self::from_db(store.db.clone())?;
|
|
385
|
+
if legacy.idempotency != rebuilt.idempotency
|
|
386
|
+
|| legacy.result_ids != rebuilt.result_ids
|
|
387
|
+
|| legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
|
|
388
|
+
{
|
|
389
|
+
return Err(WorkloadError::new(
|
|
390
|
+
"WORKLOAD_MIGRATION_CONFLICT",
|
|
391
|
+
"legacy derived indexes do not match canonical work aggregates",
|
|
392
|
+
));
|
|
393
|
+
}
|
|
394
|
+
Ok(rebuilt)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fn persist_workload(&mut self, id: &str, action: &str) -> Result<(), WorkloadError> {
|
|
398
|
+
let workload = self
|
|
399
|
+
.workloads
|
|
400
|
+
.get(id)
|
|
401
|
+
.ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
|
|
402
|
+
let key = work_key(workload);
|
|
403
|
+
let expected_version = self.document_versions.get(id).copied();
|
|
404
|
+
let transaction_id = runtime_id("work_tx");
|
|
405
|
+
let aggregate_digest = hash(&serde_json::to_value(workload).map_err(io)?);
|
|
406
|
+
let commit = self
|
|
407
|
+
.db
|
|
408
|
+
.apply_atomic_transaction_guarded(
|
|
409
|
+
&transaction_id,
|
|
410
|
+
None,
|
|
411
|
+
None,
|
|
412
|
+
&[],
|
|
413
|
+
&[RecordPrecondition {
|
|
414
|
+
capability: DURABLE_WORK_COLLECTION.into(),
|
|
415
|
+
key: key.clone(),
|
|
416
|
+
require_absent: expected_version.is_none(),
|
|
417
|
+
expected_version,
|
|
418
|
+
expected_epoch: None,
|
|
419
|
+
expected_lease_id: None,
|
|
420
|
+
}],
|
|
421
|
+
&[AtomicMutation {
|
|
422
|
+
capability: DURABLE_WORK_COLLECTION.into(),
|
|
423
|
+
key,
|
|
424
|
+
value: Some(serde_json::to_value(workload).map_err(io)?),
|
|
425
|
+
}],
|
|
426
|
+
Some(serde_json::json!({
|
|
427
|
+
"kind": action,
|
|
428
|
+
"subject": workload.requested_by.id(),
|
|
429
|
+
"principal_id": workload.requested_by.id(),
|
|
430
|
+
"agent_id": (workload.requested_by.kind() == "agent").then(|| workload.requested_by.id()),
|
|
431
|
+
"session_id": workload.session_id,
|
|
432
|
+
"delegator_id": workload.authorization_snapshot.grant.issuer.id(),
|
|
433
|
+
"authorization_decision_id": workload.authorization_snapshot.grant.id,
|
|
434
|
+
"tenant": workload.tenant_id,
|
|
435
|
+
"application": workload.application_id,
|
|
436
|
+
"work_id": workload.workload_id,
|
|
437
|
+
"operation_id": transaction_id,
|
|
438
|
+
"attempt_id": workload.attempts.last().map(|attempt| attempt.attempt_id.as_str()),
|
|
439
|
+
"worker_id": workload.current_claim.as_ref().map(|claim| claim.worker_id.as_str()),
|
|
440
|
+
"result_id": workload.result.as_ref().map(|result| result.result_id.as_str()),
|
|
441
|
+
"source_kind": (action == "workload_migration").then_some("WorkloadStore"),
|
|
442
|
+
"source_digest": aggregate_digest,
|
|
443
|
+
"migration_outcome": (action == "workload_migration").then_some("imported"),
|
|
444
|
+
})),
|
|
445
|
+
)
|
|
446
|
+
.map_err(workload_storage_error)?;
|
|
447
|
+
let version = commit
|
|
448
|
+
.rows
|
|
449
|
+
.first()
|
|
450
|
+
.and_then(|row| row.value.get("__version"))
|
|
451
|
+
.and_then(Value::as_u64)
|
|
452
|
+
.ok_or_else(|| io("canonical work commit has no document version"))?;
|
|
453
|
+
self.document_versions.insert(id.into(), version);
|
|
454
|
+
Ok(())
|
|
316
455
|
}
|
|
317
456
|
pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
|
|
318
457
|
ensure_safe(&input.input)?;
|
|
@@ -338,12 +477,11 @@ impl WorkloadStore {
|
|
|
338
477
|
"authorization snapshot does not bind the requester",
|
|
339
478
|
));
|
|
340
479
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
);
|
|
346
|
-
let id = format!("workload_{}", &hex(&Sha256::digest(seed))[..24]);
|
|
480
|
+
// The scoped idempotency identity is the record identity. Competing
|
|
481
|
+
// admissions therefore contend on one canonical require-absent CAS
|
|
482
|
+
// even when their payloads differ; no separate durable uniqueness
|
|
483
|
+
// index is needed.
|
|
484
|
+
let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
|
|
347
485
|
let input_hash = hash(&input.input);
|
|
348
486
|
let mut workload = Workload {
|
|
349
487
|
contract_version: 1,
|
|
@@ -357,6 +495,7 @@ impl WorkloadStore {
|
|
|
357
495
|
input: input.input,
|
|
358
496
|
input_hash,
|
|
359
497
|
requested_by: input.requested_by,
|
|
498
|
+
session_id: input.session_id,
|
|
360
499
|
authorization_snapshot: input.authorization_snapshot,
|
|
361
500
|
capability_snapshot: input.capability_snapshot,
|
|
362
501
|
connection_scopes: input.connection_scopes,
|
|
@@ -396,9 +535,13 @@ impl WorkloadStore {
|
|
|
396
535
|
input.created_at,
|
|
397
536
|
evidence,
|
|
398
537
|
)?;
|
|
399
|
-
self.idempotency.insert(idempotency, id.clone());
|
|
400
|
-
self.workloads.insert(id, workload.clone());
|
|
401
|
-
self.
|
|
538
|
+
self.idempotency.insert(idempotency.clone(), id.clone());
|
|
539
|
+
self.workloads.insert(id.clone(), workload.clone());
|
|
540
|
+
if let Err(error) = self.persist_workload(&id, "workload.created") {
|
|
541
|
+
self.workloads.remove(&id);
|
|
542
|
+
self.idempotency.remove(&idempotency);
|
|
543
|
+
return Err(error);
|
|
544
|
+
}
|
|
402
545
|
Ok(workload)
|
|
403
546
|
}
|
|
404
547
|
pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
|
|
@@ -421,16 +564,19 @@ impl WorkloadStore {
|
|
|
421
564
|
id: &str,
|
|
422
565
|
action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
|
|
423
566
|
) -> Result<R, WorkloadError> {
|
|
424
|
-
let
|
|
567
|
+
let workload = self
|
|
425
568
|
.workloads
|
|
426
|
-
.
|
|
569
|
+
.get(id)
|
|
570
|
+
.cloned()
|
|
427
571
|
.ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
|
|
428
|
-
let
|
|
429
|
-
self
|
|
430
|
-
|
|
431
|
-
|
|
572
|
+
let mut edited = workload.clone();
|
|
573
|
+
let result = action(self, &mut edited)?;
|
|
574
|
+
self.workloads.insert(id.into(), edited);
|
|
575
|
+
if let Err(error) = self.persist_workload(id, "workload.mutated") {
|
|
576
|
+
self.workloads.insert(id.into(), workload);
|
|
577
|
+
return Err(error);
|
|
432
578
|
}
|
|
433
|
-
result
|
|
579
|
+
Ok(result)
|
|
434
580
|
}
|
|
435
581
|
fn transition(
|
|
436
582
|
&mut self,
|
|
@@ -449,9 +595,12 @@ impl WorkloadStore {
|
|
|
449
595
|
let from = w.state;
|
|
450
596
|
w.state = to;
|
|
451
597
|
w.updated_at = at;
|
|
452
|
-
|
|
598
|
+
let sequence = w
|
|
599
|
+
.execution_history
|
|
600
|
+
.last()
|
|
601
|
+
.map_or(1, |event| event.sequence + 1);
|
|
453
602
|
w.execution_history.push(WorkloadEvent {
|
|
454
|
-
sequence
|
|
603
|
+
sequence,
|
|
455
604
|
event_type: event_name(to).into(),
|
|
456
605
|
from: Some(from),
|
|
457
606
|
to,
|
|
@@ -730,7 +879,6 @@ impl WorkloadStore {
|
|
|
730
879
|
Ok(result)
|
|
731
880
|
})?;
|
|
732
881
|
self.result_ids.insert(result_id.into(), id.into());
|
|
733
|
-
self.persist()?;
|
|
734
882
|
Ok(result)
|
|
735
883
|
}
|
|
736
884
|
pub fn fail(
|
|
@@ -1111,9 +1259,42 @@ fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
|
|
|
1111
1259
|
fn sensitive_text(v: &str) -> bool {
|
|
1112
1260
|
v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
|
|
1113
1261
|
}
|
|
1262
|
+
fn work_key(workload: &Workload) -> String {
|
|
1263
|
+
format!(
|
|
1264
|
+
"{}/{}/{}",
|
|
1265
|
+
workload.tenant_id, workload.application_id, workload.workload_id
|
|
1266
|
+
)
|
|
1267
|
+
}
|
|
1268
|
+
fn derived_event_sequence(workloads: &BTreeMap<String, Workload>) -> u64 {
|
|
1269
|
+
workloads
|
|
1270
|
+
.values()
|
|
1271
|
+
.flat_map(|workload| workload.execution_history.iter())
|
|
1272
|
+
.map(|event| event.sequence)
|
|
1273
|
+
.max()
|
|
1274
|
+
.unwrap_or_default()
|
|
1275
|
+
}
|
|
1276
|
+
fn runtime_id(prefix: &str) -> String {
|
|
1277
|
+
let value: String = rand::thread_rng()
|
|
1278
|
+
.sample_iter(&Alphanumeric)
|
|
1279
|
+
.take(24)
|
|
1280
|
+
.map(char::from)
|
|
1281
|
+
.collect();
|
|
1282
|
+
format!("{prefix}_{value}")
|
|
1283
|
+
}
|
|
1114
1284
|
fn io(e: impl std::fmt::Display) -> WorkloadError {
|
|
1115
1285
|
WorkloadError::new("WORKLOAD_STORAGE_FAILURE", e.to_string())
|
|
1116
1286
|
}
|
|
1287
|
+
fn workload_storage_error(error: FlowError) -> WorkloadError {
|
|
1288
|
+
match error {
|
|
1289
|
+
FlowError::PreconditionFailed(_) | FlowError::RevisionConflict { .. } => {
|
|
1290
|
+
WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
|
|
1291
|
+
}
|
|
1292
|
+
FlowError::CapabilityError(ref message) if message.starts_with("PRECONDITION_FAILED:") => {
|
|
1293
|
+
WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
|
|
1294
|
+
}
|
|
1295
|
+
_ => io(error),
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1117
1298
|
fn hash(value: &Value) -> String {
|
|
1118
1299
|
hex(&Sha256::digest(
|
|
1119
1300
|
serde_json::to_vec(value).unwrap_or_default(),
|
|
@@ -1129,6 +1310,7 @@ mod tests {
|
|
|
1129
1310
|
use crate::authorization::{
|
|
1130
1311
|
Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
|
|
1131
1312
|
};
|
|
1313
|
+
use std::{fs, path::PathBuf};
|
|
1132
1314
|
fn create() -> CreateWorkload {
|
|
1133
1315
|
let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
|
|
1134
1316
|
let subject = Subject::Agent("a1".into());
|
|
@@ -1160,6 +1342,7 @@ mod tests {
|
|
|
1160
1342
|
trigger: "manual".into(),
|
|
1161
1343
|
input: serde_json::json!({"id":1}),
|
|
1162
1344
|
requested_by: subject,
|
|
1345
|
+
session_id: Some("session-1".into()),
|
|
1163
1346
|
authorization_snapshot: signer.sign(grant).unwrap(),
|
|
1164
1347
|
capability_snapshot: ["email:send".into()].into(),
|
|
1165
1348
|
connection_scopes: BTreeSet::new(),
|
|
@@ -1179,6 +1362,259 @@ mod tests {
|
|
|
1179
1362
|
(path, store)
|
|
1180
1363
|
}
|
|
1181
1364
|
#[test]
|
|
1365
|
+
fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
|
|
1366
|
+
let (source_db, mut source) = store("legacy-source-builder");
|
|
1367
|
+
let workload = source.create(create()).unwrap();
|
|
1368
|
+
let legacy = LegacyWorkloadStore {
|
|
1369
|
+
workloads: source.workloads.clone(),
|
|
1370
|
+
idempotency: source.idempotency.clone(),
|
|
1371
|
+
result_ids: source.result_ids.clone(),
|
|
1372
|
+
event_sequence: derived_event_sequence(&source.workloads),
|
|
1373
|
+
};
|
|
1374
|
+
let legacy_path = source_db.with_extension("legacy-workloads.json");
|
|
1375
|
+
fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
|
1376
|
+
let canonical_path = source_db.with_extension("canonical.log");
|
|
1377
|
+
let db = FeltDb::open(&canonical_path).unwrap();
|
|
1378
|
+
let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
|
|
1379
|
+
assert_eq!(
|
|
1380
|
+
migrated.get(&workload.workload_id).unwrap().input_hash,
|
|
1381
|
+
workload.input_hash
|
|
1382
|
+
);
|
|
1383
|
+
WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
|
|
1384
|
+
fs::remove_file(&legacy_path).unwrap();
|
|
1385
|
+
let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
|
|
1386
|
+
assert_eq!(
|
|
1387
|
+
reopened.get(&workload.workload_id).unwrap().workload_id,
|
|
1388
|
+
workload.workload_id
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
let mut conflicting = legacy;
|
|
1392
|
+
conflicting
|
|
1393
|
+
.workloads
|
|
1394
|
+
.get_mut(&workload.workload_id)
|
|
1395
|
+
.unwrap()
|
|
1396
|
+
.priority += 1;
|
|
1397
|
+
fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
|
|
1398
|
+
assert_eq!(
|
|
1399
|
+
WorkloadStore::migrate_legacy_file(db, &legacy_path)
|
|
1400
|
+
.err()
|
|
1401
|
+
.unwrap()
|
|
1402
|
+
.code,
|
|
1403
|
+
"WORKLOAD_MIGRATION_CONFLICT"
|
|
1404
|
+
);
|
|
1405
|
+
let _ = fs::remove_file(source_db);
|
|
1406
|
+
let _ = fs::remove_file(canonical_path);
|
|
1407
|
+
let _ = fs::remove_file(legacy_path);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
#[test]
|
|
1411
|
+
fn canonical_history_reconstructs_work_identity_provenance() {
|
|
1412
|
+
let (path, mut store) = store("provenance");
|
|
1413
|
+
let workload = store.create(create()).unwrap();
|
|
1414
|
+
let transaction = store
|
|
1415
|
+
.db
|
|
1416
|
+
.committed_transactions_after(0, usize::MAX)
|
|
1417
|
+
.unwrap()
|
|
1418
|
+
.into_iter()
|
|
1419
|
+
.last()
|
|
1420
|
+
.unwrap();
|
|
1421
|
+
let audit = transaction.audit.unwrap();
|
|
1422
|
+
assert_eq!(audit["work_id"], workload.workload_id);
|
|
1423
|
+
assert_eq!(audit["session_id"], workload.session_id.as_deref().unwrap());
|
|
1424
|
+
assert_eq!(audit["agent_id"], workload.requested_by.id());
|
|
1425
|
+
assert_eq!(
|
|
1426
|
+
audit["delegator_id"],
|
|
1427
|
+
workload.authorization_snapshot.grant.issuer.id()
|
|
1428
|
+
);
|
|
1429
|
+
assert!(transaction.commit_revision > transaction.base_revision);
|
|
1430
|
+
assert_eq!(transaction.work_id.as_deref(), Some(workload.workload_id.as_str()));
|
|
1431
|
+
assert_eq!(transaction.agent_id.as_deref(), Some(workload.requested_by.id()));
|
|
1432
|
+
assert_eq!(transaction.session_id, workload.session_id);
|
|
1433
|
+
assert_eq!(
|
|
1434
|
+
transaction.delegator_id.as_deref(),
|
|
1435
|
+
Some(workload.authorization_snapshot.grant.issuer.id())
|
|
1436
|
+
);
|
|
1437
|
+
assert_eq!(transaction.operation_ids.len(), 1);
|
|
1438
|
+
assert_ne!(transaction.operation_ids[0], transaction.transaction_id);
|
|
1439
|
+
drop(store);
|
|
1440
|
+
let reopened = WorkloadStore::load(&path).unwrap();
|
|
1441
|
+
assert_eq!(
|
|
1442
|
+
reopened.get(&workload.workload_id).unwrap().workload_id,
|
|
1443
|
+
workload.workload_id
|
|
1444
|
+
);
|
|
1445
|
+
let _ = fs::remove_file(path);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
#[test]
|
|
1449
|
+
fn competing_writers_use_canonical_document_cas_before_and_after_restart() {
|
|
1450
|
+
let (path, mut seed) = store("canonical-cas");
|
|
1451
|
+
let workload = seed.create(create()).unwrap();
|
|
1452
|
+
let id = workload.workload_id.clone();
|
|
1453
|
+
let db = seed.db.clone();
|
|
1454
|
+
let caps = ["email:send".into()].into();
|
|
1455
|
+
|
|
1456
|
+
let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1457
|
+
let mut loser = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1458
|
+
winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
|
|
1459
|
+
let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
|
|
1460
|
+
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
1461
|
+
assert_eq!(loser.get(&id).unwrap().state, WorkloadState::Ready);
|
|
1462
|
+
|
|
1463
|
+
let row = db
|
|
1464
|
+
.list_collection(DURABLE_WORK_COLLECTION)
|
|
1465
|
+
.unwrap()
|
|
1466
|
+
.into_iter()
|
|
1467
|
+
.find(|row| row.key.ends_with(&id))
|
|
1468
|
+
.unwrap();
|
|
1469
|
+
assert_eq!(row.value["__version"], 2);
|
|
1470
|
+
|
|
1471
|
+
drop((seed, winner, loser, db));
|
|
1472
|
+
let restarted_db = FeltDb::open(&path).unwrap();
|
|
1473
|
+
let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
1474
|
+
recovery.recover_expired(13).unwrap();
|
|
1475
|
+
let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
1476
|
+
let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
1477
|
+
restarted_winner
|
|
1478
|
+
.claim(&id, "worker-c", &caps, 13, 10_000)
|
|
1479
|
+
.unwrap();
|
|
1480
|
+
let conflict = restarted_loser
|
|
1481
|
+
.claim(&id, "worker-d", &caps, 13, 10_000)
|
|
1482
|
+
.unwrap_err();
|
|
1483
|
+
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
1484
|
+
assert_eq!(
|
|
1485
|
+
restarted_loser.get(&id).unwrap().state,
|
|
1486
|
+
WorkloadState::Ready
|
|
1487
|
+
);
|
|
1488
|
+
drop((recovery, restarted_winner, restarted_loser, restarted_db));
|
|
1489
|
+
let _ = fs::remove_file(path);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
#[test]
|
|
1493
|
+
fn competing_admissions_share_one_canonical_idempotency_identity() {
|
|
1494
|
+
let path = std::env::temp_dir().join(format!(
|
|
1495
|
+
"workload-idempotency-cas-{}.log",
|
|
1496
|
+
std::process::id()
|
|
1497
|
+
));
|
|
1498
|
+
let db = FeltDb::open(&path).unwrap();
|
|
1499
|
+
let mut first = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1500
|
+
let mut second = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1501
|
+
let accepted = first.create(create()).unwrap();
|
|
1502
|
+
let mut competing = create();
|
|
1503
|
+
competing.input = serde_json::json!({"id": 2});
|
|
1504
|
+
let conflict = second.create(competing).unwrap_err();
|
|
1505
|
+
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
1506
|
+
assert!(
|
|
1507
|
+
second.workloads.is_empty(),
|
|
1508
|
+
"failed projection was rolled back"
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
drop((first, second, db));
|
|
1512
|
+
let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
|
|
1513
|
+
assert_eq!(restarted.workloads.len(), 1);
|
|
1514
|
+
assert_eq!(restarted.idempotency["t:app:one"], accepted.workload_id);
|
|
1515
|
+
let _ = fs::remove_file(path);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
#[test]
|
|
1519
|
+
fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
|
|
1520
|
+
let (path, mut store) = store("pre-versioned-upgrade");
|
|
1521
|
+
let workload = store.create(create()).unwrap();
|
|
1522
|
+
let db = store.db.clone();
|
|
1523
|
+
db.apply_atomic_transaction(
|
|
1524
|
+
"fixture:pre-versioned-work",
|
|
1525
|
+
None,
|
|
1526
|
+
&[],
|
|
1527
|
+
&[AtomicMutation {
|
|
1528
|
+
capability: DURABLE_WORK_COLLECTION.into(),
|
|
1529
|
+
key: work_key(&workload),
|
|
1530
|
+
value: Some(serde_json::to_value(&workload).unwrap()),
|
|
1531
|
+
}],
|
|
1532
|
+
None,
|
|
1533
|
+
)
|
|
1534
|
+
.unwrap();
|
|
1535
|
+
|
|
1536
|
+
let mut upgraded = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1537
|
+
upgraded
|
|
1538
|
+
.claim(
|
|
1539
|
+
&workload.workload_id,
|
|
1540
|
+
"worker",
|
|
1541
|
+
&["email:send".into()].into(),
|
|
1542
|
+
11,
|
|
1543
|
+
10_000,
|
|
1544
|
+
)
|
|
1545
|
+
.unwrap();
|
|
1546
|
+
let row = db
|
|
1547
|
+
.list_collection(DURABLE_WORK_COLLECTION)
|
|
1548
|
+
.unwrap()
|
|
1549
|
+
.into_iter()
|
|
1550
|
+
.find(|row| row.key.ends_with(&workload.workload_id))
|
|
1551
|
+
.unwrap();
|
|
1552
|
+
assert_eq!(row.value["__version"], 2);
|
|
1553
|
+
let _ = fs::remove_file(path);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
#[test]
|
|
1557
|
+
fn derived_indexes_rebuild_exclusively_from_canonical_work() {
|
|
1558
|
+
let (path, mut store) = store("derived-index-rebuild");
|
|
1559
|
+
let workload = store.create(create()).unwrap();
|
|
1560
|
+
let caps = ["email:send".into()].into();
|
|
1561
|
+
let claim = store
|
|
1562
|
+
.claim(&workload.workload_id, "worker", &caps, 11, 10_000)
|
|
1563
|
+
.unwrap();
|
|
1564
|
+
store
|
|
1565
|
+
.start(
|
|
1566
|
+
&workload.workload_id,
|
|
1567
|
+
&claim.claim_id,
|
|
1568
|
+
"worker",
|
|
1569
|
+
claim.fencing_token,
|
|
1570
|
+
12,
|
|
1571
|
+
)
|
|
1572
|
+
.unwrap();
|
|
1573
|
+
store
|
|
1574
|
+
.complete(
|
|
1575
|
+
&workload.workload_id,
|
|
1576
|
+
&claim.claim_id,
|
|
1577
|
+
"worker",
|
|
1578
|
+
claim.fencing_token,
|
|
1579
|
+
"result-rebuilt",
|
|
1580
|
+
serde_json::json!({"ok": true}),
|
|
1581
|
+
vec![],
|
|
1582
|
+
1,
|
|
1583
|
+
13,
|
|
1584
|
+
)
|
|
1585
|
+
.unwrap();
|
|
1586
|
+
|
|
1587
|
+
store.idempotency.clear();
|
|
1588
|
+
store.result_ids.clear();
|
|
1589
|
+
store.document_versions.clear();
|
|
1590
|
+
let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
|
|
1591
|
+
assert_eq!(
|
|
1592
|
+
rebuilt.idempotency.get("t:app:one"),
|
|
1593
|
+
Some(&workload.workload_id)
|
|
1594
|
+
);
|
|
1595
|
+
assert_eq!(
|
|
1596
|
+
rebuilt.result_ids.get("result-rebuilt"),
|
|
1597
|
+
Some(&workload.workload_id)
|
|
1598
|
+
);
|
|
1599
|
+
assert!(rebuilt.document_versions[&workload.workload_id] > 1);
|
|
1600
|
+
assert_eq!(
|
|
1601
|
+
rebuilt
|
|
1602
|
+
.get(&workload.workload_id)
|
|
1603
|
+
.unwrap()
|
|
1604
|
+
.execution_history
|
|
1605
|
+
.iter()
|
|
1606
|
+
.map(|event| event.sequence)
|
|
1607
|
+
.collect::<Vec<_>>(),
|
|
1608
|
+
vec![1, 2, 3, 4]
|
|
1609
|
+
);
|
|
1610
|
+
let duplicate = WorkloadStore::from_db(store.db.clone())
|
|
1611
|
+
.unwrap()
|
|
1612
|
+
.create(create())
|
|
1613
|
+
.unwrap();
|
|
1614
|
+
assert_eq!(duplicate.workload_id, workload.workload_id);
|
|
1615
|
+
let _ = fs::remove_file(path);
|
|
1616
|
+
}
|
|
1617
|
+
#[test]
|
|
1182
1618
|
fn idempotent_create_and_legal_lifecycle() {
|
|
1183
1619
|
let (path, mut store) = store("lifecycle");
|
|
1184
1620
|
let one = store.create(create()).unwrap();
|