@wtfalch/reporting 0.1.0 → 0.1.2
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/dist/alerts.js +0 -4
- package/dist/bin/migrations.js +1 -7
- package/dist/fake/index.js +1 -6
- package/dist/housekeeping/index.d.ts +1 -3
- package/dist/housekeeping/index.js +4 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -2
- package/dist/migrations/0001_reporting.sql +3 -4
- package/dist/next/index.js +7 -1
- package/dist/schema.d.ts +12 -5
- package/dist/schema.js +10 -3
- package/dist/settings.d.ts +0 -1
- package/dist/settings.js +0 -1
- package/dist/tables.d.ts +2 -36
- package/dist/tables.js +13 -6
- package/dist/types.d.ts +0 -2
- package/dist/writer.d.ts +9 -3
- package/dist/writer.js +25 -13
- package/package.json +1 -1
package/dist/alerts.js
CHANGED
|
@@ -32,12 +32,8 @@ export function projectDetail(detail) {
|
|
|
32
32
|
out[key] = Number.isFinite(value) ? value : String(value);
|
|
33
33
|
else if (typeof value === 'string')
|
|
34
34
|
out[key] = value.slice(0, 500);
|
|
35
|
-
else if (Array.isArray(value))
|
|
36
|
-
out[key] = `[${value.length} items]`;
|
|
37
35
|
else
|
|
38
36
|
out[key] = '[nested]';
|
|
39
|
-
if (Object.keys(out).length >= 40)
|
|
40
|
-
break;
|
|
41
37
|
}
|
|
42
38
|
return out;
|
|
43
39
|
}
|
package/dist/bin/migrations.js
CHANGED
|
@@ -12,11 +12,5 @@ import { copyMigrations, describeCopy } from './copy.js';
|
|
|
12
12
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const from = join(here, '..', 'migrations');
|
|
14
14
|
const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
version = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8')).version;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
version = undefined;
|
|
21
|
-
}
|
|
15
|
+
const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
|
|
22
16
|
console.log(describeCopy(copyMigrations({ from, to, version })));
|
package/dist/fake/index.js
CHANGED
|
@@ -16,11 +16,6 @@ export function createFakeReporting(opts = {}) {
|
|
|
16
16
|
tables,
|
|
17
17
|
rows,
|
|
18
18
|
alerts,
|
|
19
|
-
// Not a database; a host's test that reaches `db` through the fake is
|
|
20
|
-
// testing the wrong thing, and this makes that loud.
|
|
21
|
-
get db() {
|
|
22
|
-
throw new Error('createFakeReporting has no database');
|
|
23
|
-
},
|
|
24
19
|
event(input) {
|
|
25
20
|
const v = eventInputSchema.parse(input);
|
|
26
21
|
id += 1;
|
|
@@ -93,7 +88,7 @@ export function createFakeReporting(opts = {}) {
|
|
|
93
88
|
return { before, after: settings, by, changed };
|
|
94
89
|
},
|
|
95
90
|
},
|
|
96
|
-
stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length
|
|
91
|
+
stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
|
|
97
92
|
reset() {
|
|
98
93
|
rows.length = 0;
|
|
99
94
|
alerts.length = 0;
|
|
@@ -43,12 +43,10 @@ export interface TaskContext {
|
|
|
43
43
|
}
|
|
44
44
|
export interface HousekeepingOptions {
|
|
45
45
|
reporting: Reporting;
|
|
46
|
-
db
|
|
46
|
+
db: Db;
|
|
47
47
|
now?: () => Date;
|
|
48
48
|
/** Per process. Default 60,000 ms. */
|
|
49
49
|
debounceMs?: number;
|
|
50
|
-
/** Tasks run at once. Default 2. */
|
|
51
|
-
concurrency?: number;
|
|
52
50
|
}
|
|
53
51
|
export interface Housekeeping {
|
|
54
52
|
/** Idempotent by name. The row is created on the next tick. */
|
|
@@ -2,12 +2,13 @@ import { eq, sql } from 'drizzle-orm';
|
|
|
2
2
|
import { KIND_PATTERN } from '../schema.js';
|
|
3
3
|
import { reportingTasks } from '../tables.js';
|
|
4
4
|
import { describe } from '../writer.js';
|
|
5
|
+
/** Tasks run at once: enough that a slow prune does not delay the alerts, few enough to be one connection each. */
|
|
6
|
+
const CONCURRENCY = 2;
|
|
5
7
|
export function createHousekeeping(options) {
|
|
6
8
|
const { reporting } = options;
|
|
7
|
-
const db = options.db
|
|
9
|
+
const db = options.db;
|
|
8
10
|
const now = options.now ?? (() => new Date());
|
|
9
11
|
const debounceMs = options.debounceMs ?? 60_000;
|
|
10
|
-
const concurrency = Math.max(1, options.concurrency ?? 2);
|
|
11
12
|
const registry = new Map();
|
|
12
13
|
let lastTick = 0;
|
|
13
14
|
let ticking = null;
|
|
@@ -132,7 +133,7 @@ export function createHousekeeping(options) {
|
|
|
132
133
|
const settings = await reporting.settings.get();
|
|
133
134
|
const queue = [...registry.values()];
|
|
134
135
|
const counts = { claimed: 0, skipped: 0, ran: 0, failed: 0 };
|
|
135
|
-
const workers = Array.from({ length: Math.min(
|
|
136
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
136
137
|
for (let task = queue.shift(); task; task = queue.shift()) {
|
|
137
138
|
try {
|
|
138
139
|
const outcome = await runOne(task, settings);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export * from './schema.js';
|
|
|
3
3
|
export * from './tables.js';
|
|
4
4
|
export * from './types.js';
|
|
5
5
|
export { alertKind, projectDetail } from './alerts.js';
|
|
6
|
-
export { DEFAULT_SETTINGS, RETENTION_BOUNDS
|
|
6
|
+
export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
7
7
|
/**
|
|
8
8
|
* The host's one instance, built once over its drizzle handle and its
|
|
9
9
|
* logger. Framework-neutral: `defer` is how the host says "after this unit of
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export * from './schema.js';
|
|
|
8
8
|
export * from './tables.js';
|
|
9
9
|
export * from './types.js';
|
|
10
10
|
export { alertKind, projectDetail } from './alerts.js';
|
|
11
|
-
export { DEFAULT_SETTINGS, RETENTION_BOUNDS
|
|
11
|
+
export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
12
12
|
function modeFromEnv() {
|
|
13
13
|
const env = process.env.NODE_ENV;
|
|
14
14
|
return env === 'development' || env === 'test' ? env : 'production';
|
|
@@ -39,7 +39,6 @@ export function createReporting(options) {
|
|
|
39
39
|
});
|
|
40
40
|
const reporting = {
|
|
41
41
|
site,
|
|
42
|
-
db: options.db,
|
|
43
42
|
log: options.log,
|
|
44
43
|
tables,
|
|
45
44
|
event: (input) => writer.event(input),
|
|
@@ -48,7 +48,9 @@ create table if not exists reporting_events (
|
|
|
48
48
|
-- provider response or a request body sneaks in whole. `strict`, because
|
|
49
49
|
-- lax mode unwraps an array before the filter sees it and would never
|
|
50
50
|
-- call one an array.
|
|
51
|
-
|
|
51
|
+
-- `silent`, so a non-object `data` fails the object check by name rather
|
|
52
|
+
-- than raising 22033 from this one, whichever the planner evaluates first.
|
|
53
|
+
constraint reporting_events_data_flat_check check (not jsonb_path_exists(data, 'strict $.* ? (@.type() == "object" || @.type() == "array")', '{}', true)),
|
|
52
54
|
-- The keys that carry a person or a secret by name. A guardrail, not a
|
|
53
55
|
-- proof: src/schema.ts refuses the same list before a row is queued, and
|
|
54
56
|
-- schema.test.ts pins the two lists equal.
|
|
@@ -86,9 +88,6 @@ create table if not exists reporting_tasks (
|
|
|
86
88
|
claim_token uuid,
|
|
87
89
|
last_outcome text,
|
|
88
90
|
last_error text,
|
|
89
|
-
-- Free for a task's own bookkeeping: the analytics rollup keeps its
|
|
90
|
-
-- completed-day watermark here.
|
|
91
|
-
watermark text,
|
|
92
91
|
constraint reporting_tasks_task_check check (task ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
|
|
93
92
|
constraint reporting_tasks_outcome_check check (last_outcome is null or last_outcome in ('ok', 'error', 'skipped')),
|
|
94
93
|
constraint reporting_tasks_error_check check (last_error is null or length(last_error) <= 512)
|
package/dist/next/index.js
CHANGED
|
@@ -19,6 +19,8 @@ export function healthHandler(housekeeping) {
|
|
|
19
19
|
return Response.json({ ok: true, ts: new Date().toISOString() });
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
/** `<16 hex>-<colo>`, as Cloudflare emits it. */
|
|
23
|
+
const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
|
|
22
24
|
/**
|
|
23
25
|
* Read once where a request enters (a Server Action, a route handler, a
|
|
24
26
|
* page's data component) and handed down. Outside a request scope it returns
|
|
@@ -30,8 +32,12 @@ export async function requestContext() {
|
|
|
30
32
|
try {
|
|
31
33
|
const h = await headers();
|
|
32
34
|
const country = h.get('cf-ipcountry');
|
|
35
|
+
// A ray id is Cloudflare's or it is nothing: a request that reached the
|
|
36
|
+
// origin some other way (the Docker network, a preview host) could
|
|
37
|
+
// otherwise choose the id stamped on the audit rows of this request.
|
|
38
|
+
const ray = h.get('cf-ray');
|
|
33
39
|
return {
|
|
34
|
-
id:
|
|
40
|
+
id: ray && RAY_PATTERN.test(ray) ? ray : randomUUID(),
|
|
35
41
|
ip: h.get('cf-connecting-ip'),
|
|
36
42
|
userAgent: h.get('user-agent'),
|
|
37
43
|
country: country && /^[A-Z]{2}$/.test(country) ? country : null,
|
package/dist/schema.d.ts
CHANGED
|
@@ -28,9 +28,16 @@ export declare const LIMITS: {
|
|
|
28
28
|
readonly dataBytes: 16384;
|
|
29
29
|
readonly site: 64;
|
|
30
30
|
};
|
|
31
|
-
/** Flat, bounded,
|
|
31
|
+
/** Flat, bounded, banned keys refused, numbers finite. */
|
|
32
32
|
export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
|
|
33
33
|
export type FlatData = z.infer<typeof flatDataSchema>;
|
|
34
|
+
/**
|
|
35
|
+
* `z.object`, not strict: a host hands over its resolved principal, which
|
|
36
|
+
* carries a display name and an address beside `class` and `id`, and those
|
|
37
|
+
* must be dropped rather than refused. Stripping is the privacy behaviour;
|
|
38
|
+
* refusing would make every host's call site narrow by hand, and the first
|
|
39
|
+
* one did not.
|
|
40
|
+
*/
|
|
34
41
|
export declare const actorSchema: z.ZodObject<{
|
|
35
42
|
class: z.ZodEnum<{
|
|
36
43
|
human: "human";
|
|
@@ -39,12 +46,12 @@ export declare const actorSchema: z.ZodObject<{
|
|
|
39
46
|
service: "service";
|
|
40
47
|
}>;
|
|
41
48
|
id: z.ZodString;
|
|
42
|
-
}, z.core.$
|
|
49
|
+
}, z.core.$strip>;
|
|
43
50
|
export type Actor = z.infer<typeof actorSchema>;
|
|
44
51
|
export declare const targetSchema: z.ZodObject<{
|
|
45
52
|
type: z.ZodString;
|
|
46
53
|
id: z.ZodString;
|
|
47
|
-
}, z.core.$
|
|
54
|
+
}, z.core.$strip>;
|
|
48
55
|
/** What a caller hands `event()`. Everything else on the row is the writer's. */
|
|
49
56
|
export declare const eventInputSchema: z.ZodObject<{
|
|
50
57
|
kind: z.ZodString;
|
|
@@ -64,12 +71,12 @@ export declare const eventInputSchema: z.ZodObject<{
|
|
|
64
71
|
service: "service";
|
|
65
72
|
}>;
|
|
66
73
|
id: z.ZodString;
|
|
67
|
-
}, z.core.$
|
|
74
|
+
}, z.core.$strip>>>;
|
|
68
75
|
requestId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
69
76
|
target: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
70
77
|
type: z.ZodString;
|
|
71
78
|
id: z.ZodString;
|
|
72
|
-
}, z.core.$
|
|
79
|
+
}, z.core.$strip>>>;
|
|
73
80
|
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
|
|
74
81
|
occurredAt: z.ZodOptional<z.ZodDate>;
|
|
75
82
|
}, z.core.$strict>;
|
package/dist/schema.js
CHANGED
|
@@ -36,7 +36,7 @@ export const LIMITS = {
|
|
|
36
36
|
site: 64,
|
|
37
37
|
};
|
|
38
38
|
const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
39
|
-
/** Flat, bounded,
|
|
39
|
+
/** Flat, bounded, banned keys refused, numbers finite. */
|
|
40
40
|
export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ctx) => {
|
|
41
41
|
for (const key of Object.keys(data)) {
|
|
42
42
|
if (BANNED_KEYS.includes(key)) {
|
|
@@ -55,11 +55,18 @@ export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ct
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
57
|
const bounded = (max) => z.string().min(1).max(max);
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `z.object`, not strict: a host hands over its resolved principal, which
|
|
60
|
+
* carries a display name and an address beside `class` and `id`, and those
|
|
61
|
+
* must be dropped rather than refused. Stripping is the privacy behaviour;
|
|
62
|
+
* refusing would make every host's call site narrow by hand, and the first
|
|
63
|
+
* one did not.
|
|
64
|
+
*/
|
|
65
|
+
export const actorSchema = z.object({
|
|
59
66
|
class: z.enum(ACTOR_CLASSES),
|
|
60
67
|
id: bounded(LIMITS.actorId),
|
|
61
68
|
});
|
|
62
|
-
export const targetSchema = z.
|
|
69
|
+
export const targetSchema = z.object({
|
|
63
70
|
type: bounded(LIMITS.targetType),
|
|
64
71
|
id: bounded(LIMITS.targetId),
|
|
65
72
|
});
|
package/dist/settings.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export declare const RETENTION_BOUNDS: {
|
|
|
9
9
|
readonly min: 7;
|
|
10
10
|
readonly max: 400;
|
|
11
11
|
};
|
|
12
|
-
export declare const SETTING_KEYS: (keyof Settings)[];
|
|
13
12
|
export declare const DEFAULT_SETTINGS: Settings;
|
|
14
13
|
export declare function getSettings(db: Db): Promise<Settings>;
|
|
15
14
|
/** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
|
package/dist/settings.js
CHANGED
package/dist/tables.d.ts
CHANGED
|
@@ -240,7 +240,7 @@ export declare const reportingEvents: import("drizzle-orm/pg-core").PgTableWithC
|
|
|
240
240
|
data: Record<string, string | number | boolean | null>;
|
|
241
241
|
driverParam: unknown;
|
|
242
242
|
notNull: true;
|
|
243
|
-
hasDefault:
|
|
243
|
+
hasDefault: true;
|
|
244
244
|
isPrimaryKey: false;
|
|
245
245
|
isAutoincrement: false;
|
|
246
246
|
hasRuntimeDefault: false;
|
|
@@ -411,23 +411,6 @@ export declare const reportingTasks: import("drizzle-orm/pg-core").PgTableWithCo
|
|
|
411
411
|
identity: undefined;
|
|
412
412
|
generated: undefined;
|
|
413
413
|
}, {}, {}>;
|
|
414
|
-
watermark: import("drizzle-orm/pg-core").PgColumn<{
|
|
415
|
-
name: "watermark";
|
|
416
|
-
tableName: "reporting_tasks";
|
|
417
|
-
dataType: "string";
|
|
418
|
-
columnType: "PgText";
|
|
419
|
-
data: string;
|
|
420
|
-
driverParam: string;
|
|
421
|
-
notNull: false;
|
|
422
|
-
hasDefault: false;
|
|
423
|
-
isPrimaryKey: false;
|
|
424
|
-
isAutoincrement: false;
|
|
425
|
-
hasRuntimeDefault: false;
|
|
426
|
-
enumValues: [string, ...string[]];
|
|
427
|
-
baseColumn: never;
|
|
428
|
-
identity: undefined;
|
|
429
|
-
generated: undefined;
|
|
430
|
-
}, {}, {}>;
|
|
431
414
|
};
|
|
432
415
|
dialect: "pg";
|
|
433
416
|
}>;
|
|
@@ -745,7 +728,7 @@ export declare const tables: {
|
|
|
745
728
|
data: Record<string, string | number | boolean | null>;
|
|
746
729
|
driverParam: unknown;
|
|
747
730
|
notNull: true;
|
|
748
|
-
hasDefault:
|
|
731
|
+
hasDefault: true;
|
|
749
732
|
isPrimaryKey: false;
|
|
750
733
|
isAutoincrement: false;
|
|
751
734
|
hasRuntimeDefault: false;
|
|
@@ -916,23 +899,6 @@ export declare const tables: {
|
|
|
916
899
|
identity: undefined;
|
|
917
900
|
generated: undefined;
|
|
918
901
|
}, {}, {}>;
|
|
919
|
-
watermark: import("drizzle-orm/pg-core").PgColumn<{
|
|
920
|
-
name: "watermark";
|
|
921
|
-
tableName: "reporting_tasks";
|
|
922
|
-
dataType: "string";
|
|
923
|
-
columnType: "PgText";
|
|
924
|
-
data: string;
|
|
925
|
-
driverParam: string;
|
|
926
|
-
notNull: false;
|
|
927
|
-
hasDefault: false;
|
|
928
|
-
isPrimaryKey: false;
|
|
929
|
-
isAutoincrement: false;
|
|
930
|
-
hasRuntimeDefault: false;
|
|
931
|
-
enumValues: [string, ...string[]];
|
|
932
|
-
baseColumn: never;
|
|
933
|
-
identity: undefined;
|
|
934
|
-
generated: undefined;
|
|
935
|
-
}, {}, {}>;
|
|
936
902
|
};
|
|
937
903
|
dialect: "pg";
|
|
938
904
|
}>;
|
package/dist/tables.js
CHANGED
|
@@ -21,15 +21,23 @@ export const reportingEvents = pgTable('reporting_events', {
|
|
|
21
21
|
targetType: text('target_type'),
|
|
22
22
|
targetId: text('target_id'),
|
|
23
23
|
message: text('message').notNull(),
|
|
24
|
-
data: jsonb('data')
|
|
24
|
+
data: jsonb('data')
|
|
25
|
+
.$type()
|
|
26
|
+
.notNull()
|
|
27
|
+
.default(sql `'{}'::jsonb`),
|
|
25
28
|
schemaVersion: smallint('schema_version').notNull().default(1),
|
|
26
29
|
}, (t) => [
|
|
27
30
|
index('reporting_events_time_idx').on(t.occurredAt.desc(), t.id.desc()),
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
index('reporting_events_tenant_time_idx')
|
|
32
|
+
.on(t.tenantId, t.occurredAt.desc(), t.id.desc())
|
|
33
|
+
.where(sql `${t.tenantId} is not null`),
|
|
30
34
|
index('reporting_events_ns_time_idx').on(t.kindNs, t.occurredAt.desc(), t.id.desc()),
|
|
31
|
-
index('reporting_events_problem_time_idx')
|
|
32
|
-
|
|
35
|
+
index('reporting_events_problem_time_idx')
|
|
36
|
+
.on(t.occurredAt.desc(), t.id.desc())
|
|
37
|
+
.where(sql `${t.level} in ('error', 'alert')`),
|
|
38
|
+
index('reporting_events_request_idx')
|
|
39
|
+
.on(t.requestId, t.occurredAt.desc())
|
|
40
|
+
.where(sql `${t.requestId} is not null`),
|
|
33
41
|
]);
|
|
34
42
|
export const reportingTasks = pgTable('reporting_tasks', {
|
|
35
43
|
task: text('task').primaryKey(),
|
|
@@ -40,7 +48,6 @@ export const reportingTasks = pgTable('reporting_tasks', {
|
|
|
40
48
|
claimToken: uuid('claim_token'),
|
|
41
49
|
lastOutcome: text('last_outcome'),
|
|
42
50
|
lastError: text('last_error'),
|
|
43
|
-
watermark: text('watermark'),
|
|
44
51
|
});
|
|
45
52
|
export const reportingSettings = pgTable('reporting_settings', {
|
|
46
53
|
key: text('key').primaryKey(),
|
package/dist/types.d.ts
CHANGED
|
@@ -79,7 +79,6 @@ export interface Stats {
|
|
|
79
79
|
readonly dropped: number;
|
|
80
80
|
readonly invalid: number;
|
|
81
81
|
readonly flushed: number;
|
|
82
|
-
readonly failedFlushes: number;
|
|
83
82
|
}
|
|
84
83
|
export interface Reporting {
|
|
85
84
|
/** Never throws in production; see writer.ts. */
|
|
@@ -99,7 +98,6 @@ export interface Reporting {
|
|
|
99
98
|
};
|
|
100
99
|
readonly tables: typeof tables;
|
|
101
100
|
readonly site: string;
|
|
102
|
-
readonly db: Db;
|
|
103
101
|
readonly log: Logger;
|
|
104
102
|
stats(): Stats;
|
|
105
103
|
}
|
package/dist/writer.d.ts
CHANGED
|
@@ -20,18 +20,24 @@ export declare class Writer {
|
|
|
20
20
|
private droppedSinceReport;
|
|
21
21
|
private invalid;
|
|
22
22
|
private flushed;
|
|
23
|
-
private failedFlushes;
|
|
24
23
|
constructor(o: WriterOptions);
|
|
25
24
|
event(input: EventInput): void;
|
|
26
25
|
private toRow;
|
|
27
26
|
private enqueue;
|
|
28
27
|
/** Once per idle period through `defer`, and always on the timer. */
|
|
29
28
|
private schedule;
|
|
30
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Drain until the queue is empty and nothing is in flight, or the deadline.
|
|
31
|
+
* Two callers overlap all the time (the timer and a request's `after()`,
|
|
32
|
+
* the tick and its own timer), so "empty" is not enough: a row another
|
|
33
|
+
* flush spliced off the queue a moment ago is still on its way, and a
|
|
34
|
+
* caller that returned on an empty queue would read the table too early.
|
|
35
|
+
* Every caller therefore waits for whatever is in flight and looks again.
|
|
36
|
+
*/
|
|
31
37
|
flush(opts?: {
|
|
32
38
|
deadlineMs?: number;
|
|
33
39
|
}): Promise<void>;
|
|
34
|
-
|
|
40
|
+
/** One insert of one batch. Resolves true on success; never throws. */
|
|
35
41
|
private flushOnce;
|
|
36
42
|
stats(): Stats;
|
|
37
43
|
}
|
package/dist/writer.js
CHANGED
|
@@ -10,7 +10,6 @@ export class Writer {
|
|
|
10
10
|
droppedSinceReport = 0;
|
|
11
11
|
invalid = 0;
|
|
12
12
|
flushed = 0;
|
|
13
|
-
failedFlushes = 0;
|
|
14
13
|
constructor(o) {
|
|
15
14
|
this.o = o;
|
|
16
15
|
}
|
|
@@ -95,25 +94,40 @@ export class Writer {
|
|
|
95
94
|
this.timer.unref?.();
|
|
96
95
|
}
|
|
97
96
|
}
|
|
98
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Drain until the queue is empty and nothing is in flight, or the deadline.
|
|
99
|
+
* Two callers overlap all the time (the timer and a request's `after()`,
|
|
100
|
+
* the tick and its own timer), so "empty" is not enough: a row another
|
|
101
|
+
* flush spliced off the queue a moment ago is still on its way, and a
|
|
102
|
+
* caller that returned on an empty queue would read the table too early.
|
|
103
|
+
* Every caller therefore waits for whatever is in flight and looks again.
|
|
104
|
+
*/
|
|
99
105
|
async flush(opts = {}) {
|
|
100
106
|
const deadline = Date.now() + (opts.deadlineMs ?? 5000);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
while (this.queue.length > 0 && Date.now() < deadline) {
|
|
104
|
-
this.inFlight = this.flushOnce();
|
|
105
|
-
try {
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
if (this.inFlight) {
|
|
106
109
|
await this.inFlight;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (this.queue.length === 0)
|
|
113
|
+
break;
|
|
114
|
+
const attempt = this.flushOnce();
|
|
115
|
+
this.inFlight = attempt;
|
|
116
|
+
let ok = false;
|
|
117
|
+
try {
|
|
118
|
+
ok = await attempt;
|
|
107
119
|
}
|
|
108
120
|
finally {
|
|
109
121
|
this.inFlight = null;
|
|
110
122
|
}
|
|
111
|
-
|
|
123
|
+
// A failed insert leaves its rows queued; do not spin on the database
|
|
124
|
+
// inside one call. The timer and the next defer try again.
|
|
125
|
+
if (!ok)
|
|
112
126
|
break;
|
|
113
127
|
}
|
|
114
128
|
this.deferred = false;
|
|
115
129
|
}
|
|
116
|
-
|
|
130
|
+
/** One insert of one batch. Resolves true on success; never throws. */
|
|
117
131
|
async flushOnce() {
|
|
118
132
|
const batch = this.queue.splice(0, this.o.batchSize);
|
|
119
133
|
if (this.droppedSinceReport > 0) {
|
|
@@ -133,11 +147,9 @@ export class Writer {
|
|
|
133
147
|
try {
|
|
134
148
|
await this.o.db.insert(reportingEvents).values(batch);
|
|
135
149
|
this.flushed += batch.length;
|
|
136
|
-
|
|
150
|
+
return true;
|
|
137
151
|
}
|
|
138
152
|
catch (error) {
|
|
139
|
-
this.failedFlushes += 1;
|
|
140
|
-
this.lastFailed = true;
|
|
141
153
|
// Back at the front, so order survives; the bound still applies, and
|
|
142
154
|
// whatever does not fit is counted as dropped rather than kept for ever.
|
|
143
155
|
const room = Math.max(0, this.o.queueLimit - this.queue.length);
|
|
@@ -146,6 +158,7 @@ export class Writer {
|
|
|
146
158
|
this.droppedSinceReport += batch.length - kept.length;
|
|
147
159
|
this.queue.unshift(...kept);
|
|
148
160
|
this.o.log.error({ err: describe(error), rows: batch.length, queued: this.queue.length }, 'reporting: flush failed; rows stay queued');
|
|
161
|
+
return false;
|
|
149
162
|
}
|
|
150
163
|
}
|
|
151
164
|
stats() {
|
|
@@ -154,7 +167,6 @@ export class Writer {
|
|
|
154
167
|
dropped: this.dropped,
|
|
155
168
|
invalid: this.invalid,
|
|
156
169
|
flushed: this.flushed,
|
|
157
|
-
failedFlushes: this.failedFlushes,
|
|
158
170
|
};
|
|
159
171
|
}
|
|
160
172
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/reporting",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The estate's operational event log and housekeeping tick: a writer that never fails a request, a claim protocol that needs no scheduler, and the readers an operator opens. Per-app Postgres, host-supplied logger, framework-neutral core with a Next entry.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|