@feltdb/core 0.4.20 → 0.5.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/README.md +169 -0
- package/dist/cli/index.js +1 -1
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +12 -0
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +2 -0
- package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +381 -0
- package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +280 -0
- package/dist/error-codes.d.ts +53 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +46 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/revision-recovery.d.ts +162 -0
- package/dist/revision-recovery.d.ts.map +1 -0
- package/dist/revision-recovery.js +69 -0
- package/dist/state-contract.d.ts +2 -0
- package/dist/state-contract.d.ts.map +1 -1
- package/dist/state-contract.js +17 -7
- package/dist/studio-app/assets/{index-CGQV6zGa.js → index-3cvTQ0Mv.js} +4 -4
- package/dist/studio-app/index.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,175 @@ Browser mutations resolve after their IndexedDB transaction commits. The
|
|
|
75
75
|
durable change journal replays after reload and coordinates live collections
|
|
76
76
|
across tabs through `BroadcastChannel` when available.
|
|
77
77
|
|
|
78
|
+
## Durable Operation Management
|
|
79
|
+
|
|
80
|
+
FeltDB provides atomic operation admission and lifecycle management for systems that need to survive process crashes with guaranteed identity stability.
|
|
81
|
+
|
|
82
|
+
### Admit Operations (with atomic identity)
|
|
83
|
+
|
|
84
|
+
Guarantee: **exactly-once operation identity** across process crashes and concurrent callers.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { OperationAdmissionInput, DurableOperation } from '@feltdb/core';
|
|
88
|
+
|
|
89
|
+
const db = createFeltDB({ namespace: 'my-app', path: './state' });
|
|
90
|
+
|
|
91
|
+
const result = await db.admitOperation({
|
|
92
|
+
idempotencyKey: 'payment-123',
|
|
93
|
+
kind: 'payment-processing',
|
|
94
|
+
metadata: { amount: 99.99, currency: 'USD' }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Same idempotencyKey always returns same operationId
|
|
98
|
+
console.log(result.operationId); // 'op-xxx-yyy' (stable)
|
|
99
|
+
console.log(result.admitted); // true if this caller admitted it, false if already existed
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Transition Operations (atomic lifecycle)
|
|
103
|
+
|
|
104
|
+
Guarantee: **all-or-nothing state transitions** with version-based Compare-And-Set semantics.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { OperationTransitionInput } from '@feltdb/core';
|
|
108
|
+
|
|
109
|
+
const transition = await db.transitionOperation({
|
|
110
|
+
operationId: 'op-xxx-yyy',
|
|
111
|
+
expectedVersion: 0,
|
|
112
|
+
to: 'executing',
|
|
113
|
+
metadata: { started_at: Date.now() }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (transition.transitioned) {
|
|
117
|
+
// We won the transition race
|
|
118
|
+
console.log('Now executing...');
|
|
119
|
+
|
|
120
|
+
// Do work...
|
|
121
|
+
|
|
122
|
+
// Complete the operation
|
|
123
|
+
await db.transitionOperation({
|
|
124
|
+
operationId: 'op-xxx-yyy',
|
|
125
|
+
expectedVersion: 1,
|
|
126
|
+
to: 'completed',
|
|
127
|
+
resultSnapshot: { paymentId: 'pay-456', timestamp: Date.now() }
|
|
128
|
+
});
|
|
129
|
+
} else if (transition.reason === 'VERSION_CONFLICT') {
|
|
130
|
+
// Another process already transitioned this operation
|
|
131
|
+
console.log('Conflict - another process is handling this');
|
|
132
|
+
console.log('Current status:', transition.operation.status);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Operation lifecycle: `accepted` → `executing` → (`completed` | `failed` | `cancelled`)
|
|
137
|
+
|
|
138
|
+
Terminal states (`completed`, `failed`, `cancelled`) cannot be transitioned from.
|
|
139
|
+
|
|
140
|
+
### Recover Revisions (audited recovery from corruption)
|
|
141
|
+
|
|
142
|
+
Guarantee: **audit trail with permanent untrust markers**, no silent rollback.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { RevisionRecoveryInput } from '@feltdb/core';
|
|
146
|
+
|
|
147
|
+
const recovery = await db.recoverApplicationRevision({
|
|
148
|
+
from_revision: 'rev-corrupted-123',
|
|
149
|
+
to_revision: 'rev-clean-122',
|
|
150
|
+
expected_current_revision: 'rev-corrupted-123',
|
|
151
|
+
authorization: {
|
|
152
|
+
level: 'ELEVATED',
|
|
153
|
+
reason: 'Database corruption detected during backup validation',
|
|
154
|
+
approval_id: 'approval-789'
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Result includes immutable audit trail
|
|
159
|
+
console.log(recovery.audit_record);
|
|
160
|
+
// The corrupt revision is permanently marked untrusted
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Operation Types
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
import {
|
|
167
|
+
OperationAdmissionInput,
|
|
168
|
+
OperationAdmissionResult,
|
|
169
|
+
DurableOperation,
|
|
170
|
+
OperationStatus,
|
|
171
|
+
OperationTransitionInput,
|
|
172
|
+
OperationTransitionResult,
|
|
173
|
+
} from '@feltdb/core';
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Error Semantics
|
|
177
|
+
|
|
178
|
+
All FeltDB APIs return **deterministic, semantic error codes** (never empty `{}`).
|
|
179
|
+
|
|
180
|
+
### Error Codes
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { FeltDBErrorCode } from '@feltdb/core';
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await db.transitionOperation({ ... });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const felt_error = error.feltdb_error;
|
|
189
|
+
|
|
190
|
+
switch (felt_error.code) {
|
|
191
|
+
case FeltDBErrorCode.CONFLICT:
|
|
192
|
+
// Version mismatch; another process won the race
|
|
193
|
+
// → Retry with exponential backoff
|
|
194
|
+
console.log('Conflict; retrying...');
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case FeltDBErrorCode.PRECONDITION_FAILED:
|
|
198
|
+
// Validation or precondition error
|
|
199
|
+
// → Do not retry; fix the input
|
|
200
|
+
console.log('Validation error:', felt_error.message);
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case FeltDBErrorCode.TOO_BUSY:
|
|
204
|
+
// Queue depth exceeded
|
|
205
|
+
// → Retry with exponential backoff
|
|
206
|
+
console.log('Server busy; retrying...');
|
|
207
|
+
break;
|
|
208
|
+
|
|
209
|
+
case FeltDBErrorCode.INTERNAL_ERROR:
|
|
210
|
+
// Server error
|
|
211
|
+
// → Log and escalate; audit trail in request_id
|
|
212
|
+
console.log('Server error:', felt_error.request_id);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Error Response Structure
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { FeltDBErrorResponse } from '@feltdb/core';
|
|
222
|
+
|
|
223
|
+
interface FeltDBErrorResponse {
|
|
224
|
+
code: FeltDBErrorCode | string; // Semantic code (CONFLICT, PRECONDITION_FAILED, etc.)
|
|
225
|
+
message: string; // Human-readable message
|
|
226
|
+
request_id: string; // Unique ID for debugging
|
|
227
|
+
transaction_id?: string; // If applicable
|
|
228
|
+
http_status: number; // HTTP status for routing
|
|
229
|
+
recovery_hint?: 'retry_backoff' | 'dont_retry' | 'check_queue_depth' | 'contact_support';
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Error Handling Utilities
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { isRetryableError, getRetryStrategy } from '@feltdb/core';
|
|
237
|
+
|
|
238
|
+
// Check if error should be retried
|
|
239
|
+
if (isRetryableError(error.feltdb_error.code)) {
|
|
240
|
+
const strategy = getRetryStrategy(error.feltdb_error.code);
|
|
241
|
+
if (strategy === 'exponential_backoff') {
|
|
242
|
+
// Wait with exponential backoff before retry
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
78
247
|
## Concurrency Model
|
|
79
248
|
|
|
80
249
|
FeltDB 0.4.3 uses a **single-writer, multi-reader** model:
|
package/dist/cli/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as path from 'path';
|
|
|
23
23
|
import * as readline from 'readline';
|
|
24
24
|
import { getClient } from './api-client.js';
|
|
25
25
|
import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
|
|
26
|
-
const VERSION = '0.
|
|
26
|
+
const VERSION = '0.5.0';
|
|
27
27
|
function prompt(question) {
|
|
28
28
|
const rl = readline.createInterface({
|
|
29
29
|
input: process.stdin,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.0';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -524,6 +524,7 @@ dependencies = [
|
|
|
524
524
|
"tower-http",
|
|
525
525
|
"tracing",
|
|
526
526
|
"tracing-subscriber",
|
|
527
|
+
"uuid",
|
|
527
528
|
]
|
|
528
529
|
|
|
529
530
|
[[package]]
|
|
@@ -1904,6 +1905,17 @@ version = "1.0.4"
|
|
|
1904
1905
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1905
1906
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|
1906
1907
|
|
|
1908
|
+
[[package]]
|
|
1909
|
+
name = "uuid"
|
|
1910
|
+
version = "1.25.0"
|
|
1911
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1912
|
+
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
|
|
1913
|
+
dependencies = [
|
|
1914
|
+
"getrandom 0.4.3",
|
|
1915
|
+
"js-sys",
|
|
1916
|
+
"wasm-bindgen",
|
|
1917
|
+
]
|
|
1918
|
+
|
|
1907
1919
|
[[package]]
|
|
1908
1920
|
name = "valuable"
|
|
1909
1921
|
version = "0.1.1"
|
|
@@ -21,3 +21,4 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
|
|
|
21
21
|
tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }
|
|
22
22
|
tracing = "0.1"
|
|
23
23
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
24
|
+
uuid = { version = "1.6", features = ["v4"] }
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
use std::sync::{Arc, Mutex};
|
|
2
|
+
use std::time::{Instant, SystemTime};
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use uuid::Uuid;
|
|
5
|
+
|
|
6
|
+
/// Request lifecycle telemetry: captures all timing and context for every /v1/ request.
|
|
7
|
+
/// This is the production correctness contract for managed FeltDB concurrency.
|
|
8
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
|
+
pub struct RequestTelemetry {
|
|
10
|
+
pub request_id: String,
|
|
11
|
+
pub transaction_id: Option<String>,
|
|
12
|
+
pub application_id: String,
|
|
13
|
+
pub revision_id: String,
|
|
14
|
+
|
|
15
|
+
// Timing breakdown (milliseconds)
|
|
16
|
+
pub queue_wait_ms: u64,
|
|
17
|
+
pub lock_wait_ms: u64,
|
|
18
|
+
pub execution_ms: u64,
|
|
19
|
+
pub persistence_ms: u64,
|
|
20
|
+
pub total_ms: u64,
|
|
21
|
+
|
|
22
|
+
// Contention signals
|
|
23
|
+
pub lock_name: String,
|
|
24
|
+
pub queue_depth: usize,
|
|
25
|
+
pub max_queue_depth: usize,
|
|
26
|
+
|
|
27
|
+
// Cancellation state
|
|
28
|
+
pub deadline_ms: Option<u64>,
|
|
29
|
+
pub cancelled: bool,
|
|
30
|
+
|
|
31
|
+
// HTTP response
|
|
32
|
+
pub http_status: u16,
|
|
33
|
+
pub feltdb_code: String,
|
|
34
|
+
pub error_message: Option<String>,
|
|
35
|
+
|
|
36
|
+
// Resource tracking
|
|
37
|
+
pub orphaned_tasks: usize,
|
|
38
|
+
pub timestamp_ms: u64,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl RequestTelemetry {
|
|
42
|
+
pub fn new(application_id: String, revision_id: String) -> Self {
|
|
43
|
+
let now = SystemTime::now()
|
|
44
|
+
.duration_since(SystemTime::UNIX_EPOCH)
|
|
45
|
+
.unwrap_or_default()
|
|
46
|
+
.as_millis() as u64;
|
|
47
|
+
|
|
48
|
+
Self {
|
|
49
|
+
request_id: Uuid::new_v4().to_string(),
|
|
50
|
+
transaction_id: None,
|
|
51
|
+
application_id,
|
|
52
|
+
revision_id,
|
|
53
|
+
queue_wait_ms: 0,
|
|
54
|
+
lock_wait_ms: 0,
|
|
55
|
+
execution_ms: 0,
|
|
56
|
+
persistence_ms: 0,
|
|
57
|
+
total_ms: 0,
|
|
58
|
+
lock_name: String::new(),
|
|
59
|
+
queue_depth: 0,
|
|
60
|
+
max_queue_depth: 0,
|
|
61
|
+
deadline_ms: None,
|
|
62
|
+
cancelled: false,
|
|
63
|
+
http_status: 200,
|
|
64
|
+
feltdb_code: "OK".to_string(),
|
|
65
|
+
error_message: None,
|
|
66
|
+
orphaned_tasks: 0,
|
|
67
|
+
timestamp_ms: now,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn with_transaction_id(mut self, tx_id: String) -> Self {
|
|
72
|
+
self.transaction_id = Some(tx_id);
|
|
73
|
+
self
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Request telemetry store: thread-safe collection of all request measurements.
|
|
78
|
+
/// Enables production diagnostics without external observability dependency.
|
|
79
|
+
pub struct RequestTelemetryStore {
|
|
80
|
+
records: Arc<Mutex<Vec<RequestTelemetry>>>,
|
|
81
|
+
max_capacity: usize,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl RequestTelemetryStore {
|
|
85
|
+
pub fn new(max_capacity: usize) -> Self {
|
|
86
|
+
Self {
|
|
87
|
+
records: Arc::new(Mutex::new(Vec::with_capacity(max_capacity))),
|
|
88
|
+
max_capacity,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn record(&self, telemetry: RequestTelemetry) {
|
|
93
|
+
if let Ok(mut records) = self.records.lock() {
|
|
94
|
+
records.push(telemetry);
|
|
95
|
+
// Keep only the most recent records
|
|
96
|
+
if records.len() > self.max_capacity {
|
|
97
|
+
let to_remove = records.len() - self.max_capacity;
|
|
98
|
+
records.drain(0..to_remove);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn all(&self) -> Vec<RequestTelemetry> {
|
|
104
|
+
self.records
|
|
105
|
+
.lock()
|
|
106
|
+
.map(|r| r.clone())
|
|
107
|
+
.unwrap_or_default()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pub fn recent(&self, count: usize) -> Vec<RequestTelemetry> {
|
|
111
|
+
self.records
|
|
112
|
+
.lock()
|
|
113
|
+
.map(|r| {
|
|
114
|
+
let start = if r.len() > count { r.len() - count } else { 0 };
|
|
115
|
+
r[start..].to_vec()
|
|
116
|
+
})
|
|
117
|
+
.unwrap_or_default()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pub fn by_transaction_id(&self, tx_id: &str) -> Vec<RequestTelemetry> {
|
|
121
|
+
self.records
|
|
122
|
+
.lock()
|
|
123
|
+
.map(|r| r.iter().filter(|t| t.transaction_id.as_deref() == Some(tx_id)).cloned().collect())
|
|
124
|
+
.unwrap_or_default()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pub fn statistics(&self) -> TelemetryStatistics {
|
|
128
|
+
if let Ok(records) = self.records.lock() {
|
|
129
|
+
let mut stats = TelemetryStatistics::default();
|
|
130
|
+
let mut total_latencies = Vec::new();
|
|
131
|
+
let mut queue_waits = Vec::new();
|
|
132
|
+
let mut lock_waits = Vec::new();
|
|
133
|
+
let mut execution_times = Vec::new();
|
|
134
|
+
let mut persistence_times = Vec::new();
|
|
135
|
+
|
|
136
|
+
for telemetry in records.iter() {
|
|
137
|
+
total_latencies.push(telemetry.total_ms);
|
|
138
|
+
queue_waits.push(telemetry.queue_wait_ms);
|
|
139
|
+
lock_waits.push(telemetry.lock_wait_ms);
|
|
140
|
+
execution_times.push(telemetry.execution_ms);
|
|
141
|
+
persistence_times.push(telemetry.persistence_ms);
|
|
142
|
+
stats.total_requests += 1;
|
|
143
|
+
stats.max_queue_depth = stats.max_queue_depth.max(telemetry.max_queue_depth);
|
|
144
|
+
stats.total_orphaned_tasks += telemetry.orphaned_tasks;
|
|
145
|
+
|
|
146
|
+
match telemetry.http_status {
|
|
147
|
+
200..=299 => stats.successful_requests += 1,
|
|
148
|
+
400..=499 => stats.client_errors += 1,
|
|
149
|
+
500..=599 => stats.server_errors += 1,
|
|
150
|
+
_ => {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if telemetry.cancelled {
|
|
154
|
+
stats.cancelled_requests += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if telemetry.feltdb_code == "CONFLICT" {
|
|
158
|
+
stats.conflicts += 1;
|
|
159
|
+
} else if telemetry.feltdb_code == "TOO_BUSY" {
|
|
160
|
+
stats.too_busy_count += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Calculate percentiles
|
|
165
|
+
if !total_latencies.is_empty() {
|
|
166
|
+
total_latencies.sort();
|
|
167
|
+
stats.latency_p50_ms = calculate_percentile(&total_latencies, 50);
|
|
168
|
+
stats.latency_p95_ms = calculate_percentile(&total_latencies, 95);
|
|
169
|
+
stats.latency_p99_ms = calculate_percentile(&total_latencies, 99);
|
|
170
|
+
stats.latency_max_ms = *total_latencies.last().unwrap_or(&0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if !queue_waits.is_empty() {
|
|
174
|
+
queue_waits.sort();
|
|
175
|
+
stats.queue_wait_p99_ms = calculate_percentile(&queue_waits, 99);
|
|
176
|
+
stats.queue_wait_max_ms = *queue_waits.last().unwrap_or(&0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if !lock_waits.is_empty() {
|
|
180
|
+
lock_waits.sort();
|
|
181
|
+
stats.lock_wait_p99_ms = calculate_percentile(&lock_waits, 99);
|
|
182
|
+
stats.lock_wait_max_ms = *lock_waits.last().unwrap_or(&0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if !execution_times.is_empty() {
|
|
186
|
+
stats.execution_p99_ms = calculate_percentile(&execution_times, 99);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if !persistence_times.is_empty() {
|
|
190
|
+
stats.persistence_p99_ms = calculate_percentile(&persistence_times, 99);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
stats
|
|
194
|
+
} else {
|
|
195
|
+
TelemetryStatistics::default()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pub fn clear(&self) {
|
|
200
|
+
if let Ok(mut records) = self.records.lock() {
|
|
201
|
+
records.clear();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[derive(Debug, Clone, Serialize, Default)]
|
|
207
|
+
pub struct TelemetryStatistics {
|
|
208
|
+
pub total_requests: usize,
|
|
209
|
+
pub successful_requests: usize,
|
|
210
|
+
pub client_errors: usize,
|
|
211
|
+
pub server_errors: usize,
|
|
212
|
+
pub cancelled_requests: usize,
|
|
213
|
+
pub conflicts: usize,
|
|
214
|
+
pub too_busy_count: usize,
|
|
215
|
+
|
|
216
|
+
pub latency_p50_ms: u64,
|
|
217
|
+
pub latency_p95_ms: u64,
|
|
218
|
+
pub latency_p99_ms: u64,
|
|
219
|
+
pub latency_max_ms: u64,
|
|
220
|
+
|
|
221
|
+
pub queue_wait_p99_ms: u64,
|
|
222
|
+
pub queue_wait_max_ms: u64,
|
|
223
|
+
|
|
224
|
+
pub lock_wait_p99_ms: u64,
|
|
225
|
+
pub lock_wait_max_ms: u64,
|
|
226
|
+
|
|
227
|
+
pub execution_p99_ms: u64,
|
|
228
|
+
pub persistence_p99_ms: u64,
|
|
229
|
+
|
|
230
|
+
pub max_queue_depth: usize,
|
|
231
|
+
pub total_orphaned_tasks: usize,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn calculate_percentile(sorted: &[u64], percentile: u32) -> u64 {
|
|
235
|
+
if sorted.is_empty() {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
let index = ((sorted.len() as u32 * percentile / 100).max(1) - 1) as usize;
|
|
239
|
+
sorted[index.min(sorted.len() - 1)]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Request lifecycle timer: tracks all phases of request execution.
|
|
243
|
+
pub struct RequestTimer {
|
|
244
|
+
start: Instant,
|
|
245
|
+
queue_start: Option<Instant>,
|
|
246
|
+
lock_start: Option<Instant>,
|
|
247
|
+
execution_start: Option<Instant>,
|
|
248
|
+
persistence_start: Option<Instant>,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
impl RequestTimer {
|
|
252
|
+
pub fn new() -> Self {
|
|
253
|
+
Self {
|
|
254
|
+
start: Instant::now(),
|
|
255
|
+
queue_start: None,
|
|
256
|
+
lock_start: None,
|
|
257
|
+
execution_start: None,
|
|
258
|
+
persistence_start: None,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pub fn begin_queue_wait(&mut self) {
|
|
263
|
+
self.queue_start = Some(Instant::now());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
pub fn end_queue_wait(&mut self) -> u64 {
|
|
267
|
+
self.queue_start
|
|
268
|
+
.take()
|
|
269
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
270
|
+
.unwrap_or(0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
pub fn begin_lock_wait(&mut self) {
|
|
274
|
+
self.lock_start = Some(Instant::now());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pub fn end_lock_wait(&mut self) -> u64 {
|
|
278
|
+
self.lock_start
|
|
279
|
+
.take()
|
|
280
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
281
|
+
.unwrap_or(0)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
pub fn begin_execution(&mut self) {
|
|
285
|
+
self.execution_start = Some(Instant::now());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
pub fn end_execution(&mut self) -> u64 {
|
|
289
|
+
self.execution_start
|
|
290
|
+
.take()
|
|
291
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
292
|
+
.unwrap_or(0)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pub fn begin_persistence(&mut self) {
|
|
296
|
+
self.persistence_start = Some(Instant::now());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pub fn end_persistence(&mut self) -> u64 {
|
|
300
|
+
self.persistence_start
|
|
301
|
+
.take()
|
|
302
|
+
.map(|s| s.elapsed().as_millis() as u64)
|
|
303
|
+
.unwrap_or(0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
pub fn total_elapsed(&self) -> u64 {
|
|
307
|
+
self.start.elapsed().as_millis() as u64
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
impl Default for RequestTimer {
|
|
312
|
+
fn default() -> Self {
|
|
313
|
+
Self::new()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[cfg(test)]
|
|
318
|
+
mod tests {
|
|
319
|
+
use super::*;
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn test_telemetry_creation() {
|
|
323
|
+
let telemetry = RequestTelemetry::new("app-1".to_string(), "rev-1".to_string());
|
|
324
|
+
assert!(!telemetry.request_id.is_empty());
|
|
325
|
+
assert_eq!(telemetry.application_id, "app-1");
|
|
326
|
+
assert_eq!(telemetry.revision_id, "rev-1");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn test_telemetry_store() {
|
|
331
|
+
let store = RequestTelemetryStore::new(100);
|
|
332
|
+
let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
|
|
333
|
+
telemetry.total_ms = 50;
|
|
334
|
+
telemetry.http_status = 200;
|
|
335
|
+
|
|
336
|
+
store.record(telemetry.clone());
|
|
337
|
+
let all = store.all();
|
|
338
|
+
assert_eq!(all.len(), 1);
|
|
339
|
+
assert_eq!(all[0].total_ms, 50);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#[test]
|
|
343
|
+
fn test_timer_lifecycle() {
|
|
344
|
+
let mut timer = RequestTimer::new();
|
|
345
|
+
|
|
346
|
+
timer.begin_queue_wait();
|
|
347
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
348
|
+
let queue_ms = timer.end_queue_wait();
|
|
349
|
+
assert!(queue_ms >= 10);
|
|
350
|
+
|
|
351
|
+
timer.begin_lock_wait();
|
|
352
|
+
std::thread::sleep(Duration::from_millis(5));
|
|
353
|
+
let lock_ms = timer.end_lock_wait();
|
|
354
|
+
assert!(lock_ms >= 5);
|
|
355
|
+
|
|
356
|
+
let total = timer.total_elapsed();
|
|
357
|
+
assert!(total >= 15);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn test_statistics_calculation() {
|
|
362
|
+
let store = RequestTelemetryStore::new(1000);
|
|
363
|
+
|
|
364
|
+
// Record 101 requests with increasing latency
|
|
365
|
+
for i in 0..101 {
|
|
366
|
+
let mut telemetry = RequestTelemetry::new("app".to_string(), "rev".to_string());
|
|
367
|
+
telemetry.total_ms = (i * 10) as u64; // 0, 10, 20, ..., 1000
|
|
368
|
+
telemetry.http_status = if i % 10 == 0 { 409 } else { 200 };
|
|
369
|
+
if i % 10 == 0 {
|
|
370
|
+
telemetry.feltdb_code = "CONFLICT".to_string();
|
|
371
|
+
}
|
|
372
|
+
store.record(telemetry);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let stats = store.statistics();
|
|
376
|
+
assert_eq!(stats.total_requests, 101);
|
|
377
|
+
assert!(stats.latency_p50_ms > 0);
|
|
378
|
+
assert!(stats.latency_p99_ms > stats.latency_p50_ms);
|
|
379
|
+
assert_eq!(stats.conflicts, 11);
|
|
380
|
+
}
|
|
381
|
+
}
|