@feltdb/core 0.5.7 → 0.6.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/cli/commands.js +68 -1
- package/dist/cli/workspace-integration.js +96 -0
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +21 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +10 -0
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -1
- package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
- package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
- package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
- package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +9 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +157 -9
- package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
- package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
- package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +49 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
- package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
- package/dist/create/template/dot-feltdb-README.md +58 -0
- package/dist/create/workspace-initialization.js +77 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/studio-app/assets/{feltdb_wasm-h9mxesnH.js → feltdb_wasm-B4wq4mqp.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-Ceyi7l21.wasm +0 -0
- package/dist/studio-app/assets/{index-B_EMnTaE.js → index-LQmvJSq6.js} +2 -2
- package/dist/studio-app/index.html +1 -1
- package/dist/telemetry.js +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/development-node.d.ts +42 -0
- package/dist/workspace/development-node.d.ts.map +1 -0
- package/dist/workspace/development-node.js +208 -0
- package/dist/workspace/index.d.ts +20 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +16 -0
- package/dist/workspace/workspace-connection.d.ts +174 -0
- package/dist/workspace/workspace-connection.d.ts.map +1 -0
- package/dist/workspace/workspace-connection.js +290 -0
- package/dist/workspace/workspace-identity.d.ts +13 -0
- package/dist/workspace/workspace-identity.d.ts.map +1 -0
- package/dist/workspace/workspace-identity.js +70 -0
- package/dist/workspace/workspace-types.d.ts +82 -0
- package/dist/workspace/workspace-types.d.ts.map +1 -0
- package/dist/workspace/workspace-types.js +7 -0
- package/package.json +5 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
use std::collections::HashMap;
|
|
4
|
+
use std::sync::{Arc, RwLock};
|
|
5
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
6
|
+
|
|
7
|
+
/// Operation state lifecycle.
|
|
8
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
9
|
+
#[serde(rename_all = "lowercase")]
|
|
10
|
+
pub enum OperationState {
|
|
11
|
+
/// Operation created, awaiting worker claim.
|
|
12
|
+
Pending,
|
|
13
|
+
/// Operation claimed by a worker (lease active).
|
|
14
|
+
Claimed,
|
|
15
|
+
/// Operation being executed by worker.
|
|
16
|
+
Running,
|
|
17
|
+
/// Operation completed successfully.
|
|
18
|
+
Completed,
|
|
19
|
+
/// Operation failed.
|
|
20
|
+
Failed,
|
|
21
|
+
/// Operation state unknown (after lease expiration).
|
|
22
|
+
Unknown,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Durable operation for work coordination and recovery.
|
|
26
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
27
|
+
pub struct DurableOperation {
|
|
28
|
+
/// Unique operation ID.
|
|
29
|
+
pub operation_id: String,
|
|
30
|
+
|
|
31
|
+
/// Type of operation (defines behavior/retry policy).
|
|
32
|
+
pub operation_type: String,
|
|
33
|
+
|
|
34
|
+
/// Tenant ID for isolation.
|
|
35
|
+
pub tenant_id: String,
|
|
36
|
+
|
|
37
|
+
/// Current state of the operation.
|
|
38
|
+
pub state: OperationState,
|
|
39
|
+
|
|
40
|
+
/// Worker claiming the operation (if claimed).
|
|
41
|
+
pub worker_id: Option<String>,
|
|
42
|
+
|
|
43
|
+
/// Lease expiration time (unix timestamp).
|
|
44
|
+
pub lease_expires_at: u64,
|
|
45
|
+
|
|
46
|
+
/// When operation was created.
|
|
47
|
+
pub created_at: u64,
|
|
48
|
+
|
|
49
|
+
/// Last state change time.
|
|
50
|
+
pub updated_at: u64,
|
|
51
|
+
|
|
52
|
+
/// Operation payload (caller-defined work).
|
|
53
|
+
pub payload: Value,
|
|
54
|
+
|
|
55
|
+
/// Result (set on completion).
|
|
56
|
+
pub result: Option<Value>,
|
|
57
|
+
|
|
58
|
+
/// Error reason (set on failure).
|
|
59
|
+
pub error: Option<String>,
|
|
60
|
+
|
|
61
|
+
/// Version for optimistic concurrency control.
|
|
62
|
+
pub version: u64,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Request to create a durable operation.
|
|
66
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
67
|
+
pub struct CreateOperationRequest {
|
|
68
|
+
pub operation_id: String,
|
|
69
|
+
pub operation_type: String,
|
|
70
|
+
pub tenant_id: String,
|
|
71
|
+
pub payload: Value,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Request to claim an operation.
|
|
75
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
76
|
+
pub struct ClaimOperationRequest {
|
|
77
|
+
pub operation_id: String,
|
|
78
|
+
pub worker_id: String,
|
|
79
|
+
pub lease_duration_secs: u64,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Response with operation after claiming.
|
|
83
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
84
|
+
pub struct ClaimOperationResponse {
|
|
85
|
+
pub operation: DurableOperation,
|
|
86
|
+
pub version: u64,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Store for durable operations.
|
|
90
|
+
#[derive(Clone)]
|
|
91
|
+
pub struct DurableOperationStore {
|
|
92
|
+
operations: Arc<RwLock<HashMap<String, DurableOperation>>>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl DurableOperationStore {
|
|
96
|
+
/// Creates a new durable operation store.
|
|
97
|
+
pub fn new() -> Self {
|
|
98
|
+
Self {
|
|
99
|
+
operations: Arc::new(RwLock::new(HashMap::new())),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Creates a new durable operation.
|
|
104
|
+
pub fn create_operation(
|
|
105
|
+
&self,
|
|
106
|
+
operation_id: String,
|
|
107
|
+
operation_type: String,
|
|
108
|
+
tenant_id: String,
|
|
109
|
+
payload: Value,
|
|
110
|
+
) -> Result<DurableOperation, String> {
|
|
111
|
+
let mut ops = self
|
|
112
|
+
.operations
|
|
113
|
+
.write()
|
|
114
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
115
|
+
|
|
116
|
+
if ops.contains_key(&operation_id) {
|
|
117
|
+
return Err(format!(
|
|
118
|
+
"operation already exists: {}",
|
|
119
|
+
operation_id
|
|
120
|
+
));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let now = now();
|
|
124
|
+
let operation = DurableOperation {
|
|
125
|
+
operation_id: operation_id.clone(),
|
|
126
|
+
operation_type,
|
|
127
|
+
tenant_id,
|
|
128
|
+
state: OperationState::Pending,
|
|
129
|
+
worker_id: None,
|
|
130
|
+
lease_expires_at: 0,
|
|
131
|
+
created_at: now,
|
|
132
|
+
updated_at: now,
|
|
133
|
+
payload,
|
|
134
|
+
result: None,
|
|
135
|
+
error: None,
|
|
136
|
+
version: 1,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
ops.insert(operation_id, operation.clone());
|
|
140
|
+
Ok(operation)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Claims an operation for a worker.
|
|
144
|
+
/// Returns the claimed operation and current version for optimistic concurrency.
|
|
145
|
+
pub fn claim_operation(
|
|
146
|
+
&self,
|
|
147
|
+
operation_id: &str,
|
|
148
|
+
worker_id: String,
|
|
149
|
+
lease_duration_secs: u64,
|
|
150
|
+
) -> Result<ClaimOperationResponse, String> {
|
|
151
|
+
let mut ops = self
|
|
152
|
+
.operations
|
|
153
|
+
.write()
|
|
154
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
155
|
+
|
|
156
|
+
let operation = ops
|
|
157
|
+
.get_mut(operation_id)
|
|
158
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
159
|
+
|
|
160
|
+
// Can only claim Pending or Unknown operations
|
|
161
|
+
if operation.state != OperationState::Pending && operation.state != OperationState::Unknown {
|
|
162
|
+
return Err(format!(
|
|
163
|
+
"cannot claim operation in state {:?}",
|
|
164
|
+
operation.state
|
|
165
|
+
));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let now = now();
|
|
169
|
+
|
|
170
|
+
// Transition to Claimed state
|
|
171
|
+
operation.state = OperationState::Claimed;
|
|
172
|
+
operation.worker_id = Some(worker_id);
|
|
173
|
+
operation.lease_expires_at = now + lease_duration_secs;
|
|
174
|
+
operation.updated_at = now;
|
|
175
|
+
operation.version += 1;
|
|
176
|
+
|
|
177
|
+
Ok(ClaimOperationResponse {
|
|
178
|
+
operation: operation.clone(),
|
|
179
|
+
version: operation.version,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Renews a worker's lease on an operation (extends expiration).
|
|
184
|
+
/// Requires the worker to prove ownership.
|
|
185
|
+
pub fn renew_operation(
|
|
186
|
+
&self,
|
|
187
|
+
operation_id: &str,
|
|
188
|
+
worker_id: &str,
|
|
189
|
+
lease_duration_secs: u64,
|
|
190
|
+
) -> Result<DurableOperation, String> {
|
|
191
|
+
let mut ops = self
|
|
192
|
+
.operations
|
|
193
|
+
.write()
|
|
194
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
195
|
+
|
|
196
|
+
let operation = ops
|
|
197
|
+
.get_mut(operation_id)
|
|
198
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
199
|
+
|
|
200
|
+
let now = now();
|
|
201
|
+
|
|
202
|
+
// Check if lease has expired (worker fencing)
|
|
203
|
+
if operation.lease_expires_at < now {
|
|
204
|
+
return Err(format!(
|
|
205
|
+
"worker lease expired for operation: {}",
|
|
206
|
+
operation_id
|
|
207
|
+
));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Verify worker ownership
|
|
211
|
+
if operation.worker_id.as_deref() != Some(worker_id) {
|
|
212
|
+
return Err(format!(
|
|
213
|
+
"worker mismatch: expected {:?}, got {}",
|
|
214
|
+
operation.worker_id, worker_id
|
|
215
|
+
));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extend lease
|
|
219
|
+
operation.lease_expires_at = now + lease_duration_secs;
|
|
220
|
+
operation.updated_at = now;
|
|
221
|
+
operation.version += 1;
|
|
222
|
+
|
|
223
|
+
Ok(operation.clone())
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Marks an operation as Running.
|
|
227
|
+
pub fn start_operation(
|
|
228
|
+
&self,
|
|
229
|
+
operation_id: &str,
|
|
230
|
+
worker_id: &str,
|
|
231
|
+
) -> Result<DurableOperation, String> {
|
|
232
|
+
let mut ops = self
|
|
233
|
+
.operations
|
|
234
|
+
.write()
|
|
235
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
236
|
+
|
|
237
|
+
let operation = ops
|
|
238
|
+
.get_mut(operation_id)
|
|
239
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
240
|
+
|
|
241
|
+
let now = now();
|
|
242
|
+
|
|
243
|
+
// Check lease expiration
|
|
244
|
+
if operation.lease_expires_at < now {
|
|
245
|
+
return Err("worker lease expired".to_string());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Verify worker ownership
|
|
249
|
+
if operation.worker_id.as_deref() != Some(worker_id) {
|
|
250
|
+
return Err("worker mismatch".to_string());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Transition from Claimed to Running
|
|
254
|
+
if operation.state != OperationState::Claimed {
|
|
255
|
+
return Err(format!(
|
|
256
|
+
"cannot start operation in state {:?}",
|
|
257
|
+
operation.state
|
|
258
|
+
));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
operation.state = OperationState::Running;
|
|
262
|
+
operation.updated_at = now;
|
|
263
|
+
operation.version += 1;
|
|
264
|
+
|
|
265
|
+
Ok(operation.clone())
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Completes an operation with a result.
|
|
269
|
+
pub fn complete_operation(
|
|
270
|
+
&self,
|
|
271
|
+
operation_id: &str,
|
|
272
|
+
worker_id: &str,
|
|
273
|
+
result: Value,
|
|
274
|
+
) -> Result<DurableOperation, String> {
|
|
275
|
+
let mut ops = self
|
|
276
|
+
.operations
|
|
277
|
+
.write()
|
|
278
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
279
|
+
|
|
280
|
+
let operation = ops
|
|
281
|
+
.get_mut(operation_id)
|
|
282
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
283
|
+
|
|
284
|
+
let now = now();
|
|
285
|
+
|
|
286
|
+
// Check lease expiration
|
|
287
|
+
if operation.lease_expires_at < now {
|
|
288
|
+
return Err("worker lease expired".to_string());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Verify worker ownership
|
|
292
|
+
if operation.worker_id.as_deref() != Some(worker_id) {
|
|
293
|
+
return Err("worker mismatch".to_string());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Must be in Running state
|
|
297
|
+
if operation.state != OperationState::Running {
|
|
298
|
+
return Err(format!(
|
|
299
|
+
"cannot complete operation in state {:?}",
|
|
300
|
+
operation.state
|
|
301
|
+
));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
operation.state = OperationState::Completed;
|
|
305
|
+
operation.result = Some(result);
|
|
306
|
+
operation.error = None;
|
|
307
|
+
operation.updated_at = now;
|
|
308
|
+
operation.version += 1;
|
|
309
|
+
|
|
310
|
+
Ok(operation.clone())
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Fails an operation with an error.
|
|
314
|
+
pub fn fail_operation(
|
|
315
|
+
&self,
|
|
316
|
+
operation_id: &str,
|
|
317
|
+
worker_id: &str,
|
|
318
|
+
error: String,
|
|
319
|
+
) -> Result<DurableOperation, String> {
|
|
320
|
+
let mut ops = self
|
|
321
|
+
.operations
|
|
322
|
+
.write()
|
|
323
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
324
|
+
|
|
325
|
+
let operation = ops
|
|
326
|
+
.get_mut(operation_id)
|
|
327
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
328
|
+
|
|
329
|
+
let now = now();
|
|
330
|
+
|
|
331
|
+
// Check lease expiration
|
|
332
|
+
if operation.lease_expires_at < now {
|
|
333
|
+
return Err("worker lease expired".to_string());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Verify worker ownership
|
|
337
|
+
if operation.worker_id.as_deref() != Some(worker_id) {
|
|
338
|
+
return Err("worker mismatch".to_string());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Must be in Claimed or Running state
|
|
342
|
+
if operation.state != OperationState::Claimed && operation.state != OperationState::Running {
|
|
343
|
+
return Err(format!(
|
|
344
|
+
"cannot fail operation in state {:?}",
|
|
345
|
+
operation.state
|
|
346
|
+
));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
operation.state = OperationState::Failed;
|
|
350
|
+
operation.error = Some(error);
|
|
351
|
+
operation.result = None;
|
|
352
|
+
operation.updated_at = now;
|
|
353
|
+
operation.version += 1;
|
|
354
|
+
|
|
355
|
+
Ok(operation.clone())
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Marks an operation as Unknown (when lease expires).
|
|
359
|
+
pub fn mark_unknown(
|
|
360
|
+
&self,
|
|
361
|
+
operation_id: &str,
|
|
362
|
+
) -> Result<DurableOperation, String> {
|
|
363
|
+
let mut ops = self
|
|
364
|
+
.operations
|
|
365
|
+
.write()
|
|
366
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
367
|
+
|
|
368
|
+
let operation = ops
|
|
369
|
+
.get_mut(operation_id)
|
|
370
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
371
|
+
|
|
372
|
+
let now = now();
|
|
373
|
+
|
|
374
|
+
// Can only mark Unknown if in Claimed or Running state
|
|
375
|
+
if operation.state != OperationState::Claimed && operation.state != OperationState::Running {
|
|
376
|
+
return Err(format!(
|
|
377
|
+
"cannot mark Unknown operation in state {:?}",
|
|
378
|
+
operation.state
|
|
379
|
+
));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
operation.state = OperationState::Unknown;
|
|
383
|
+
operation.updated_at = now;
|
|
384
|
+
operation.version += 1;
|
|
385
|
+
|
|
386
|
+
Ok(operation.clone())
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Reconciles an operation after lease expiration.
|
|
390
|
+
/// Safely transitions from Unknown back to Pending if reconciliation succeeds.
|
|
391
|
+
pub fn reconcile_operation(
|
|
392
|
+
&self,
|
|
393
|
+
operation_id: &str,
|
|
394
|
+
expected_worker_id: Option<&str>,
|
|
395
|
+
) -> Result<DurableOperation, String> {
|
|
396
|
+
let mut ops = self
|
|
397
|
+
.operations
|
|
398
|
+
.write()
|
|
399
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
400
|
+
|
|
401
|
+
let operation = ops
|
|
402
|
+
.get_mut(operation_id)
|
|
403
|
+
.ok_or(format!("operation not found: {}", operation_id))?;
|
|
404
|
+
|
|
405
|
+
// Can only reconcile if Unknown
|
|
406
|
+
if operation.state != OperationState::Unknown {
|
|
407
|
+
return Err(format!(
|
|
408
|
+
"cannot reconcile operation in state {:?}",
|
|
409
|
+
operation.state
|
|
410
|
+
));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// If expected_worker_id provided, verify it matches (to detect multiple workers)
|
|
414
|
+
if let Some(expected) = expected_worker_id {
|
|
415
|
+
if operation.worker_id.as_deref() != Some(expected) {
|
|
416
|
+
return Err(format!(
|
|
417
|
+
"worker mismatch during reconciliation: expected {}, got {:?}",
|
|
418
|
+
expected, operation.worker_id
|
|
419
|
+
));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let now = now();
|
|
424
|
+
|
|
425
|
+
// Transition back to Pending for retry
|
|
426
|
+
operation.state = OperationState::Pending;
|
|
427
|
+
operation.worker_id = None;
|
|
428
|
+
operation.lease_expires_at = 0;
|
|
429
|
+
operation.updated_at = now;
|
|
430
|
+
operation.version += 1;
|
|
431
|
+
|
|
432
|
+
Ok(operation.clone())
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/// Gets an operation by ID.
|
|
436
|
+
pub fn get_operation(&self, operation_id: &str) -> Result<DurableOperation, String> {
|
|
437
|
+
let ops = self
|
|
438
|
+
.operations
|
|
439
|
+
.read()
|
|
440
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
441
|
+
|
|
442
|
+
ops.get(operation_id)
|
|
443
|
+
.cloned()
|
|
444
|
+
.ok_or(format!("operation not found: {}", operation_id))
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/// Lists all operations for a tenant.
|
|
448
|
+
pub fn list_operations(&self, tenant_id: &str) -> Result<Vec<DurableOperation>, String> {
|
|
449
|
+
let ops = self
|
|
450
|
+
.operations
|
|
451
|
+
.read()
|
|
452
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
453
|
+
|
|
454
|
+
Ok(ops
|
|
455
|
+
.values()
|
|
456
|
+
.filter(|op| op.tenant_id == tenant_id)
|
|
457
|
+
.cloned()
|
|
458
|
+
.collect())
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/// Lists operations by state.
|
|
462
|
+
pub fn list_by_state(
|
|
463
|
+
&self,
|
|
464
|
+
tenant_id: &str,
|
|
465
|
+
state: OperationState,
|
|
466
|
+
) -> Result<Vec<DurableOperation>, String> {
|
|
467
|
+
let ops = self
|
|
468
|
+
.operations
|
|
469
|
+
.read()
|
|
470
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
471
|
+
|
|
472
|
+
Ok(ops
|
|
473
|
+
.values()
|
|
474
|
+
.filter(|op| op.tenant_id == tenant_id && op.state == state)
|
|
475
|
+
.cloned()
|
|
476
|
+
.collect())
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/// Cleanup: Removes completed operations older than cutoff time.
|
|
480
|
+
pub fn cleanup_completed_before(&self, cutoff_time: u64) -> Result<usize, String> {
|
|
481
|
+
let mut ops = self
|
|
482
|
+
.operations
|
|
483
|
+
.write()
|
|
484
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
485
|
+
|
|
486
|
+
let before_len = ops.len();
|
|
487
|
+
ops.retain(|_, op| {
|
|
488
|
+
!(op.state == OperationState::Completed && op.updated_at < cutoff_time)
|
|
489
|
+
});
|
|
490
|
+
let after_len = ops.len();
|
|
491
|
+
|
|
492
|
+
Ok(before_len - after_len)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/// Returns count of operations by state (for diagnostics).
|
|
496
|
+
pub fn get_state_counts(&self) -> Result<StateCountsResponse, String> {
|
|
497
|
+
let ops = self
|
|
498
|
+
.operations
|
|
499
|
+
.read()
|
|
500
|
+
.map_err(|_| "store lock poisoned".to_string())?;
|
|
501
|
+
|
|
502
|
+
let mut counts = StateCountsResponse::default();
|
|
503
|
+
for op in ops.values() {
|
|
504
|
+
match op.state {
|
|
505
|
+
OperationState::Pending => counts.pending += 1,
|
|
506
|
+
OperationState::Claimed => counts.claimed += 1,
|
|
507
|
+
OperationState::Running => counts.running += 1,
|
|
508
|
+
OperationState::Completed => counts.completed += 1,
|
|
509
|
+
OperationState::Failed => counts.failed += 1,
|
|
510
|
+
OperationState::Unknown => counts.unknown += 1,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
Ok(counts)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
impl Default for DurableOperationStore {
|
|
519
|
+
fn default() -> Self {
|
|
520
|
+
Self::new()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/// Response with counts of operations by state.
|
|
525
|
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
526
|
+
pub struct StateCountsResponse {
|
|
527
|
+
pub pending: usize,
|
|
528
|
+
pub claimed: usize,
|
|
529
|
+
pub running: usize,
|
|
530
|
+
pub completed: usize,
|
|
531
|
+
pub failed: usize,
|
|
532
|
+
pub unknown: usize,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn now() -> u64 {
|
|
536
|
+
SystemTime::now()
|
|
537
|
+
.duration_since(UNIX_EPOCH)
|
|
538
|
+
.unwrap_or_default()
|
|
539
|
+
.as_secs()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#[cfg(test)]
|
|
543
|
+
mod tests {
|
|
544
|
+
use super::*;
|
|
545
|
+
|
|
546
|
+
#[test]
|
|
547
|
+
fn create_operation() {
|
|
548
|
+
let store = DurableOperationStore::new();
|
|
549
|
+
let payload = serde_json::json!({"work": "data"});
|
|
550
|
+
|
|
551
|
+
let op = store
|
|
552
|
+
.create_operation(
|
|
553
|
+
"op_123".into(),
|
|
554
|
+
"process".into(),
|
|
555
|
+
"tenant_prod".into(),
|
|
556
|
+
payload.clone(),
|
|
557
|
+
)
|
|
558
|
+
.expect("create failed");
|
|
559
|
+
|
|
560
|
+
assert_eq!(op.operation_id, "op_123");
|
|
561
|
+
assert_eq!(op.state, OperationState::Pending);
|
|
562
|
+
assert_eq!(op.version, 1);
|
|
563
|
+
assert_eq!(op.payload, payload);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#[test]
|
|
567
|
+
fn reject_duplicate_operation_id() {
|
|
568
|
+
let store = DurableOperationStore::new();
|
|
569
|
+
let payload = serde_json::json!({"work": "data"});
|
|
570
|
+
|
|
571
|
+
store
|
|
572
|
+
.create_operation(
|
|
573
|
+
"op_123".into(),
|
|
574
|
+
"process".into(),
|
|
575
|
+
"tenant_prod".into(),
|
|
576
|
+
payload.clone(),
|
|
577
|
+
)
|
|
578
|
+
.expect("first create failed");
|
|
579
|
+
|
|
580
|
+
let result = store.create_operation(
|
|
581
|
+
"op_123".into(),
|
|
582
|
+
"process".into(),
|
|
583
|
+
"tenant_prod".into(),
|
|
584
|
+
payload,
|
|
585
|
+
);
|
|
586
|
+
assert!(result.is_err());
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#[test]
|
|
590
|
+
fn claim_pending_operation() {
|
|
591
|
+
let store = DurableOperationStore::new();
|
|
592
|
+
let payload = serde_json::json!({"work": "data"});
|
|
593
|
+
|
|
594
|
+
store
|
|
595
|
+
.create_operation(
|
|
596
|
+
"op_123".into(),
|
|
597
|
+
"process".into(),
|
|
598
|
+
"tenant_prod".into(),
|
|
599
|
+
payload,
|
|
600
|
+
)
|
|
601
|
+
.expect("create failed");
|
|
602
|
+
|
|
603
|
+
let response = store
|
|
604
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
605
|
+
.expect("claim failed");
|
|
606
|
+
|
|
607
|
+
assert_eq!(response.operation.state, OperationState::Claimed);
|
|
608
|
+
assert_eq!(response.operation.worker_id, Some("worker_1".into()));
|
|
609
|
+
assert_eq!(response.version, 2);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
#[test]
|
|
613
|
+
fn reject_claim_non_pending_operation() {
|
|
614
|
+
let store = DurableOperationStore::new();
|
|
615
|
+
let payload = serde_json::json!({"work": "data"});
|
|
616
|
+
|
|
617
|
+
store
|
|
618
|
+
.create_operation(
|
|
619
|
+
"op_123".into(),
|
|
620
|
+
"process".into(),
|
|
621
|
+
"tenant_prod".into(),
|
|
622
|
+
payload,
|
|
623
|
+
)
|
|
624
|
+
.expect("create failed");
|
|
625
|
+
|
|
626
|
+
store
|
|
627
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
628
|
+
.expect("first claim failed");
|
|
629
|
+
|
|
630
|
+
let result = store.claim_operation("op_123", "worker_2".into(), 60);
|
|
631
|
+
assert!(result.is_err());
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
#[test]
|
|
635
|
+
fn renew_lease() {
|
|
636
|
+
let store = DurableOperationStore::new();
|
|
637
|
+
let payload = serde_json::json!({"work": "data"});
|
|
638
|
+
|
|
639
|
+
store
|
|
640
|
+
.create_operation(
|
|
641
|
+
"op_123".into(),
|
|
642
|
+
"process".into(),
|
|
643
|
+
"tenant_prod".into(),
|
|
644
|
+
payload,
|
|
645
|
+
)
|
|
646
|
+
.expect("create failed");
|
|
647
|
+
|
|
648
|
+
store
|
|
649
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
650
|
+
.expect("claim failed");
|
|
651
|
+
|
|
652
|
+
let renewed = store
|
|
653
|
+
.renew_operation("op_123", "worker_1", 120)
|
|
654
|
+
.expect("renew failed");
|
|
655
|
+
|
|
656
|
+
assert_eq!(renewed.version, 3);
|
|
657
|
+
assert_eq!(renewed.state, OperationState::Claimed);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#[test]
|
|
661
|
+
fn reject_renew_wrong_worker() {
|
|
662
|
+
let store = DurableOperationStore::new();
|
|
663
|
+
let payload = serde_json::json!({"work": "data"});
|
|
664
|
+
|
|
665
|
+
store
|
|
666
|
+
.create_operation(
|
|
667
|
+
"op_123".into(),
|
|
668
|
+
"process".into(),
|
|
669
|
+
"tenant_prod".into(),
|
|
670
|
+
payload,
|
|
671
|
+
)
|
|
672
|
+
.expect("create failed");
|
|
673
|
+
|
|
674
|
+
store
|
|
675
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
676
|
+
.expect("claim failed");
|
|
677
|
+
|
|
678
|
+
let result = store.renew_operation("op_123", "worker_2", 120);
|
|
679
|
+
assert!(result.is_err());
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#[test]
|
|
683
|
+
fn complete_operation() {
|
|
684
|
+
let store = DurableOperationStore::new();
|
|
685
|
+
let payload = serde_json::json!({"work": "data"});
|
|
686
|
+
|
|
687
|
+
store
|
|
688
|
+
.create_operation(
|
|
689
|
+
"op_123".into(),
|
|
690
|
+
"process".into(),
|
|
691
|
+
"tenant_prod".into(),
|
|
692
|
+
payload,
|
|
693
|
+
)
|
|
694
|
+
.expect("create failed");
|
|
695
|
+
|
|
696
|
+
store
|
|
697
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
698
|
+
.expect("claim failed");
|
|
699
|
+
|
|
700
|
+
store
|
|
701
|
+
.start_operation("op_123", "worker_1")
|
|
702
|
+
.expect("start failed");
|
|
703
|
+
|
|
704
|
+
let result_value = serde_json::json!({"result": "success"});
|
|
705
|
+
let completed = store
|
|
706
|
+
.complete_operation("op_123", "worker_1", result_value.clone())
|
|
707
|
+
.expect("complete failed");
|
|
708
|
+
|
|
709
|
+
assert_eq!(completed.state, OperationState::Completed);
|
|
710
|
+
assert_eq!(completed.result, Some(result_value));
|
|
711
|
+
assert_eq!(completed.error, None);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#[test]
|
|
715
|
+
fn fail_operation() {
|
|
716
|
+
let store = DurableOperationStore::new();
|
|
717
|
+
let payload = serde_json::json!({"work": "data"});
|
|
718
|
+
|
|
719
|
+
store
|
|
720
|
+
.create_operation(
|
|
721
|
+
"op_123".into(),
|
|
722
|
+
"process".into(),
|
|
723
|
+
"tenant_prod".into(),
|
|
724
|
+
payload,
|
|
725
|
+
)
|
|
726
|
+
.expect("create failed");
|
|
727
|
+
|
|
728
|
+
store
|
|
729
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
730
|
+
.expect("claim failed");
|
|
731
|
+
|
|
732
|
+
let failed = store
|
|
733
|
+
.fail_operation("op_123", "worker_1", "operation timeout".into())
|
|
734
|
+
.expect("fail failed");
|
|
735
|
+
|
|
736
|
+
assert_eq!(failed.state, OperationState::Failed);
|
|
737
|
+
assert_eq!(failed.error, Some("operation timeout".into()));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#[test]
|
|
741
|
+
fn mark_unknown_and_reconcile() {
|
|
742
|
+
let store = DurableOperationStore::new();
|
|
743
|
+
let payload = serde_json::json!({"work": "data"});
|
|
744
|
+
|
|
745
|
+
store
|
|
746
|
+
.create_operation(
|
|
747
|
+
"op_123".into(),
|
|
748
|
+
"process".into(),
|
|
749
|
+
"tenant_prod".into(),
|
|
750
|
+
payload,
|
|
751
|
+
)
|
|
752
|
+
.expect("create failed");
|
|
753
|
+
|
|
754
|
+
store
|
|
755
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
756
|
+
.expect("claim failed");
|
|
757
|
+
|
|
758
|
+
let unknown = store
|
|
759
|
+
.mark_unknown("op_123")
|
|
760
|
+
.expect("mark unknown failed");
|
|
761
|
+
assert_eq!(unknown.state, OperationState::Unknown);
|
|
762
|
+
|
|
763
|
+
let reconciled = store
|
|
764
|
+
.reconcile_operation("op_123", Some("worker_1"))
|
|
765
|
+
.expect("reconcile failed");
|
|
766
|
+
|
|
767
|
+
assert_eq!(reconciled.state, OperationState::Pending);
|
|
768
|
+
assert_eq!(reconciled.worker_id, None);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#[test]
|
|
772
|
+
fn worker_fencing_prevents_renew_after_lease_expiration() {
|
|
773
|
+
let store = DurableOperationStore::new();
|
|
774
|
+
let payload = serde_json::json!({"work": "data"});
|
|
775
|
+
|
|
776
|
+
store
|
|
777
|
+
.create_operation(
|
|
778
|
+
"op_123".into(),
|
|
779
|
+
"process".into(),
|
|
780
|
+
"tenant_prod".into(),
|
|
781
|
+
payload,
|
|
782
|
+
)
|
|
783
|
+
.expect("create failed");
|
|
784
|
+
|
|
785
|
+
store
|
|
786
|
+
.claim_operation("op_123", "worker_1".into(), 1)
|
|
787
|
+
.expect("claim failed");
|
|
788
|
+
|
|
789
|
+
// Wait for lease to expire
|
|
790
|
+
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
791
|
+
|
|
792
|
+
let result = store.renew_operation("op_123", "worker_1", 60);
|
|
793
|
+
assert!(result.is_err());
|
|
794
|
+
assert!(result.unwrap_err().contains("lease expired"));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#[test]
|
|
798
|
+
fn list_operations_by_tenant() {
|
|
799
|
+
let store = DurableOperationStore::new();
|
|
800
|
+
let payload = serde_json::json!({"work": "data"});
|
|
801
|
+
|
|
802
|
+
store
|
|
803
|
+
.create_operation(
|
|
804
|
+
"op_1".into(),
|
|
805
|
+
"process".into(),
|
|
806
|
+
"tenant_prod".into(),
|
|
807
|
+
payload.clone(),
|
|
808
|
+
)
|
|
809
|
+
.expect("create failed");
|
|
810
|
+
|
|
811
|
+
store
|
|
812
|
+
.create_operation(
|
|
813
|
+
"op_2".into(),
|
|
814
|
+
"process".into(),
|
|
815
|
+
"tenant_prod".into(),
|
|
816
|
+
payload.clone(),
|
|
817
|
+
)
|
|
818
|
+
.expect("create failed");
|
|
819
|
+
|
|
820
|
+
store
|
|
821
|
+
.create_operation(
|
|
822
|
+
"op_3".into(),
|
|
823
|
+
"process".into(),
|
|
824
|
+
"tenant_other".into(),
|
|
825
|
+
payload,
|
|
826
|
+
)
|
|
827
|
+
.expect("create failed");
|
|
828
|
+
|
|
829
|
+
let ops = store
|
|
830
|
+
.list_operations("tenant_prod")
|
|
831
|
+
.expect("list failed");
|
|
832
|
+
assert_eq!(ops.len(), 2);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
#[test]
|
|
836
|
+
fn list_operations_by_state() {
|
|
837
|
+
let store = DurableOperationStore::new();
|
|
838
|
+
let payload = serde_json::json!({"work": "data"});
|
|
839
|
+
|
|
840
|
+
store
|
|
841
|
+
.create_operation(
|
|
842
|
+
"op_1".into(),
|
|
843
|
+
"process".into(),
|
|
844
|
+
"tenant_prod".into(),
|
|
845
|
+
payload.clone(),
|
|
846
|
+
)
|
|
847
|
+
.expect("create failed");
|
|
848
|
+
|
|
849
|
+
store
|
|
850
|
+
.create_operation(
|
|
851
|
+
"op_2".into(),
|
|
852
|
+
"process".into(),
|
|
853
|
+
"tenant_prod".into(),
|
|
854
|
+
payload,
|
|
855
|
+
)
|
|
856
|
+
.expect("create failed");
|
|
857
|
+
|
|
858
|
+
store
|
|
859
|
+
.claim_operation("op_2", "worker_1".into(), 60)
|
|
860
|
+
.expect("claim failed");
|
|
861
|
+
|
|
862
|
+
let pending = store
|
|
863
|
+
.list_by_state("tenant_prod", OperationState::Pending)
|
|
864
|
+
.expect("list pending failed");
|
|
865
|
+
assert_eq!(pending.len(), 1);
|
|
866
|
+
|
|
867
|
+
let claimed = store
|
|
868
|
+
.list_by_state("tenant_prod", OperationState::Claimed)
|
|
869
|
+
.expect("list claimed failed");
|
|
870
|
+
assert_eq!(claimed.len(), 1);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
#[test]
|
|
874
|
+
fn cleanup_completed_operations() {
|
|
875
|
+
let store = DurableOperationStore::new();
|
|
876
|
+
let payload = serde_json::json!({"work": "data"});
|
|
877
|
+
|
|
878
|
+
store
|
|
879
|
+
.create_operation(
|
|
880
|
+
"op_completed".into(),
|
|
881
|
+
"process".into(),
|
|
882
|
+
"tenant_prod".into(),
|
|
883
|
+
payload.clone(),
|
|
884
|
+
)
|
|
885
|
+
.expect("create failed");
|
|
886
|
+
|
|
887
|
+
store
|
|
888
|
+
.claim_operation("op_completed", "worker_1".into(), 60)
|
|
889
|
+
.expect("claim failed");
|
|
890
|
+
|
|
891
|
+
store
|
|
892
|
+
.start_operation("op_completed", "worker_1")
|
|
893
|
+
.expect("start failed");
|
|
894
|
+
|
|
895
|
+
store
|
|
896
|
+
.complete_operation(
|
|
897
|
+
"op_completed",
|
|
898
|
+
"worker_1",
|
|
899
|
+
serde_json::json!({"result": "done"}),
|
|
900
|
+
)
|
|
901
|
+
.expect("complete failed");
|
|
902
|
+
|
|
903
|
+
store
|
|
904
|
+
.create_operation(
|
|
905
|
+
"op_pending".into(),
|
|
906
|
+
"process".into(),
|
|
907
|
+
"tenant_prod".into(),
|
|
908
|
+
payload,
|
|
909
|
+
)
|
|
910
|
+
.expect("create failed");
|
|
911
|
+
|
|
912
|
+
let cutoff = now();
|
|
913
|
+
let deleted = store
|
|
914
|
+
.cleanup_completed_before(cutoff + 1)
|
|
915
|
+
.expect("cleanup failed");
|
|
916
|
+
assert_eq!(deleted, 1);
|
|
917
|
+
|
|
918
|
+
let ops = store
|
|
919
|
+
.list_operations("tenant_prod")
|
|
920
|
+
.expect("list failed");
|
|
921
|
+
assert_eq!(ops.len(), 1);
|
|
922
|
+
assert_eq!(ops[0].operation_id, "op_pending");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
#[test]
|
|
926
|
+
fn get_state_counts() {
|
|
927
|
+
let store = DurableOperationStore::new();
|
|
928
|
+
let payload = serde_json::json!({"work": "data"});
|
|
929
|
+
|
|
930
|
+
store
|
|
931
|
+
.create_operation(
|
|
932
|
+
"op_1".into(),
|
|
933
|
+
"process".into(),
|
|
934
|
+
"tenant_prod".into(),
|
|
935
|
+
payload.clone(),
|
|
936
|
+
)
|
|
937
|
+
.expect("create failed");
|
|
938
|
+
|
|
939
|
+
store
|
|
940
|
+
.create_operation(
|
|
941
|
+
"op_2".into(),
|
|
942
|
+
"process".into(),
|
|
943
|
+
"tenant_prod".into(),
|
|
944
|
+
payload,
|
|
945
|
+
)
|
|
946
|
+
.expect("create failed");
|
|
947
|
+
|
|
948
|
+
store
|
|
949
|
+
.claim_operation("op_2", "worker_1".into(), 60)
|
|
950
|
+
.expect("claim failed");
|
|
951
|
+
|
|
952
|
+
let counts = store.get_state_counts().expect("get counts failed");
|
|
953
|
+
assert_eq!(counts.pending, 1);
|
|
954
|
+
assert_eq!(counts.claimed, 1);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#[test]
|
|
958
|
+
fn state_transitions_are_safe() {
|
|
959
|
+
let store = DurableOperationStore::new();
|
|
960
|
+
let payload = serde_json::json!({"work": "data"});
|
|
961
|
+
|
|
962
|
+
store
|
|
963
|
+
.create_operation(
|
|
964
|
+
"op_123".into(),
|
|
965
|
+
"process".into(),
|
|
966
|
+
"tenant_prod".into(),
|
|
967
|
+
payload,
|
|
968
|
+
)
|
|
969
|
+
.expect("create failed");
|
|
970
|
+
|
|
971
|
+
let claim_response = store
|
|
972
|
+
.claim_operation("op_123", "worker_1".into(), 60)
|
|
973
|
+
.expect("claim failed");
|
|
974
|
+
assert_eq!(claim_response.operation.version, 2);
|
|
975
|
+
|
|
976
|
+
store
|
|
977
|
+
.start_operation("op_123", "worker_1")
|
|
978
|
+
.expect("start failed");
|
|
979
|
+
|
|
980
|
+
store
|
|
981
|
+
.complete_operation(
|
|
982
|
+
"op_123",
|
|
983
|
+
"worker_1",
|
|
984
|
+
serde_json::json!({"result": "done"}),
|
|
985
|
+
)
|
|
986
|
+
.expect("complete failed");
|
|
987
|
+
|
|
988
|
+
let final_op = store.get_operation("op_123").expect("get failed");
|
|
989
|
+
assert_eq!(final_op.state, OperationState::Completed);
|
|
990
|
+
assert_eq!(final_op.version, 4);
|
|
991
|
+
}
|
|
992
|
+
}
|