toiljs 0.0.111 → 0.0.113
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/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/toil-docs.generated.js +2 -2
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.d.ts +2 -0
- package/build/devserver/analytics/index.js +15 -9
- package/build/devserver/db/database.d.ts +1 -0
- package/build/devserver/db/database.js +49 -23
- package/build/devserver/runtime/module.js +1 -0
- package/docs/database/documents.md +40 -18
- package/docs/services/analytics.md +28 -17
- package/examples/basic/server/routes/Analytics.ts +6 -5
- package/package.json +3 -3
- package/src/compiler/toil-docs.generated.ts +2 -2
- package/src/devserver/analytics/index.ts +40 -17
- package/src/devserver/db/database.ts +51 -0
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +8 -8
|
@@ -15,8 +15,19 @@ import type { DbDevState } from '../db/index.js';
|
|
|
15
15
|
import type { MemoryRef } from '../runtime/host.js';
|
|
16
16
|
|
|
17
17
|
/** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
|
|
18
|
-
const FRAME_VERSION =
|
|
18
|
+
const FRAME_VERSION = 3;
|
|
19
19
|
const METRIC_COUNTERS = 42;
|
|
20
|
+
|
|
21
|
+
/** The sustained-rate window: requests are metered over a 3-minute ROLLING window. A plan's
|
|
22
|
+
* `sustained_rps` (X) allows `X * BURST_WINDOW_SECS` requests across it; instantaneous spikes above
|
|
23
|
+
* that are NOT capped per-tier (only the box `firewall.global_pps` DDoS backstop applies). */
|
|
24
|
+
export const BURST_WINDOW_SECS = 180;
|
|
25
|
+
|
|
26
|
+
/** The plan's sustained rate in requests/second, derived from the rolling window's allowance.
|
|
27
|
+
* `0` means unmetered (no sustained cap), and must NOT be divided into a rate. */
|
|
28
|
+
export function sustainedRpsCap(reqBurstCap: number): number {
|
|
29
|
+
return reqBurstCap === 0 ? 0 : reqBurstCap / BURST_WINDOW_SECS;
|
|
30
|
+
}
|
|
20
31
|
/** MetricId indices we seed with sample data (others default 0). Mirrors the AUTHORITATIVE edge wire
|
|
21
32
|
* contract EXACTLY (`toil-backend/src/analytics/metric_id.rs`): counter ids 0..=41, gauge ids
|
|
22
33
|
* ConnectedStreamsAvg=42, CommittedMemoryAvg=44. There is NO WasmDispatches (removed + reindexed); the
|
|
@@ -43,10 +54,16 @@ interface DevTenantStats {
|
|
|
43
54
|
life: bigint[]; // length METRIC_COUNTERS, indexed by MetricId
|
|
44
55
|
connectedStreams: number;
|
|
45
56
|
committedMemory: number;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
// Fleet-global requests in the trailing 3-minute ROLLING window, and its allowance
|
|
58
|
+
// (= sustained_rps * BURST_WINDOW_SECS; 0 = unmetered). Spikes are not tier-capped.
|
|
59
|
+
reqBurstUsed: number;
|
|
60
|
+
reqBurstCap: number;
|
|
61
|
+
// Fleet-global requests in the current 30-day tumbling bucket, and the 30-day quota (0 = unmetered).
|
|
62
|
+
req30dUsed: number;
|
|
63
|
+
req30dCap: number;
|
|
64
|
+
// Derived (NOT part of the wire frame): the plan's sustained request rate, `reqBurstCap /
|
|
65
|
+
// BURST_WINDOW_SECS` (0 = unmetered). Surfaced so a dev dashboard can show "X sustained rps".
|
|
66
|
+
sustainedRpsCap: number;
|
|
50
67
|
// LIVE per-second rates (f64), appended after the windows. Mirrors the edge `encode_stats`.
|
|
51
68
|
rps: number;
|
|
52
69
|
bytesInPerSec: number;
|
|
@@ -76,14 +93,18 @@ function devStats(): DevTenantStats {
|
|
|
76
93
|
life[MID.MemGrownBytes] = 262144n;
|
|
77
94
|
life[MID.CacheHits] = 900n;
|
|
78
95
|
life[MID.CacheMisses] = 100n;
|
|
96
|
+
// Free tier: sustained 1000 rps -> a 3-minute burst cap of 1000 * 180 = 180_000, plus a 30-day
|
|
97
|
+
// quota of 10_000_000. `used` values are small so the dashboard shows plenty of headroom.
|
|
98
|
+
const reqBurstCap: number = 180_000;
|
|
79
99
|
return {
|
|
80
100
|
life,
|
|
81
101
|
connectedStreams: 3,
|
|
82
102
|
committedMemory: 65536,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
reqBurstUsed: 1_200,
|
|
104
|
+
reqBurstCap,
|
|
105
|
+
req30dUsed: 250_000,
|
|
106
|
+
req30dCap: 10_000_000,
|
|
107
|
+
sustainedRpsCap: sustainedRpsCap(reqBurstCap),
|
|
87
108
|
// Plausible non-zero live rates so a dev dashboard shows moving gauges.
|
|
88
109
|
rps: 0.7,
|
|
89
110
|
bytesInPerSec: 68.2,
|
|
@@ -97,10 +118,12 @@ function devStats(): DevTenantStats {
|
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
/**
|
|
100
|
-
* Encode the
|
|
101
|
-
* layout (no strings).
|
|
121
|
+
* Encode the v3 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
|
|
122
|
+
* layout (no strings). The v2 -> v3 bump renamed two u64 pairs (req_minute_* -> req_burst_*,
|
|
123
|
+
* req_day_* -> req_30d_*); the BYTE LAYOUT is UNCHANGED (same field count, order, and widths).
|
|
124
|
+
* Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
|
|
102
125
|
* u16 version | u64 now_ms | u32 count | u64 × count | u64 connectedStreams | u64 committedMemory |
|
|
103
|
-
* u64
|
|
126
|
+
* u64 reqBurstUsed | u64 reqBurstCap | u64 req30dUsed | u64 req30dCap |
|
|
104
127
|
* f64 rps | f64 bytesInPerSec | f64 bytesOutPerSec | f64 streamBytesInPerSec |
|
|
105
128
|
* f64 streamBytesOutPerSec | f64 dbOpsPerSec | f64 gasPerSec
|
|
106
129
|
* The rates are APPENDED (version-gated by length): the guest's bounds-checked reader yields 0.0 for a
|
|
@@ -120,10 +143,10 @@ function encodeStats(s: DevTenantStats): Buffer {
|
|
|
120
143
|
}
|
|
121
144
|
body.writeBigUInt64LE(BigInt(s.connectedStreams), o); o += 8;
|
|
122
145
|
body.writeBigUInt64LE(BigInt(s.committedMemory), o); o += 8;
|
|
123
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
124
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
125
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
126
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
146
|
+
body.writeBigUInt64LE(BigInt(s.reqBurstUsed), o); o += 8;
|
|
147
|
+
body.writeBigUInt64LE(BigInt(s.reqBurstCap), o); o += 8;
|
|
148
|
+
body.writeBigUInt64LE(BigInt(s.req30dUsed), o); o += 8;
|
|
149
|
+
body.writeBigUInt64LE(BigInt(s.req30dCap), o); o += 8;
|
|
127
150
|
body.writeDoubleLE(s.rps, o); o += 8;
|
|
128
151
|
body.writeDoubleLE(s.bytesInPerSec, o); o += 8;
|
|
129
152
|
body.writeDoubleLE(s.bytesOutPerSec, o); o += 8;
|
|
@@ -165,7 +188,7 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
|
|
|
165
188
|
}
|
|
166
189
|
|
|
167
190
|
/**
|
|
168
|
-
* Encode the
|
|
191
|
+
* Encode the v3 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
|
|
169
192
|
* u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
|
|
170
193
|
* A synthetic gentle ramp so a dev dashboard draws a non-flat line.
|
|
171
194
|
*/
|
|
@@ -186,6 +186,7 @@ enum DbOp {
|
|
|
186
186
|
Exists,
|
|
187
187
|
Create,
|
|
188
188
|
Patch,
|
|
189
|
+
Upsert,
|
|
189
190
|
Delete,
|
|
190
191
|
GetDelete,
|
|
191
192
|
Enqueue,
|
|
@@ -211,6 +212,13 @@ enum DbOp {
|
|
|
211
212
|
EventsSince,
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** `data.upsert` return tags (ABI.md), mirroring the guest `UpsertResult` enum. */
|
|
216
|
+
enum UpsertResult {
|
|
217
|
+
Created = 0,
|
|
218
|
+
Updated = 1,
|
|
219
|
+
Conflict = 2,
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
function isReadOp(op: DbOp): boolean {
|
|
215
223
|
return (
|
|
216
224
|
op === DbOp.Get ||
|
|
@@ -238,6 +246,7 @@ function kindAllows(kind: DbFunctionKind, op: DbOp): boolean {
|
|
|
238
246
|
(isReadOp(op) && !isScanOp(op)) ||
|
|
239
247
|
op === DbOp.Create ||
|
|
240
248
|
op === DbOp.Patch ||
|
|
249
|
+
op === DbOp.Upsert ||
|
|
241
250
|
op === DbOp.Delete ||
|
|
242
251
|
op === DbOp.GetDelete ||
|
|
243
252
|
op === DbOp.Enqueue ||
|
|
@@ -821,6 +830,39 @@ export class DevDatabase {
|
|
|
821
830
|
return this.replayRecordOutcome(db, outcome);
|
|
822
831
|
}
|
|
823
832
|
|
|
833
|
+
// Create-or-overwrite in ONE op: a blind last-writer-wins put. Returns
|
|
834
|
+
// UPSERT_CREATED (0) / UPSERT_UPDATED (1), or UPSERT_CONFLICT (2) when a
|
|
835
|
+
// @unique value is held by ANOTHER record (nothing written). A blind put is
|
|
836
|
+
// naturally idempotent (re-applying the same value is a no-op beyond the
|
|
837
|
+
// Created/Updated tag), so `idem` needs no dedup ledger; it is accepted for
|
|
838
|
+
// ABI parity and ignored, mirroring how the edge merely threads it into the
|
|
839
|
+
// oplog env.
|
|
840
|
+
upsert(
|
|
841
|
+
ref: MemoryRef,
|
|
842
|
+
db: DbDevState,
|
|
843
|
+
handle: number,
|
|
844
|
+
keyPtr: number,
|
|
845
|
+
keyLen: number,
|
|
846
|
+
valPtr: number,
|
|
847
|
+
valLen: number,
|
|
848
|
+
_idemPtr: number,
|
|
849
|
+
): number {
|
|
850
|
+
const coll = collForOp(db, handle, DbOp.Upsert, CollectionFamily.Record);
|
|
851
|
+
if (typeof coll === 'number') return coll;
|
|
852
|
+
if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
|
|
853
|
+
const key = readKey(ref, keyPtr, keyLen);
|
|
854
|
+
const value = readCopy(ref, valPtr, valLen);
|
|
855
|
+
const sk = storeKey(coll.name, key);
|
|
856
|
+
// @unique: a value already held by ANOTHER record blocks the upsert (the
|
|
857
|
+
// one outcome a blind put cannot auto-resolve), exactly like the edge op.
|
|
858
|
+
if (this.uniqueConflict(coll, value, sk)) return UpsertResult.Conflict;
|
|
859
|
+
const existed = this.store.has(sk);
|
|
860
|
+
this.store.set(sk, value);
|
|
861
|
+
this.stampVersion(coll, sk); // stamp the value type's current schema version
|
|
862
|
+
this.recordWrite(db, coll);
|
|
863
|
+
return existed ? UpsertResult.Updated : UpsertResult.Created;
|
|
864
|
+
}
|
|
865
|
+
|
|
824
866
|
delete(
|
|
825
867
|
ref: MemoryRef,
|
|
826
868
|
db: DbDevState,
|
|
@@ -1603,6 +1645,15 @@ export function buildDatabaseImports(
|
|
|
1603
1645
|
idemPtr: number,
|
|
1604
1646
|
): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen, idemPtr),
|
|
1605
1647
|
|
|
1648
|
+
'data.upsert': (
|
|
1649
|
+
handle: number,
|
|
1650
|
+
keyPtr: number,
|
|
1651
|
+
keyLen: number,
|
|
1652
|
+
valPtr: number,
|
|
1653
|
+
valLen: number,
|
|
1654
|
+
idemPtr: number,
|
|
1655
|
+
): number => devDb.upsert(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
|
|
1656
|
+
|
|
1606
1657
|
'data.delete': (handle: number, keyPtr: number, keyLen: number, idemPtr: number): number =>
|
|
1607
1658
|
devDb.delete(ref, db, handle, keyPtr, keyLen, idemPtr),
|
|
1608
1659
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dev-server analytics stub parity: the `env.analytics_read` / `env.analytics_series` dev imports must
|
|
3
|
-
* encode the
|
|
3
|
+
* encode the v3 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
|
|
4
4
|
* decode is identical in dev and prod. We decode the stashed frame positionally, exactly as the guest.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
@@ -16,7 +16,7 @@ function harness() {
|
|
|
16
16
|
return { imports: buildAnalyticsImports(ref, db), db };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
describe('dev analytics stub
|
|
19
|
+
describe('dev analytics stub v3 frame parity', () => {
|
|
20
20
|
it('analytics_read encodes the fixed-layout snapshot frame', () => {
|
|
21
21
|
const { imports, db } = harness();
|
|
22
22
|
const n = imports.analytics_read(0, 0);
|
|
@@ -29,7 +29,7 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
29
29
|
const u64 = () => { const v = buf.readBigUInt64LE(p); p += 8; return v; };
|
|
30
30
|
const f64 = () => { const v = buf.readDoubleLE(p); p += 8; return v; };
|
|
31
31
|
|
|
32
|
-
expect(u16()).toBe(
|
|
32
|
+
expect(u16()).toBe(3); // FRAME_VERSION
|
|
33
33
|
u64(); // now_ms
|
|
34
34
|
const count = u32();
|
|
35
35
|
expect(count).toBe(42); // METRIC_COUNTERS (edge N_COUNTERS; no WasmDispatches)
|
|
@@ -44,10 +44,10 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
44
44
|
expect(life[41]).toBe(100n); // CacheMisses (id 41, was 42)
|
|
45
45
|
expect(u64()).toBe(3n); // connectedStreams
|
|
46
46
|
expect(u64()).toBe(65536n); // committedMemory
|
|
47
|
-
expect(u64()).toBe(
|
|
48
|
-
expect(u64()).toBe(
|
|
49
|
-
expect(u64()).toBe(
|
|
50
|
-
expect(u64()).toBe(
|
|
47
|
+
expect(u64()).toBe(1_200n); // reqBurstUsed (current 3-min tumbling bucket)
|
|
48
|
+
expect(u64()).toBe(180_000n); // reqBurstCap (sustained 1000 rps * 180s window)
|
|
49
|
+
expect(u64()).toBe(250_000n); // req30dUsed (current 30-day bucket)
|
|
50
|
+
expect(u64()).toBe(10_000_000n); // req30dCap (30-day quota)
|
|
51
51
|
// 7 LIVE per-second rates (f64 LE), appended after the windows.
|
|
52
52
|
expect(f64()).toBeCloseTo(0.7); // rps
|
|
53
53
|
expect(f64()).toBeCloseTo(68.2); // bytesInPerSec
|
|
@@ -66,7 +66,7 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
66
66
|
const buf = db.lastResult as unknown as Buffer;
|
|
67
67
|
expect(n).toBe(buf.length);
|
|
68
68
|
let p = 0;
|
|
69
|
-
expect(buf.readUInt16LE(p)).toBe(
|
|
69
|
+
expect(buf.readUInt16LE(p)).toBe(3); p += 2; // version
|
|
70
70
|
expect(buf.readUInt16LE(p)).toBe(0); p += 2; // metric id
|
|
71
71
|
expect(buf.readUInt32LE(p)).toBe(3600); p += 4; // bucketSecs
|
|
72
72
|
p += 8; // headMs
|