@kedge/testing 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/package.json +34 -3
- package/src/budgets.ts +476 -0
- package/src/http.ts +435 -0
- package/src/index.ts +30 -0
- package/src/invariants.ts +308 -0
- package/src/ports.ts +207 -0
- package/src/requests.ts +68 -0
- package/src/scenario.ts +438 -0
- package/src/vitest.ts +11 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Kedge Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kedge/testing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/chandeldivyam/Kedge.git",
|
|
8
|
+
"directory": "packages/testing"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"description": "Kedge's test kit (K11): the invariant suite, a scenario DSL in workerd, a D1 request counter, a concurrency helper and the query budget catalog.",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./invariants": "./src/invariants.ts",
|
|
15
|
+
"./http": "./src/http.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@kedge/core": "0.9.0",
|
|
22
|
+
"@kedge/db": "0.9.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@cloudflare/vitest-plugin": "1.4.0",
|
|
26
|
+
"@cloudflare/workers-types": "5.20261024.1",
|
|
27
|
+
"wrangler": "4.152.0",
|
|
28
|
+
"@types/node": "24.19.2"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"vitest": "^4.1.12"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit -p .",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
}
|
|
6
37
|
}
|
package/src/budgets.ts
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// The query budget catalog (K11). Every statement on the checkout path, with
|
|
2
|
+
// its triggers, is named here with a bound on the rows it may read that
|
|
3
|
+
// depends only on the checkout's own shape (its lines, items, locations,
|
|
4
|
+
// plugins), never on how much history the store has. A statement that scans
|
|
5
|
+
// history passes every small test and then resets D1 on a store with a year
|
|
6
|
+
// of orders (part A: an unindexed integrity scan hit D1's CPU limit). The
|
|
7
|
+
// check runs each statement on a history-inflated fixture and compares D1's
|
|
8
|
+
// own rows_read with the bound.
|
|
9
|
+
|
|
10
|
+
export interface CheckoutShape {
|
|
11
|
+
/** Distinct variants in the cart. */
|
|
12
|
+
variants: number;
|
|
13
|
+
/** Inventory components per variant, at most. */
|
|
14
|
+
components: number;
|
|
15
|
+
/** Stock locations carrying the items, at most. */
|
|
16
|
+
locations: number;
|
|
17
|
+
lines: number;
|
|
18
|
+
reservations: number;
|
|
19
|
+
/** Installed plugins (plugin_state rows) and participants in this order. */
|
|
20
|
+
plugins: number;
|
|
21
|
+
participants: number;
|
|
22
|
+
facts: number;
|
|
23
|
+
/** Fee and adjustment rows, and adjustment allocations. */
|
|
24
|
+
moneyRows: number;
|
|
25
|
+
allocations: number;
|
|
26
|
+
// P0.4 (p0.4-dated-capacity.md §2.4, §4.3): capacity's shape.
|
|
27
|
+
/** Every stock location in the store (the claim reads them, by priority). */
|
|
28
|
+
storeLocations?: number;
|
|
29
|
+
/** Pools in the claim's scope, and the buckets it reads over them. */
|
|
30
|
+
pools?: number;
|
|
31
|
+
buckets?: number;
|
|
32
|
+
/** Holds the order inserts, and the buckets they hold. */
|
|
33
|
+
holds?: number;
|
|
34
|
+
heldBuckets?: number;
|
|
35
|
+
/** Components across the order's lines. */
|
|
36
|
+
orderComponents?: number;
|
|
37
|
+
/** A relay pass's rows (p0.6a §2.4): the timers it fires, the deliveries it leases. */
|
|
38
|
+
page?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StatementBudget {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Matches the statement's SQL text. */
|
|
44
|
+
match: RegExp;
|
|
45
|
+
bound: (s: CheckoutShape) => number;
|
|
46
|
+
/** Set when the statement is known to read history today, with the slice that fixes it. */
|
|
47
|
+
exempt?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const items = (s: CheckoutShape) => s.variants * s.components;
|
|
51
|
+
const pools = (s: CheckoutShape) => s.pools ?? 0;
|
|
52
|
+
const buckets = (s: CheckoutShape) => s.buckets ?? 0;
|
|
53
|
+
const holds = (s: CheckoutShape) => s.holds ?? 0;
|
|
54
|
+
const heldBuckets = (s: CheckoutShape) => s.heldBuckets ?? 0;
|
|
55
|
+
const orderComponents = (s: CheckoutShape) => s.orderComponents ?? s.lines * s.components;
|
|
56
|
+
/**
|
|
57
|
+
* An event's deliveries, written with it by 0026's trigger (p0.6a §2.2): an
|
|
58
|
+
* index probe, then per live subscription its row, its plugin's state, its
|
|
59
|
+
* participation and the obligation's unique key. Live subscriptions per event
|
|
60
|
+
* type are capped at 32 by a trigger, so this holds whatever the store installs.
|
|
61
|
+
*/
|
|
62
|
+
const SUBSCRIBER_FANOUT = 4 + 4 * 32;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An event's webhook deliveries, written with it by 0027's trigger (p0.6b
|
|
66
|
+
* §2.2): a probe of the type's selections, then per selecting endpoint (at
|
|
67
|
+
* most 16, by trigger) its row and the delivery's unique keys.
|
|
68
|
+
*/
|
|
69
|
+
const WEBHOOK_FANOUT = 4 + 4 * 16;
|
|
70
|
+
|
|
71
|
+
const FANOUT = SUBSCRIBER_FANOUT + WEBHOOK_FANOUT;
|
|
72
|
+
|
|
73
|
+
/** The write fence's marker (p0.4 §2.5), first and last in every core batch. */
|
|
74
|
+
const MARKER_BUDGETS: readonly StatementBudget[] = [
|
|
75
|
+
{ name: "batch.marker_open", match: /^INSERT INTO batch_marker/, bound: () => 2 },
|
|
76
|
+
// Clearing also reads its deferred key's target, commit_obligation (p0.5 §7.1).
|
|
77
|
+
{ name: "batch.marker_close", match: /^DELETE FROM batch_marker$/, bound: () => 3 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One timer descriptor's statements (p0.6a §3.2), wherever a batch carries
|
|
82
|
+
* them: checkout's commit, a subscriber's success. Each reads by the timer's
|
|
83
|
+
* key or the owning order's primary key; the insert's triggers check the
|
|
84
|
+
* plugin, the order's status and the per-order cap, by primary key.
|
|
85
|
+
*/
|
|
86
|
+
export const TIMER_BUDGETS: readonly StatementBudget[] = [
|
|
87
|
+
{ name: "timer.owner", match: /^INSERT OR IGNORE INTO timer_owner \(order_id\)/, bound: () => 2 },
|
|
88
|
+
{
|
|
89
|
+
name: "timer.assert_changed",
|
|
90
|
+
match: /^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_TIMER_CHANGED'/,
|
|
91
|
+
bound: () => 8,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "timer.assert_anchor",
|
|
95
|
+
match: /^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_TIMER_ANCHOR'/,
|
|
96
|
+
bound: () => 8,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "timer.assert_cause",
|
|
100
|
+
match: /^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_TIMER_CAUSE_CONFLICT'/,
|
|
101
|
+
bound: () => 8,
|
|
102
|
+
},
|
|
103
|
+
{ name: "timer.insert", match: /^INSERT INTO timer \(id, key, plugin, registration/, bound: () => 32 },
|
|
104
|
+
{
|
|
105
|
+
name: "timer.reschedule",
|
|
106
|
+
match: /^UPDATE timer SET generation = generation \+ 1, revision_id = \?2/,
|
|
107
|
+
bound: () => 24,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "timer.cancel",
|
|
111
|
+
match: /^UPDATE timer SET state = 'canceled', generation = generation \+ 1/,
|
|
112
|
+
bound: () => 24,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** Core's claim and commit statements, in batch order. */
|
|
117
|
+
export const CHECKOUT_BUDGETS: readonly StatementBudget[] = [
|
|
118
|
+
...MARKER_BUDGETS,
|
|
119
|
+
...TIMER_BUDGETS,
|
|
120
|
+
{ name: "claim.fence_upsert", match: /^\s*INSERT INTO checkout_fence/, bound: () => 4 },
|
|
121
|
+
// P0.4: expiry on touch seeks pending_expiry per resource in scope (§4.2);
|
|
122
|
+
// the orders it expires cost at most the work budget, measured remotely.
|
|
123
|
+
{
|
|
124
|
+
name: "claim.expire_on_touch",
|
|
125
|
+
match: /^UPDATE "order" SET status = 'expired', expired_at = \?5 WHERE id IN \(/,
|
|
126
|
+
// Per scoped resource, one walk of at most EXPIRY_SEEK seeks on pending_expiry's key.
|
|
127
|
+
// P0.6b: plus, per order it expires, the webhook fan-out's probe of the
|
|
128
|
+
// `order.expired` selections (§2.2); the fixture's history expires two.
|
|
129
|
+
bound: (s) => 10 * items(s) + 16 * pools(s) + 40 + 2 * 2,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "claim.overdue_remaining",
|
|
133
|
+
match: /^WITH RECURSIVE[\s\S]*SELECT count\(\*\) AS n FROM \(SELECT 1 FROM overdue v/,
|
|
134
|
+
bound: (s) => 10 * items(s) + 16 * pools(s) + 40,
|
|
135
|
+
},
|
|
136
|
+
{ name: "claim.fence_read", match: /^SELECT attempt_id FROM checkout_fence/, bound: () => 2 },
|
|
137
|
+
{ name: "claim.existing_order", match: /^SELECT id FROM "order" WHERE cart_id = \?/, bound: () => 2 },
|
|
138
|
+
{ name: "claim.variants", match: /^SELECT v\.id AS variant_id/, bound: (s) => 4 * items(s) + 4 },
|
|
139
|
+
{
|
|
140
|
+
name: "claim.levels",
|
|
141
|
+
match: /^\s*SELECT l\.inventory_item_id, l\.location_id, s\.priority/,
|
|
142
|
+
bound: (s) => 6 * items(s) * s.locations + 4,
|
|
143
|
+
},
|
|
144
|
+
{ name: "claim.plugin_state", match: /^SELECT plugin, state FROM plugin_state$/, bound: (s) => s.plugins + 1 },
|
|
145
|
+
// P0.3: the tax context (one row) and at most 5 required policies, each one indexed seek.
|
|
146
|
+
{
|
|
147
|
+
name: "claim.tax_setting",
|
|
148
|
+
match: /^SELECT inclusive, rate_revenue_ppm, rate_deposit_ppm FROM tax_setting/,
|
|
149
|
+
bound: () => 2,
|
|
150
|
+
},
|
|
151
|
+
{ name: "claim.policies", match: /^SELECT cp\.policy, \(SELECT r\.id FROM policy_revision/, bound: () => 5 * 3 + 2 },
|
|
152
|
+
// P0.4: the pinned rules, the locations, and the scope's pools, mappings, buffers and free buckets (§2.4).
|
|
153
|
+
{ name: "claim.store_time", match: /^SELECT tz_rules_version FROM store_time/, bound: () => 2 },
|
|
154
|
+
{
|
|
155
|
+
name: "claim.locations",
|
|
156
|
+
match: /^SELECT id, priority FROM stock_location/,
|
|
157
|
+
bound: (s) => (s.storeLocations ?? s.locations) + 1,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "claim.scope_pools",
|
|
161
|
+
match: /^WITH RECURSIVE[\s\S]*SELECT p\.id, p\.kind, p\.inventory_item_id/,
|
|
162
|
+
bound: (s) => 6 * items(s) + 6 * pools(s) + 8,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "claim.scope_map",
|
|
166
|
+
match: /^WITH RECURSIVE[\s\S]*SELECT DISTINCT mapping, key, pool_id FROM mapped/,
|
|
167
|
+
bound: (s) => 4 * items(s) + 4 * pools(s) + 8,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "claim.buffers",
|
|
171
|
+
match: /^WITH RECURSIVE[\s\S]*SELECT b\.kind, b\.pool_kind/,
|
|
172
|
+
bound: (s) => 4 * items(s) + 2 * s.lines + 8,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "claim.free",
|
|
176
|
+
match: /^WITH RECURSIVE[\s\S]*buf AS \(SELECT/,
|
|
177
|
+
// Per bucket: its capacity expression (base step, ceiling, adjustments) and held count;
|
|
178
|
+
// per pool and window, its first and last labels' seeks into the pinned transitions.
|
|
179
|
+
bound: (s) => 6 * items(s) + 20 * pools(s) + 16 * buckets(s) + 16,
|
|
180
|
+
},
|
|
181
|
+
{ name: "commit.order", match: /^\s*INSERT INTO "order" \(/, bound: () => 8 },
|
|
182
|
+
{ name: "commit.lines", match: /^\s*INSERT INTO order_line \(/, bound: (s) => 4 * s.lines + 4 },
|
|
183
|
+
// P0.4: components, versions and requirements by core SQL (§2.1), each insert checking the seal.
|
|
184
|
+
{
|
|
185
|
+
name: "commit.components",
|
|
186
|
+
match: /^\s*INSERT INTO order_line_component \(/,
|
|
187
|
+
bound: (s) => 8 * orderComponents(s) + 4 * s.lines + 4,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "commit.component_versions",
|
|
191
|
+
match: /^\s*INSERT INTO order_line_component_version \(/,
|
|
192
|
+
bound: (s) => 10 * orderComponents(s) + 4,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "commit.requirements",
|
|
196
|
+
match: /^\s*INSERT INTO order_line_component_requirement \(/,
|
|
197
|
+
bound: (s) => 10 * (s.reservations + holds(s) + orderComponents(s)) + 4,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "commit.holds",
|
|
201
|
+
match: /^\s*INSERT INTO capacity_hold \(/,
|
|
202
|
+
// Per held bucket: its row materialised, checked against capacity, counted and backstopped.
|
|
203
|
+
bound: (s) => 20 * holds(s) + 24 * heldBuckets(s) + 4,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "commit.place_by_sources",
|
|
207
|
+
match: /^\s*INSERT INTO order_place_by_source \(/,
|
|
208
|
+
bound: (s) => 6 * (2 * holds(s) + s.lines) + 4,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "commit.period_provenance",
|
|
212
|
+
match: /^\s*INSERT INTO order_line_period_provenance \(/,
|
|
213
|
+
bound: (s) => 6 * s.lines + 4,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "commit.reservations",
|
|
217
|
+
match: /^\s*INSERT INTO inventory_reservation \(/,
|
|
218
|
+
bound: (s) => 10 * s.reservations + 4,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "commit.participation",
|
|
222
|
+
match: /^INSERT INTO plugin_participation/,
|
|
223
|
+
// +1 under 0026: bisected to its order-status triggers (§7), which this
|
|
224
|
+
// insert doesn't fire; not reproduced in isolation, so stated as measured.
|
|
225
|
+
// +1 again under 0027, which recreates two of those triggers (p0.6b §5):
|
|
226
|
+
// the same unexplained step, constant over history, stated as measured.
|
|
227
|
+
bound: (s) => 4 * s.participants + 8,
|
|
228
|
+
},
|
|
229
|
+
{ name: "commit.contribution_facts", match: /^\s*INSERT INTO contribution_fact \(/, bound: (s) => 4 * s.facts + 4 },
|
|
230
|
+
{ name: "commit.fees", match: /^\s*INSERT INTO order_fee \(/, bound: (s) => 6 * s.moneyRows + 4 },
|
|
231
|
+
{ name: "commit.adjustments", match: /^\s*INSERT INTO order_adjustment \(/, bound: (s) => 6 * s.moneyRows + 4 },
|
|
232
|
+
{
|
|
233
|
+
name: "commit.allocations",
|
|
234
|
+
match: /^\s*INSERT INTO order_line_adjustment \(/,
|
|
235
|
+
bound: (s) => 6 * s.allocations + 4,
|
|
236
|
+
},
|
|
237
|
+
// P0.3: tax lines and acceptances, each insert checking its owner, revision and the seal.
|
|
238
|
+
{
|
|
239
|
+
name: "commit.tax_lines",
|
|
240
|
+
match: /^\s*INSERT INTO order_tax_line \(/,
|
|
241
|
+
bound: (s) => 8 * (s.lines + s.moneyRows) + 4,
|
|
242
|
+
},
|
|
243
|
+
{ name: "commit.acceptances", match: /^\s*INSERT INTO policy_acceptance \(/, bound: () => 5 * 12 + 4 },
|
|
244
|
+
{ name: "commit.order_event", match: /^INSERT INTO order_event/, bound: () => 4 },
|
|
245
|
+
// P0.4: every outbox insert also runs the shortage queue's trigger (its WHEN, mostly false).
|
|
246
|
+
// P0.6a: the event's deliveries are written with it.
|
|
247
|
+
{ name: "commit.outbox", match: /^INSERT INTO outbox_event/, bound: () => 8 + FANOUT },
|
|
248
|
+
{
|
|
249
|
+
name: "commit.contribution_check",
|
|
250
|
+
match: /^INSERT INTO contribution_check/,
|
|
251
|
+
// P0.3's checks, then P0.4's coverage, costs and provenance over the order's own rows.
|
|
252
|
+
bound: (s) =>
|
|
253
|
+
4 * (s.facts + s.moneyRows + s.allocations + s.lines) +
|
|
254
|
+
40 * (orderComponents(s) + s.reservations) +
|
|
255
|
+
30 * holds(s) +
|
|
256
|
+
16 * heldBuckets(s) +
|
|
257
|
+
24,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
/** Staff placement (and, later, the payment settler's): the event, the guarded transition with its triggers, the read-back. */
|
|
262
|
+
export const PLACEMENT_BUDGETS: readonly StatementBudget[] = [
|
|
263
|
+
...MARKER_BUDGETS,
|
|
264
|
+
// Placement's refusals, asserted ahead of its writes (codex code r10, D-058): the order
|
|
265
|
+
// by primary key, then kedge_assert's own trigger and the fence's marker read.
|
|
266
|
+
{
|
|
267
|
+
name: "place.assert_found",
|
|
268
|
+
match:
|
|
269
|
+
/^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_NOT_FOUND', EXISTS \(SELECT 1 FROM "order" WHERE id = \?\)/,
|
|
270
|
+
bound: () => 8,
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "place.assert_placeable",
|
|
274
|
+
match: /^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_NOT_PLACEABLE'/,
|
|
275
|
+
bound: () => 8,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "place.event",
|
|
279
|
+
match: /^\s*INSERT INTO order_event \(id, order_id, type, actor, created_at\)/,
|
|
280
|
+
// + the fence's marker read: order_event is fenced from P0.5 (§7.1).
|
|
281
|
+
bound: () => 3,
|
|
282
|
+
},
|
|
283
|
+
// The transition's triggers number the order, snapshot its lines into order.placed,
|
|
284
|
+
// write the transition row and run participating plugins' acquire SQL.
|
|
285
|
+
{
|
|
286
|
+
name: "place.transition",
|
|
287
|
+
match: /^\s*UPDATE "order" SET status = 'open'/,
|
|
288
|
+
// P0.4: plus deleting the order's pending_expiry rows, and the outbox trigger's WHEN.
|
|
289
|
+
// P0.6a: plus `order.placed`'s deliveries and the new status triggers' checks.
|
|
290
|
+
bound: (s) => 8 * s.lines + 4 * s.reservations + 4 * (holds(s) + 1) + 24 + FANOUT,
|
|
291
|
+
},
|
|
292
|
+
{ name: "place.order_read", match: /^SELECT id, cart_id, status, payment_status/, bound: () => 2 },
|
|
293
|
+
{
|
|
294
|
+
name: "place.lines_read",
|
|
295
|
+
match: /^SELECT id, variant_id, sku, title, unit_price, quantity, kind, parent_line_id/,
|
|
296
|
+
bound: (s) => s.lines + 1,
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
/** The minute cron's expiry chunk: seeks pending orders by (status, expires_at), bounded by its LIMIT. */
|
|
301
|
+
export const EXPIRY_BUDGETS: readonly StatementBudget[] = [
|
|
302
|
+
...MARKER_BUDGETS,
|
|
303
|
+
{
|
|
304
|
+
name: "cron.expire_due",
|
|
305
|
+
match: /^\s*UPDATE "order" SET status = 'expired', expired_at = \? WHERE id IN \(\s*SELECT id FROM \(/,
|
|
306
|
+
// Per expired order: its reservations and holds released, its costs, transition and obligations.
|
|
307
|
+
// P0.6a: plus `order.expired` and its deliveries, and the timers' epoch checks.
|
|
308
|
+
bound: (s) => 12 * (s.reservations + s.participants) + 16 * (holds(s) + heldBuckets(s)) + 32 + FANOUT,
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
export interface Measured {
|
|
313
|
+
sql: string;
|
|
314
|
+
rowsRead: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Records each statement's SQL and D1's rows_read, for batches and single statements. */
|
|
318
|
+
export function measureRows(real: D1Database): { db: D1Database; log: Measured[]; reset(): void } {
|
|
319
|
+
const log: Measured[] = [];
|
|
320
|
+
const sqlOf = new WeakMap<object, string>();
|
|
321
|
+
const realOf = new WeakMap<object, D1PreparedStatement>();
|
|
322
|
+
const wrap = (s: D1PreparedStatement, sql: string): D1PreparedStatement => {
|
|
323
|
+
const w = new Proxy(s, {
|
|
324
|
+
get(target, prop, receiver) {
|
|
325
|
+
if (prop === "bind") return (...a: unknown[]) => wrap(target.bind(...a), sql);
|
|
326
|
+
if (prop === "run" || prop === "all") {
|
|
327
|
+
return async (...a: unknown[]) => {
|
|
328
|
+
const r = (await (Reflect.get(target, prop, receiver) as (...x: unknown[]) => Promise<D1Result>).apply(
|
|
329
|
+
target,
|
|
330
|
+
a,
|
|
331
|
+
)) as D1Result;
|
|
332
|
+
log.push({ sql, rowsRead: r.meta.rows_read ?? 0 });
|
|
333
|
+
return r;
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const v = Reflect.get(target, prop, receiver);
|
|
337
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
sqlOf.set(w, sql);
|
|
341
|
+
realOf.set(w, s);
|
|
342
|
+
return w;
|
|
343
|
+
};
|
|
344
|
+
const db = new Proxy(real, {
|
|
345
|
+
get(target, prop, receiver) {
|
|
346
|
+
if (prop === "prepare") return (q: string) => wrap(target.prepare(q), q);
|
|
347
|
+
if (prop === "batch") {
|
|
348
|
+
return async (stmts: D1PreparedStatement[]) => {
|
|
349
|
+
const results = await target.batch(stmts.map((s) => realOf.get(s) ?? s));
|
|
350
|
+
results.forEach((r, i) => {
|
|
351
|
+
log.push({ sql: sqlOf.get(stmts[i] as object) ?? "?", rowsRead: r.meta.rows_read ?? 0 });
|
|
352
|
+
});
|
|
353
|
+
return results;
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const v = Reflect.get(target, prop, receiver);
|
|
357
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
db,
|
|
362
|
+
log,
|
|
363
|
+
reset: () => {
|
|
364
|
+
log.length = 0;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface BudgetViolation {
|
|
370
|
+
name: string;
|
|
371
|
+
sql: string;
|
|
372
|
+
rowsRead: number;
|
|
373
|
+
bound: number | "unnamed";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Checks measured statements against the catalog. A statement no budget names
|
|
378
|
+
* is a violation too: every statement on the path must be catalogued. Plugin
|
|
379
|
+
* statements (on plg_ tables or kedge_v1_ views) are checked against the
|
|
380
|
+
* plugin's declared `rowBudget`.
|
|
381
|
+
*/
|
|
382
|
+
export function overBudget(
|
|
383
|
+
measured: readonly Measured[],
|
|
384
|
+
shape: CheckoutShape,
|
|
385
|
+
pluginBudgets: Readonly<Record<string, number>> = {},
|
|
386
|
+
budgets: readonly StatementBudget[] = CHECKOUT_BUDGETS,
|
|
387
|
+
): BudgetViolation[] {
|
|
388
|
+
const out: BudgetViolation[] = [];
|
|
389
|
+
for (const m of measured) {
|
|
390
|
+
const sql = m.sql.trim();
|
|
391
|
+
const b = budgets.find((x) => x.match.test(m.sql));
|
|
392
|
+
if (b) {
|
|
393
|
+
if (b.exempt) continue;
|
|
394
|
+
const bound = b.bound(shape);
|
|
395
|
+
if (m.rowsRead > bound) out.push({ name: b.name, sql, rowsRead: m.rowsRead, bound });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const plugin = Object.keys(pluginBudgets).find((p) => sql.includes(`plg_${p}_`));
|
|
399
|
+
if (plugin !== undefined) {
|
|
400
|
+
const bound = pluginBudgets[plugin] as number;
|
|
401
|
+
if (m.rowsRead > bound) out.push({ name: `plugin:${plugin}`, sql, rowsRead: m.rowsRead, bound });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
out.push({ name: "unnamed", sql: sql.slice(0, 120), rowsRead: m.rowsRead, bound: "unnamed" });
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const page = (s: CheckoutShape) => s.page ?? 0;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The relay's statements (p0.6a §2.3, §3.3, §7): the clock's pass seeks timers
|
|
413
|
+
* by (state, due_at) and deliveries by (state, next_attempt_at), so its reads
|
|
414
|
+
* grow with the page it takes, never with the rows already done; a run's
|
|
415
|
+
* statements are by primary key.
|
|
416
|
+
*/
|
|
417
|
+
export const RELAY_BUDGETS: readonly StatementBudget[] = [
|
|
418
|
+
...MARKER_BUDGETS,
|
|
419
|
+
...TIMER_BUDGETS,
|
|
420
|
+
{
|
|
421
|
+
name: "clock.due_timers",
|
|
422
|
+
match: /^SELECT id, generation FROM timer WHERE state = 'scheduled' AND due_at <= \? ORDER BY due_at LIMIT \?$/,
|
|
423
|
+
bound: (s) => page(s) + 1,
|
|
424
|
+
},
|
|
425
|
+
// A firing's triggers write timer_firing, the event, its outbox row and its one delivery.
|
|
426
|
+
{ name: "clock.fire", match: /^\s*UPDATE timer SET state = 'fired', fired_at = \?1/, bound: () => 32 },
|
|
427
|
+
{
|
|
428
|
+
name: "clock.clean_dead",
|
|
429
|
+
match: /^\s*UPDATE timer SET state = 'canceled', generation = generation \+ 1, updated_at = \?1\s+WHERE id = \?2/,
|
|
430
|
+
bound: () => 16,
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: "clock.due_deliveries",
|
|
434
|
+
match:
|
|
435
|
+
/^SELECT seq, next_attempt_at FROM \w+ WHERE state = \? AND next_attempt_at <= \? ORDER BY next_attempt_at LIMIT \?$/,
|
|
436
|
+
bound: (s) => page(s) + 1,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: "clock.abandon_at_max",
|
|
440
|
+
match: /^\s*UPDATE \w+ SET state = 'failed', last_code = 'run_abandoned'/,
|
|
441
|
+
bound: (s) => 6 * page(s) + 4,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: "clock.lease",
|
|
445
|
+
match: /^\s*UPDATE \w+ SET lease_token = \?1, run_token = NULL/,
|
|
446
|
+
bound: (s) => 12 * page(s) + 8,
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "clock.next_due",
|
|
450
|
+
match: /^\s*SELECT \(SELECT min\(due_at\) FROM timer WHERE state = 'scheduled'\) AS timer/,
|
|
451
|
+
bound: () => 16,
|
|
452
|
+
},
|
|
453
|
+
{ name: "run.claim", match: /^\s*UPDATE \w+ SET run_token = \?1, lease_token = NULL,/, bound: () => 8 },
|
|
454
|
+
{
|
|
455
|
+
name: "run.fail",
|
|
456
|
+
match: /^\s*UPDATE \w+ SET run_token = NULL, lease_token = NULL, last_code = \?1/,
|
|
457
|
+
bound: () => 8,
|
|
458
|
+
},
|
|
459
|
+
// A success's batch: the run still owns it, its receipt, its end state.
|
|
460
|
+
{
|
|
461
|
+
name: "run.assert_owned",
|
|
462
|
+
match: /^INSERT INTO kedge_assert \(code, ok\) SELECT 'KEDGE_RUN_LOST'/,
|
|
463
|
+
bound: () => 10,
|
|
464
|
+
},
|
|
465
|
+
{ name: "run.receipt", match: /^INSERT INTO (subscriber|release|resolution)_delivery \(seq, /, bound: () => 8 },
|
|
466
|
+
{
|
|
467
|
+
name: "run.finalizing",
|
|
468
|
+
match: /^\s*UPDATE subscriber_obligation SET state = 'finalizing', final_reason = /,
|
|
469
|
+
bound: () => 8,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "run.done",
|
|
473
|
+
match: /^\s*UPDATE \w+ SET state = 'done', run_token = NULL, lease_token = NULL/,
|
|
474
|
+
bound: () => 8,
|
|
475
|
+
},
|
|
476
|
+
];
|