pipework 0.264.2 → 0.265.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/CHANGELOG.md +8 -0
- package/REFERENCE.md +7 -4
- package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
- package/dist/data/migrate/internal-definitions.js +2 -1
- package/dist/data/migrate/internal-definitions.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/infra/alerts/sources.d.ts +1 -1
- package/dist/infra/alerts/sources.d.ts.map +1 -1
- package/dist/infra/alerts/sources.js +27 -10
- package/dist/infra/alerts/sources.js.map +1 -1
- package/dist/infra/quota/handle.d.ts +27 -3
- package/dist/infra/quota/handle.d.ts.map +1 -1
- package/dist/infra/quota/handle.js +128 -31
- package/dist/infra/quota/handle.js.map +1 -1
- package/dist/infra/quota/index.d.ts +1 -1
- package/dist/infra/quota/index.d.ts.map +1 -1
- package/dist/infra/quota/index.js.map +1 -1
- package/dist/infra/quota/schema.d.ts +20 -0
- package/dist/infra/quota/schema.d.ts.map +1 -1
- package/dist/infra/quota/schema.js +12 -0
- package/dist/infra/quota/schema.js.map +1 -1
- package/dist/test/setup.d.ts.map +1 -1
- package/dist/test/setup.js +4 -3
- package/dist/test/setup.js.map +1 -1
- package/package.json +1 -1
- package/src/data/migrate/internal-definitions.ts +2 -1
- package/src/index.ts +2 -1
- package/src/infra/alerts/sources.ts +27 -10
- package/src/infra/quota/handle.ts +157 -35
- package/src/infra/quota/index.ts +1 -0
- package/src/infra/quota/schema.ts +13 -0
- package/src/test/setup.ts +4 -3
|
@@ -387,11 +387,17 @@ function untilText(reset: Date, now: Date): string {
|
|
|
387
387
|
return day === now.toISOString().slice(0, 10) ? hhmm : `${day} ${hhmm}`
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
function topCallers(r: Record<string, unknown>): { caller: string; sent: number; refused: number }[] {
|
|
391
|
+
const raw = typeof r['top_callers'] === 'string' ? JSON.parse(r['top_callers']) as unknown : r['top_callers']
|
|
392
|
+
if (!Array.isArray(raw)) return []
|
|
393
|
+
return raw.map((c: { caller: unknown; sent: unknown; refused: unknown }) => ({ caller: String(c.caller), sent: Number(c.sent), refused: Number(c.refused) }))
|
|
394
|
+
}
|
|
395
|
+
|
|
390
396
|
/**
|
|
391
397
|
* Raises `quota-exhausted:<key>` while an upstream quota row (needs `quota` configured) has remaining at or
|
|
392
398
|
* under `floor` (default 0) before its reset, or refused a call since it was last recorded; resolves when the
|
|
393
399
|
* row recovers or its reset passes. `keys` limits it to those keys (a non-empty array or a RegExp). Conditions
|
|
394
|
-
* carry `owner`, `runbook` and a context of `key`, `remaining`, `limit`, `resetAt` and `
|
|
400
|
+
* carry `owner`, `runbook` and a context of `key`, `remaining`, `limit`, `resetAt`, `refused` and `topCallers` (the three callers that spent the most in the window, from `quota` spend counts).
|
|
395
401
|
*/
|
|
396
402
|
export function quotaExhaustedSource(options: { owner?: string; runbook?: string; keys?: readonly string[] | RegExp; floor?: number; severity?: AlertSeverity; database?: string } = {}): AlertSource {
|
|
397
403
|
const floor = options.floor ?? 0
|
|
@@ -403,7 +409,7 @@ export function quotaExhaustedSource(options: { owner?: string; runbook?: string
|
|
|
403
409
|
throw new Error('[pipework] alerts.source.quotaExhausted keys must not be empty; omit it to watch every key.')
|
|
404
410
|
}
|
|
405
411
|
const keyFilter = Array.isArray(keys)
|
|
406
|
-
? sql`AND key IN (SELECT jsonb_array_elements_text(${JSON.stringify([...new Set(keys as readonly string[])])}::jsonb))`
|
|
412
|
+
? sql`AND q.key IN (SELECT jsonb_array_elements_text(${JSON.stringify([...new Set(keys as readonly string[])])}::jsonb))`
|
|
407
413
|
: sql``
|
|
408
414
|
const base = querySource({
|
|
409
415
|
name: 'quota-exhausted',
|
|
@@ -411,19 +417,29 @@ export function quotaExhaustedSource(options: { owner?: string; runbook?: string
|
|
|
411
417
|
...(options.database !== undefined ? { database: options.database } : {}),
|
|
412
418
|
...ownerFields(options),
|
|
413
419
|
query: sql`
|
|
414
|
-
SELECT key, remaining, "limit", reset_at,
|
|
415
|
-
(remaining > ${floor} AND refused_at IS NOT NULL AND refused_at >= updated_at) AS refused_only,
|
|
416
|
-
(refused_at IS NOT NULL AND refused_at >= updated_at) AS refused
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
420
|
+
SELECT q.key, q.remaining, q."limit", q.reset_at,
|
|
421
|
+
(q.remaining > ${floor} AND q.refused_at IS NOT NULL AND q.refused_at >= q.updated_at) AS refused_only,
|
|
422
|
+
(q.refused_at IS NOT NULL AND q.refused_at >= q.updated_at) AS refused,
|
|
423
|
+
COALESCE(top.callers, '[]'::jsonb) AS top_callers
|
|
424
|
+
FROM outbound_quota q
|
|
425
|
+
LEFT JOIN LATERAL (
|
|
426
|
+
SELECT jsonb_agg(jsonb_build_object('caller', caller, 'sent', sent, 'refused', refused)
|
|
427
|
+
ORDER BY sent + refused DESC, caller) AS callers
|
|
428
|
+
FROM (SELECT caller, sent, refused FROM outbound_quota_spend s
|
|
429
|
+
WHERE s.key = q.key AND s.window_end = q.reset_at
|
|
430
|
+
ORDER BY sent + refused DESC, caller LIMIT 3) t
|
|
431
|
+
) top ON true
|
|
432
|
+
WHERE q.reset_at > now() ${keyFilter}
|
|
433
|
+
AND (q.remaining <= ${floor} OR (q.refused_at IS NOT NULL AND q.refused_at >= q.updated_at))
|
|
434
|
+
ORDER BY q.key`,
|
|
421
435
|
id: r => String(r['key']),
|
|
422
436
|
message: r => {
|
|
423
437
|
const remaining = Number(r['remaining'])
|
|
424
438
|
const limit = r['limit'] === null ? null : Number(r['limit'])
|
|
425
439
|
const head = limit === null ? `${String(r['key'])} ${String(remaining)} remaining` : `${String(r['key'])} ${String(remaining)}/${String(limit)}`
|
|
426
|
-
|
|
440
|
+
const top = topCallers(r)
|
|
441
|
+
const spenders = top.length === 0 ? '' : `; top: ${top.map(c => `${c.caller} ${String(c.sent + c.refused)}`).join(', ')}`
|
|
442
|
+
return `${head} until ${untilText(new Date(r['reset_at'] as string | Date), new Date())}${r['refused_only'] === true ? '; a call was refused' : ''}${spenders}`
|
|
427
443
|
},
|
|
428
444
|
subject: r => ({ entity: 'quota', entityId: String(r['key']) }),
|
|
429
445
|
context: r => ({
|
|
@@ -432,6 +448,7 @@ export function quotaExhaustedSource(options: { owner?: string; runbook?: string
|
|
|
432
448
|
limit: r['limit'] === null ? null : Number(r['limit']),
|
|
433
449
|
resetAt: new Date(r['reset_at'] as string | Date).toISOString(),
|
|
434
450
|
refused: r['refused'] === true,
|
|
451
|
+
topCallers: topCallers(r),
|
|
435
452
|
}),
|
|
436
453
|
})
|
|
437
454
|
if (!(keys instanceof RegExp)) return base
|
|
@@ -19,6 +19,8 @@ export interface QuotaBudget {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface QuotaConfig<C extends string> {
|
|
22
|
+
/** How many of a key's newest windows keep their per-caller counts; older ones are deleted when a new window starts. Default 48. */
|
|
23
|
+
readonly retainWindows?: number
|
|
22
24
|
/** Remaining count each class keeps in reserve for others: a call of class c is refused while remaining <= reserve[c]. Classes absent here have reserve 0 (refused only at zero). */
|
|
23
25
|
readonly reserve?: Partial<Record<C, number>>
|
|
24
26
|
/** Reads the budget from a response's headers; default reads GitHub-style headers. Return null when the response carries none. */
|
|
@@ -45,13 +47,37 @@ export interface QuotaCallOptions<R> {
|
|
|
45
47
|
readonly retryUnsent?: true | ConnectRetryOptions
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
/** Calls of one caller in one window: `sent` took a unit, `refused` was refused at the reserve. */
|
|
51
|
+
export interface QuotaCallerSpend<K extends string = string> {
|
|
52
|
+
readonly caller: K | 'unlabelled'
|
|
53
|
+
readonly sent: number
|
|
54
|
+
readonly refused: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QuotaWindowSpend<K extends string = string> {
|
|
58
|
+
readonly resetAt: Date
|
|
59
|
+
/** Sorted by sent + refused descending, then caller. */
|
|
60
|
+
readonly callers: QuotaCallerSpend<K>[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface QuotaSpend<K extends string = string> {
|
|
64
|
+
/** The window the key's live budget row is in; null when none is live. */
|
|
65
|
+
readonly current: QuotaWindowSpend<K> | null
|
|
66
|
+
/** The newest window before `current` (the newest recorded one when none is live). */
|
|
67
|
+
readonly previous: QuotaWindowSpend<K> | null
|
|
68
|
+
/** Calls made before any window was recorded; they move into the first window a response records. */
|
|
69
|
+
readonly unplaced: QuotaCallerSpend<K>[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Quota<C extends string, K extends string = string> {
|
|
73
|
+
/** Takes one unit of the upstream's budget for this class, sends, and records the budget from the response headers. `caller` labels the call in `spend`; without it the call counts as 'unlabelled'. */
|
|
74
|
+
call<R>(target: { key: string; class: C; caller?: K }, send: () => Promise<R>, opts?: QuotaCallOptions<R>): Promise<R>
|
|
51
75
|
/** Records the budget from headers of a call sent through the caller's own client. */
|
|
52
76
|
record(key: string, headers: QuotaHeaderInput): Promise<void>
|
|
53
77
|
/** The recorded budget; null when there is none or its window has passed. */
|
|
54
78
|
state(key: string): Promise<QuotaState | null>
|
|
79
|
+
/** Calls counted per caller for the key's current and previous window. A response recorded with `record` instead of `call` is not counted. */
|
|
80
|
+
spend(key: string): Promise<QuotaSpend<K>>
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
function intHeader(headers: QuotaHeaders, name: string): number | null {
|
|
@@ -105,13 +131,44 @@ function logRecordFailure(key: string, err: unknown): void {
|
|
|
105
131
|
void recordFault('quota-record', faultMessage(err), { key })
|
|
106
132
|
}
|
|
107
133
|
|
|
108
|
-
|
|
134
|
+
const DEFAULT_RETAIN_WINDOWS = 48
|
|
135
|
+
const UNLABELLED = 'unlabelled'
|
|
136
|
+
|
|
137
|
+
const toDate = (v: unknown): Date => new Date(v as string | Date)
|
|
138
|
+
|
|
139
|
+
export function createQuota<C extends string, K extends string = string>(config: QuotaConfig<C> = {}): Quota<C, K> {
|
|
140
|
+
const retain = config.retainWindows ?? DEFAULT_RETAIN_WINDOWS
|
|
141
|
+
if (!Number.isInteger(retain) || retain < 1) {
|
|
142
|
+
throw new Error(`[pipework] quota.create retainWindows must be a positive integer, got ${String(config.retainWindows)}.`)
|
|
143
|
+
}
|
|
109
144
|
if (config.connection !== undefined && config.database !== undefined) {
|
|
110
145
|
throw new Error('[pipework] quota.create takes connection or database, not both.')
|
|
111
146
|
}
|
|
112
147
|
const read = config.read ?? defaultRead
|
|
113
148
|
const db = (): DB => config.connection !== undefined ? config.connection.drizzle : resolveDb(config.database)
|
|
114
149
|
|
|
150
|
+
/** A new live window began: calls counted before any window was known move into it, and windows past the retention are deleted. */
|
|
151
|
+
async function startWindow(tx: DB, key: string): Promise<void> {
|
|
152
|
+
await tx.execute(sql`
|
|
153
|
+
WITH moved AS (
|
|
154
|
+
DELETE FROM outbound_quota_spend WHERE key = ${key} AND window_end = 'infinity'::timestamptz
|
|
155
|
+
RETURNING caller, sent, refused)
|
|
156
|
+
INSERT INTO outbound_quota_spend (key, caller, window_end, sent, refused)
|
|
157
|
+
SELECT ${key}, caller, (SELECT reset_at FROM outbound_quota WHERE key = ${key}), sent, refused FROM moved
|
|
158
|
+
ON CONFLICT (key, caller, window_end) DO UPDATE SET
|
|
159
|
+
sent = outbound_quota_spend.sent + EXCLUDED.sent,
|
|
160
|
+
refused = outbound_quota_spend.refused + EXCLUDED.refused,
|
|
161
|
+
updated_at = clock_timestamp()`)
|
|
162
|
+
await tx.execute(sql`
|
|
163
|
+
DELETE FROM outbound_quota_spend
|
|
164
|
+
WHERE key = ${key} AND window_end <> ALL (
|
|
165
|
+
SELECT window_end FROM (
|
|
166
|
+
SELECT window_end FROM outbound_quota_spend WHERE key = ${key} AND window_end <> 'infinity'::timestamptz
|
|
167
|
+
UNION SELECT reset_at FROM outbound_quota WHERE key = ${key}
|
|
168
|
+
ORDER BY window_end DESC LIMIT ${retain}) kept)
|
|
169
|
+
AND window_end <> 'infinity'::timestamptz`)
|
|
170
|
+
}
|
|
171
|
+
|
|
115
172
|
async function record(key: string, input: QuotaHeaderInput): Promise<void> {
|
|
116
173
|
const headers = toHeaders(input)
|
|
117
174
|
const budget = read(headers)
|
|
@@ -121,26 +178,36 @@ export function createQuota<C extends string>(config: QuotaConfig<C> = {}): Quot
|
|
|
121
178
|
if (budget !== null) {
|
|
122
179
|
// Same window: lowest remaining. Different window: a live response (reset in the future) replaces the row; a past reset is ignored.
|
|
123
180
|
const limit = budget.limit ?? null
|
|
124
|
-
await conn.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
remaining
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
181
|
+
await conn.transaction(async (tx: DB) => {
|
|
182
|
+
const before = await tx.execute(sql`SELECT reset_at FROM outbound_quota WHERE key = ${key} FOR UPDATE`)
|
|
183
|
+
const prior = before[0] === undefined ? null : toDate(before[0]['reset_at'])
|
|
184
|
+
const after = await tx.execute(sql`
|
|
185
|
+
INSERT INTO outbound_quota (key, remaining, "limit", reset_at, updated_at)
|
|
186
|
+
VALUES (${key}, ${budget.remaining}, ${limit}, ${iso(budget.resetAt)}::timestamptz, clock_timestamp())
|
|
187
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
188
|
+
remaining = CASE
|
|
189
|
+
WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
|
|
190
|
+
THEN LEAST(outbound_quota.remaining, EXCLUDED.remaining)
|
|
191
|
+
WHEN EXCLUDED.reset_at > now() THEN EXCLUDED.remaining
|
|
192
|
+
ELSE outbound_quota.remaining END,
|
|
193
|
+
"limit" = CASE
|
|
194
|
+
WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
|
|
195
|
+
OR EXCLUDED.reset_at > now()
|
|
196
|
+
THEN COALESCE(EXCLUDED."limit", outbound_quota."limit")
|
|
197
|
+
ELSE outbound_quota."limit" END,
|
|
198
|
+
reset_at = CASE
|
|
199
|
+
WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
|
|
200
|
+
THEN outbound_quota.reset_at
|
|
201
|
+
WHEN EXCLUDED.reset_at > now() THEN EXCLUDED.reset_at
|
|
202
|
+
ELSE outbound_quota.reset_at END,
|
|
203
|
+
updated_at = clock_timestamp()
|
|
204
|
+
RETURNING reset_at, reset_at > now() AS live`)
|
|
205
|
+
const row = after[0]
|
|
206
|
+
if (row === undefined || row['live'] !== true) return
|
|
207
|
+
const reset = toDate(row['reset_at'])
|
|
208
|
+
if (prior !== null && Math.floor(prior.getTime() / 1000) === Math.floor(reset.getTime() / 1000)) return
|
|
209
|
+
await startWindow(tx, key)
|
|
210
|
+
})
|
|
144
211
|
}
|
|
145
212
|
if (retryAfter !== null) {
|
|
146
213
|
await conn.execute(sql`
|
|
@@ -165,32 +232,87 @@ export function createQuota<C extends string>(config: QuotaConfig<C> = {}): Quot
|
|
|
165
232
|
}
|
|
166
233
|
}
|
|
167
234
|
|
|
168
|
-
async function take(key: string, cls: string): Promise<void> {
|
|
235
|
+
async function take(key: string, cls: string, caller: string): Promise<void> {
|
|
169
236
|
const conn = db()
|
|
170
237
|
const reserve = config.reserve?.[cls as C] ?? 0
|
|
238
|
+
// The unit and its count are one statement, so a count and a spend cannot disagree.
|
|
171
239
|
const taken = await conn.execute(sql`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
240
|
+
WITH taken AS (
|
|
241
|
+
UPDATE outbound_quota SET remaining = remaining - 1, updated_at = clock_timestamp()
|
|
242
|
+
WHERE key = ${key} AND reset_at > now() AND remaining > ${reserve}
|
|
243
|
+
RETURNING key, reset_at)
|
|
244
|
+
INSERT INTO outbound_quota_spend (key, caller, window_end, sent)
|
|
245
|
+
SELECT key, ${caller}, reset_at, 1 FROM taken
|
|
246
|
+
ON CONFLICT (key, caller, window_end) DO UPDATE SET
|
|
247
|
+
sent = outbound_quota_spend.sent + 1, updated_at = clock_timestamp()
|
|
248
|
+
RETURNING 1 AS counted`)
|
|
175
249
|
if (taken.length > 0) return
|
|
176
250
|
const rows = await conn.execute(sql`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
251
|
+
WITH asked AS (
|
|
252
|
+
UPDATE outbound_quota SET refused_at = clock_timestamp()
|
|
253
|
+
WHERE key = ${key} AND reset_at > now()
|
|
254
|
+
RETURNING key, remaining, reset_at),
|
|
255
|
+
counted AS (
|
|
256
|
+
INSERT INTO outbound_quota_spend (key, caller, window_end, refused)
|
|
257
|
+
SELECT key, ${caller}, reset_at, 1 FROM asked
|
|
258
|
+
ON CONFLICT (key, caller, window_end) DO UPDATE SET
|
|
259
|
+
refused = outbound_quota_spend.refused + 1, updated_at = clock_timestamp()
|
|
260
|
+
RETURNING 1)
|
|
261
|
+
SELECT remaining, reset_at FROM asked`)
|
|
180
262
|
const row = rows[0]
|
|
181
|
-
if (row === undefined)
|
|
263
|
+
if (row === undefined) {
|
|
264
|
+
// unknown budget: send optimistically, counted until a response places the window
|
|
265
|
+
await conn.execute(sql`
|
|
266
|
+
INSERT INTO outbound_quota_spend (key, caller, window_end, sent)
|
|
267
|
+
VALUES (${key}, ${caller}, 'infinity'::timestamptz, 1)
|
|
268
|
+
ON CONFLICT (key, caller, window_end) DO UPDATE SET
|
|
269
|
+
sent = outbound_quota_spend.sent + 1, updated_at = clock_timestamp()`)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
182
272
|
throw new QuotaExhaustedError({
|
|
183
273
|
key, class: cls, reserve,
|
|
184
274
|
remaining: Number(row['remaining']),
|
|
185
|
-
resetAt:
|
|
275
|
+
resetAt: toDate(row['reset_at']),
|
|
186
276
|
})
|
|
187
277
|
}
|
|
188
278
|
|
|
279
|
+
async function spend(key: string): Promise<QuotaSpend<K>> {
|
|
280
|
+
const conn = db()
|
|
281
|
+
const live = await conn.execute(sql`SELECT reset_at FROM outbound_quota WHERE key = ${key} AND reset_at > now()`)
|
|
282
|
+
const liveAt = live[0] === undefined ? null : toDate(live[0]['reset_at']).getTime()
|
|
283
|
+
const rows = await conn.execute(sql`
|
|
284
|
+
SELECT caller, sent, refused,
|
|
285
|
+
CASE WHEN window_end = 'infinity'::timestamptz THEN NULL ELSE window_end END AS window_end
|
|
286
|
+
FROM outbound_quota_spend WHERE key = ${key}`)
|
|
287
|
+
const byWindow = new Map<number, QuotaCallerSpend<K>[]>()
|
|
288
|
+
const unplaced: QuotaCallerSpend<K>[] = []
|
|
289
|
+
for (const r of rows) {
|
|
290
|
+
const entry: QuotaCallerSpend<K> = { caller: String(r['caller']) as K | 'unlabelled', sent: Number(r['sent']), refused: Number(r['refused']) }
|
|
291
|
+
if (r['window_end'] === null) { unplaced.push(entry); continue }
|
|
292
|
+
const at = toDate(r['window_end']).getTime()
|
|
293
|
+
const list = byWindow.get(at)
|
|
294
|
+
if (list === undefined) byWindow.set(at, [entry]); else list.push(entry)
|
|
295
|
+
}
|
|
296
|
+
const order = (list: QuotaCallerSpend<K>[]): QuotaCallerSpend<K>[] => list.sort((a, b) =>
|
|
297
|
+
(b.sent + b.refused) - (a.sent + a.refused) || (a.caller < b.caller ? -1 : a.caller > b.caller ? 1 : 0))
|
|
298
|
+
const windowOf = (at: number | null): QuotaWindowSpend<K> | null =>
|
|
299
|
+
at === null ? null : { resetAt: new Date(at), callers: order(byWindow.get(at) ?? []) }
|
|
300
|
+
const finite = [...byWindow.keys()].sort((a, b) => b - a)
|
|
301
|
+
const currentAt = liveAt
|
|
302
|
+
const previousAt = finite.find(at => currentAt === null || at < currentAt) ?? null
|
|
303
|
+
return {
|
|
304
|
+
current: currentAt === null ? null : windowOf(currentAt),
|
|
305
|
+
previous: windowOf(previousAt),
|
|
306
|
+
unplaced: order(unplaced),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
189
310
|
return {
|
|
190
311
|
record,
|
|
191
312
|
state,
|
|
192
|
-
|
|
193
|
-
|
|
313
|
+
spend,
|
|
314
|
+
async call<R>(target: { key: string; class: C; caller?: K }, send: () => Promise<R>, opts: QuotaCallOptions<R> = {}): Promise<R> {
|
|
315
|
+
await take(target.key, target.class, target.caller ?? UNLABELLED)
|
|
194
316
|
let result: R
|
|
195
317
|
try {
|
|
196
318
|
const o = opts.retryUnsent
|
package/src/infra/quota/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createQuota } from './handle.js'
|
|
2
2
|
export type {
|
|
3
3
|
Quota, QuotaConfig, QuotaHeaders, QuotaHeaderInput, QuotaBudget, QuotaState, QuotaCallOptions,
|
|
4
|
+
QuotaSpend, QuotaWindowSpend, QuotaCallerSpend,
|
|
4
5
|
} from './handle.js'
|
|
5
6
|
export { QuotaExhaustedError } from './errors.js'
|
|
@@ -13,3 +13,16 @@ export const outboundQuota = define('outbound_quota', {
|
|
|
13
13
|
/** When a call was last refused against this row; it counts while it is not older than `updatedAt`. */
|
|
14
14
|
refusedAt: field.timestamp().nullable(),
|
|
15
15
|
})
|
|
16
|
+
|
|
17
|
+
// Calls per (key, caller, window): `window_end` is the budget row's reset_at; 'infinity' holds calls made
|
|
18
|
+
// before any window was known, moved into the first window a response records.
|
|
19
|
+
export const outboundQuotaSpend = define('outbound_quota_spend', {
|
|
20
|
+
key: field.text(),
|
|
21
|
+
caller: field.text(),
|
|
22
|
+
windowEnd: field.timestamp(),
|
|
23
|
+
sent: field.integer().default(0),
|
|
24
|
+
refused: field.integer().default(0),
|
|
25
|
+
updatedAt: field.timestamp().default('now'),
|
|
26
|
+
}, {
|
|
27
|
+
primaryKey: ['key', 'caller', 'windowEnd'],
|
|
28
|
+
})
|
package/src/test/setup.ts
CHANGED
|
@@ -1176,7 +1176,7 @@ async function readAnchorAcl(anchorUrl: string, anchorRole: string): Promise<Anc
|
|
|
1176
1176
|
* `provisionTemplateRole`) does not reach it. Read those grants from the anchor
|
|
1177
1177
|
* and make them on the template, which every clone inherits. Only schemas and
|
|
1178
1178
|
* objects the template carries are granted; each grantor grants what it owns, and
|
|
1179
|
-
* a grant nobody could make is
|
|
1179
|
+
* a grant nobody could make is a warning naming the privilege.
|
|
1180
1180
|
*/
|
|
1181
1181
|
async function grantAnchorSchemas(
|
|
1182
1182
|
anchorUrl: string,
|
|
@@ -1261,9 +1261,10 @@ async function grantAnchorSchemas(
|
|
|
1261
1261
|
await check.end()
|
|
1262
1262
|
}
|
|
1263
1263
|
if (missing.length > 0) {
|
|
1264
|
-
|
|
1264
|
+
console.warn(
|
|
1265
1265
|
`[pipework] The anchor's app role "${anchorRole}" holds privileges the clone app role "${roleName}" could not be given, ` +
|
|
1266
|
-
`because neither the test login nor the migration owner owns the object: ${missing.join('; ')}
|
|
1266
|
+
`because neither the test login nor the migration owner owns the object: ${missing.join('; ')}. ` +
|
|
1267
|
+
`A test that needs one of them will fail with permission denied.`,
|
|
1267
1268
|
)
|
|
1268
1269
|
}
|
|
1269
1270
|
}
|