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.
@@ -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 `refused`.
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
- FROM outbound_quota
418
- WHERE reset_at > now() ${keyFilter}
419
- AND (remaining <= ${floor} OR (refused_at IS NOT NULL AND refused_at >= updated_at))
420
- ORDER BY key`,
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
- return `${head} until ${untilText(new Date(r['reset_at'] as string | Date), new Date())}${r['refused_only'] === true ? '; a call was refused' : ''}`
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
- export interface Quota<C extends string> {
49
- /** Takes one unit of the upstream's budget for this class, sends, and records the budget from the response headers. */
50
- call<R>(target: { key: string; class: C }, send: () => Promise<R>, opts?: QuotaCallOptions<R>): Promise<R>
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
- export function createQuota<C extends string>(config: QuotaConfig<C> = {}): Quota<C> {
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.execute(sql`
125
- INSERT INTO outbound_quota (key, remaining, "limit", reset_at, updated_at)
126
- VALUES (${key}, ${budget.remaining}, ${limit}, ${iso(budget.resetAt)}::timestamptz, clock_timestamp())
127
- ON CONFLICT (key) DO UPDATE SET
128
- remaining = CASE
129
- WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
130
- THEN LEAST(outbound_quota.remaining, EXCLUDED.remaining)
131
- WHEN EXCLUDED.reset_at > now() THEN EXCLUDED.remaining
132
- ELSE outbound_quota.remaining END,
133
- "limit" = CASE
134
- WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
135
- OR EXCLUDED.reset_at > now()
136
- THEN COALESCE(EXCLUDED."limit", outbound_quota."limit")
137
- ELSE outbound_quota."limit" END,
138
- reset_at = CASE
139
- WHEN date_trunc('second', outbound_quota.reset_at) = date_trunc('second', EXCLUDED.reset_at)
140
- THEN outbound_quota.reset_at
141
- WHEN EXCLUDED.reset_at > now() THEN EXCLUDED.reset_at
142
- ELSE outbound_quota.reset_at END,
143
- updated_at = clock_timestamp()`)
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
- UPDATE outbound_quota SET remaining = remaining - 1, updated_at = clock_timestamp()
173
- WHERE key = ${key} AND reset_at > now() AND remaining > ${reserve}
174
- RETURNING remaining`)
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
- UPDATE outbound_quota SET refused_at = clock_timestamp()
178
- WHERE key = ${key} AND reset_at > now()
179
- RETURNING remaining, reset_at`)
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) return // unknown budget: send optimistically
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: new Date(row['reset_at'] as string | Date),
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
- async call<R>(target: { key: string; class: C }, send: () => Promise<R>, opts: QuotaCallOptions<R> = {}): Promise<R> {
193
- await take(target.key, target.class)
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
@@ -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 an error naming the object.
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
- throw new Error(
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
  }