@adcp/sdk 16.0.0-beta.3 → 16.0.0-beta.5
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/lib/auth/oauth/diagnose.js +3 -11
- package/dist/lib/auth/oauth/diagnose.mjs +4 -12
- package/dist/lib/auth/oauth/diagnostics.d.mts +2 -1
- package/dist/lib/auth/oauth/diagnostics.d.ts +2 -1
- package/dist/lib/auth/oauth/diagnostics.js +8 -10
- package/dist/lib/auth/oauth/diagnostics.mjs +7 -10
- package/dist/lib/index.d.mts +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.mjs +7 -1
- package/dist/lib/reporting/consumer-runtime.js +2 -1
- package/dist/lib/reporting/consumer-runtime.mjs +2 -1
- package/dist/lib/reporting/ledger/handler.js +50 -11
- package/dist/lib/reporting/ledger/handler.mjs +50 -12
- package/dist/lib/reporting/ledger/index.d.mts +17 -14
- package/dist/lib/reporting/ledger/index.d.ts +17 -14
- package/dist/lib/reporting/ledger/index.js +32 -20
- package/dist/lib/reporting/ledger/index.mjs +21 -10
- package/dist/lib/reporting/ledger/offering-validation.d.mts +9 -0
- package/dist/lib/reporting/ledger/offering-validation.d.ts +9 -0
- package/dist/lib/reporting/{gcs/bucket-policy.js → ledger/offering-validation.js} +9 -8
- package/dist/lib/reporting/ledger/offering-validation.mjs +8 -0
- package/dist/lib/reporting/ledger/receipts.d.mts +48 -0
- package/dist/lib/reporting/ledger/receipts.d.ts +48 -0
- package/dist/lib/reporting/ledger/receipts.js +186 -0
- package/dist/lib/reporting/ledger/receipts.mjs +161 -0
- package/dist/lib/reporting/ledger/status-ingest.js +3 -3
- package/dist/lib/reporting/ledger/status-ingest.mjs +4 -5
- package/dist/lib/reporting/ledger/types.d.mts +43 -0
- package/dist/lib/reporting/ledger/types.d.ts +43 -0
- package/dist/lib/reporting/ledger/types.js +31 -2
- package/dist/lib/reporting/ledger/types.mjs +26 -1
- package/dist/lib/reporting/pagination.d.mts +6 -0
- package/dist/lib/reporting/pagination.d.ts +6 -0
- package/dist/lib/reporting/{gcs/errors.js → pagination.js} +12 -17
- package/dist/lib/reporting/pagination.mjs +12 -0
- package/dist/lib/reporting/reconciliation.d.mts +1 -0
- package/dist/lib/reporting/reconciliation.d.ts +1 -0
- package/dist/lib/reporting/reconciliation.js +3 -2
- package/dist/lib/reporting/reconciliation.mjs +3 -2
- package/dist/lib/reporting/service/index.d.mts +79 -153
- package/dist/lib/reporting/service/index.d.ts +79 -153
- package/dist/lib/reporting/service/index.js +34 -491
- package/dist/lib/reporting/service/index.mjs +36 -510
- package/dist/lib/reporting/service/scheduler-support.d.mts +43 -0
- package/dist/lib/reporting/service/scheduler-support.d.ts +43 -0
- package/dist/lib/reporting/service/scheduler-support.js +125 -0
- package/dist/lib/reporting/service/scheduler-support.mjs +96 -0
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/signing/canonicalize.d.mts +4 -0
- package/dist/lib/signing/canonicalize.d.ts +4 -0
- package/dist/lib/signing/canonicalize.js +14 -8
- package/dist/lib/signing/canonicalize.mjs +14 -8
- package/dist/lib/signing/content-digest.d.mts +8 -0
- package/dist/lib/signing/content-digest.d.ts +8 -0
- package/dist/lib/signing/content-digest.js +11 -5
- package/dist/lib/signing/content-digest.mjs +10 -5
- package/dist/lib/signing/signer.js +9 -6
- package/dist/lib/signing/signer.mjs +10 -7
- package/dist/lib/signing/web-bot-auth.js +103 -37
- package/dist/lib/signing/web-bot-auth.mjs +108 -37
- package/dist/lib/testing/storyboard/junit.js +16 -3
- package/dist/lib/testing/storyboard/junit.mjs +16 -3
- package/dist/lib/testing/storyboard/request-signing/builder.js +10 -9
- package/dist/lib/testing/storyboard/request-signing/builder.mjs +10 -9
- package/dist/lib/testing/storyboard/runner.js +94 -25
- package/dist/lib/testing/storyboard/runner.mjs +94 -25
- package/dist/lib/testing/storyboard/types.d.mts +6 -0
- package/dist/lib/testing/storyboard/types.d.ts +6 -0
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/types/schemas.generated.d.ts +907 -60
- package/dist/lib/types/schemas.generated.js +7 -7
- package/dist/lib/types/schemas.generated.mjs +7 -7
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/TYPE-SUMMARY.md +23 -5
- package/docs/guides/REPORTING-GCS-FENCE.md +3 -3
- package/docs/guides/REPORTING-GCS-MANAGED.md +4 -4
- package/docs/guides/REPORTING-LEDGER-CUTOVER.md +7 -7
- package/docs/guides/REPORTING-LEDGER.md +107 -51
- package/docs/guides/REPORTING-OPERATIONS.md +66 -5
- package/docs/guides/REPORTING-STABILITY.md +36 -32
- package/docs/llms.txt +4 -3
- package/docs/migration-14.x-rc-worksheet.md +4 -7
- package/docs/migration-reporting-storage-package.md +186 -0
- package/examples/README.md +1 -1
- package/package.json +13 -78
- package/dist/lib/reporting/azure/index.d.mts +0 -31
- package/dist/lib/reporting/azure/index.d.ts +0 -31
- package/dist/lib/reporting/azure/index.js +0 -266
- package/dist/lib/reporting/azure/index.mjs +0 -249
- package/dist/lib/reporting/bigquery/index.d.mts +0 -246
- package/dist/lib/reporting/bigquery/index.d.ts +0 -246
- package/dist/lib/reporting/bigquery/index.js +0 -633
- package/dist/lib/reporting/bigquery/index.mjs +0 -594
- package/dist/lib/reporting/gcs/adapter.d.mts +0 -38
- package/dist/lib/reporting/gcs/adapter.d.ts +0 -38
- package/dist/lib/reporting/gcs/adapter.js +0 -342
- package/dist/lib/reporting/gcs/adapter.mjs +0 -325
- package/dist/lib/reporting/gcs/bucket-policy.d.mts +0 -3
- package/dist/lib/reporting/gcs/bucket-policy.d.ts +0 -3
- package/dist/lib/reporting/gcs/bucket-policy.mjs +0 -7
- package/dist/lib/reporting/gcs/errors.d.mts +0 -11
- package/dist/lib/reporting/gcs/errors.d.ts +0 -11
- package/dist/lib/reporting/gcs/errors.mjs +0 -16
- package/dist/lib/reporting/gcs/fence.d.mts +0 -41
- package/dist/lib/reporting/gcs/fence.d.ts +0 -41
- package/dist/lib/reporting/gcs/fence.js +0 -250
- package/dist/lib/reporting/gcs/fence.mjs +0 -230
- package/dist/lib/reporting/gcs/index.d.mts +0 -18
- package/dist/lib/reporting/gcs/index.d.ts +0 -18
- package/dist/lib/reporting/gcs/index.js +0 -44
- package/dist/lib/reporting/gcs/index.mjs +0 -14
- package/dist/lib/reporting/gcs/io.d.mts +0 -7
- package/dist/lib/reporting/gcs/io.d.ts +0 -7
- package/dist/lib/reporting/gcs/io.js +0 -95
- package/dist/lib/reporting/gcs/io.mjs +0 -69
- package/dist/lib/reporting/gcs/reader.d.mts +0 -67
- package/dist/lib/reporting/gcs/reader.d.ts +0 -67
- package/dist/lib/reporting/gcs/reader.js +0 -193
- package/dist/lib/reporting/gcs/reader.mjs +0 -169
- package/dist/lib/reporting/gcs/row-provider.d.mts +0 -39
- package/dist/lib/reporting/gcs/row-provider.d.ts +0 -39
- package/dist/lib/reporting/gcs/row-provider.js +0 -225
- package/dist/lib/reporting/gcs/row-provider.mjs +0 -208
- package/dist/lib/reporting/gcs/validation.d.mts +0 -5
- package/dist/lib/reporting/gcs/validation.d.ts +0 -5
- package/dist/lib/reporting/gcs/validation.js +0 -49
- package/dist/lib/reporting/gcs/validation.mjs +0 -25
- package/dist/lib/reporting/ledger/change-feed.d.mts +0 -69
- package/dist/lib/reporting/ledger/change-feed.d.ts +0 -69
- package/dist/lib/reporting/ledger/change-feed.js +0 -107
- package/dist/lib/reporting/ledger/change-feed.mjs +0 -80
- package/dist/lib/reporting/ledger/managed-postgres.d.mts +0 -293
- package/dist/lib/reporting/ledger/managed-postgres.d.ts +0 -293
- package/dist/lib/reporting/ledger/managed-postgres.js +0 -2346
- package/dist/lib/reporting/ledger/managed-postgres.mjs +0 -2332
- package/dist/lib/reporting/ledger/managed-router.d.mts +0 -148
- package/dist/lib/reporting/ledger/managed-router.d.ts +0 -148
- package/dist/lib/reporting/ledger/managed-router.js +0 -280
- package/dist/lib/reporting/ledger/managed-router.mjs +0 -258
- package/dist/lib/reporting/ledger/managed.d.mts +0 -535
- package/dist/lib/reporting/ledger/managed.d.ts +0 -535
- package/dist/lib/reporting/ledger/managed.js +0 -761
- package/dist/lib/reporting/ledger/managed.mjs +0 -729
- package/dist/lib/reporting/ledger/notification-activity.d.mts +0 -332
- package/dist/lib/reporting/ledger/notification-activity.d.ts +0 -332
- package/dist/lib/reporting/ledger/notification-activity.js +0 -1366
- package/dist/lib/reporting/ledger/notification-activity.mjs +0 -1337
- package/dist/lib/reporting/ledger/object-writes.d.mts +0 -128
- package/dist/lib/reporting/ledger/object-writes.d.ts +0 -128
- package/dist/lib/reporting/ledger/object-writes.js +0 -147
- package/dist/lib/reporting/ledger/object-writes.mjs +0 -114
- package/dist/lib/reporting/ledger/postgres.d.mts +0 -689
- package/dist/lib/reporting/ledger/postgres.d.ts +0 -689
- package/dist/lib/reporting/ledger/postgres.js +0 -4462
- package/dist/lib/reporting/ledger/postgres.mjs +0 -4462
- package/dist/lib/reporting/ledger/row-storage-migration.d.mts +0 -59
- package/dist/lib/reporting/ledger/row-storage-migration.d.ts +0 -59
- package/dist/lib/reporting/ledger/row-storage-migration.js +0 -365
- package/dist/lib/reporting/ledger/row-storage-migration.mjs +0 -339
- package/dist/lib/reporting/ledger/row-storage-object.d.mts +0 -203
- package/dist/lib/reporting/ledger/row-storage-object.d.ts +0 -203
- package/dist/lib/reporting/ledger/row-storage-object.js +0 -379
- package/dist/lib/reporting/ledger/row-storage-object.mjs +0 -335
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -44
- package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -44
- package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -142
- package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -111
- package/dist/lib/reporting/ledger/row-storage.d.mts +0 -316
- package/dist/lib/reporting/ledger/row-storage.d.ts +0 -316
- package/dist/lib/reporting/ledger/row-storage.js +0 -812
- package/dist/lib/reporting/ledger/row-storage.mjs +0 -802
- package/dist/lib/reporting/s3/index.d.mts +0 -34
- package/dist/lib/reporting/s3/index.d.ts +0 -34
- package/dist/lib/reporting/s3/index.js +0 -347
- package/dist/lib/reporting/s3/index.mjs +0 -340
- package/dist/lib/reporting/service/maintenance.d.mts +0 -148
- package/dist/lib/reporting/service/maintenance.d.ts +0 -148
- package/dist/lib/reporting/service/maintenance.js +0 -185
- package/dist/lib/reporting/service/maintenance.mjs +0 -159
- package/examples/reliable-reporting-service/README.md +0 -169
|
@@ -1,4462 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
-
var postgres_exports = {};
|
|
20
|
-
__export(postgres_exports, {
|
|
21
|
-
PostgresReportingLedgerStore: () => PostgresReportingLedgerStore,
|
|
22
|
-
REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT: () => REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT,
|
|
23
|
-
REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION: () => REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION,
|
|
24
|
-
REPORTING_LEDGER_MIGRATION: () => REPORTING_LEDGER_MIGRATION,
|
|
25
|
-
ReportingLedgerContinuityError: () => ReportingLedgerContinuityError,
|
|
26
|
-
ReportingLedgerLeaseLostError: () => ReportingLedgerLeaseLostError,
|
|
27
|
-
ReportingLedgerPeriodRetiredError: () => ReportingLedgerPeriodRetiredError,
|
|
28
|
-
sweepExpiredReportingLedgerState: () => sweepExpiredReportingLedgerState
|
|
29
|
-
});
|
|
30
|
-
module.exports = __toCommonJS(postgres_exports);
|
|
31
|
-
var import_node_crypto = require("node:crypto");
|
|
32
|
-
var import_jcs = require('../../utils/jcs.js');
|
|
33
|
-
var import_error_codes = require('../../types/error-codes.js');
|
|
34
|
-
var import_source = require('../source/index.js');
|
|
35
|
-
var import_well_formed_unicode = require('../../utils/well-formed-unicode.js');
|
|
36
|
-
var import_coverage = require('./coverage.js');
|
|
37
|
-
var import_health = require('./health.js');
|
|
38
|
-
var import_instant = require('./instant.js');
|
|
39
|
-
var import_types = require('./types.js');
|
|
40
|
-
var import_types2 = require('./types.js');
|
|
41
|
-
var import_handler = require('./handler.js');
|
|
42
|
-
var import_producer = require('./producer.js');
|
|
43
|
-
var import_change_feed = require('./change-feed.js');
|
|
44
|
-
var import_row_storage = require('./row-storage.js');
|
|
45
|
-
var import_schedule = require('./schedule.js');
|
|
46
|
-
var import_consumer_status_identity = require('./consumer-status-identity.js');
|
|
47
|
-
const REPORTING_LEDGER_MIGRATION = `
|
|
48
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_configurations (
|
|
49
|
-
configuration_id TEXT PRIMARY KEY,
|
|
50
|
-
account_id TEXT NOT NULL,
|
|
51
|
-
delivery_config_id TEXT NOT NULL,
|
|
52
|
-
delivery_config_version INTEGER NOT NULL,
|
|
53
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
54
|
-
data JSONB NOT NULL,
|
|
55
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
56
|
-
UNIQUE (account_id, delivery_config_id, delivery_config_version)
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_obligations (
|
|
60
|
-
obligation_id TEXT PRIMARY KEY,
|
|
61
|
-
configuration_id TEXT NOT NULL REFERENCES adcp_reporting_configurations(configuration_id),
|
|
62
|
-
account_id TEXT NOT NULL,
|
|
63
|
-
period_ordinal BIGINT NOT NULL,
|
|
64
|
-
period_start TIMESTAMPTZ NOT NULL,
|
|
65
|
-
period_end TIMESTAMPTZ NOT NULL,
|
|
66
|
-
next_attempt_at TIMESTAMPTZ NOT NULL,
|
|
67
|
-
state TEXT NOT NULL,
|
|
68
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
69
|
-
data JSONB NOT NULL,
|
|
70
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
71
|
-
changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
72
|
-
lease_owner TEXT,
|
|
73
|
-
lease_generation BIGINT NOT NULL DEFAULT 0,
|
|
74
|
-
lease_expires_at TIMESTAMPTZ,
|
|
75
|
-
UNIQUE (configuration_id, period_ordinal),
|
|
76
|
-
CHECK (period_start < period_end),
|
|
77
|
-
CHECK (state IN ('pending', 'terminal'))
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
ALTER TABLE adcp_reporting_obligations ADD COLUMN IF NOT EXISTS period_ordinal BIGINT;
|
|
81
|
-
UPDATE adcp_reporting_obligations
|
|
82
|
-
SET period_ordinal = (data->>'periodOrdinal')::bigint
|
|
83
|
-
WHERE period_ordinal IS NULL AND data->>'periodOrdinal' ~ '^-?[0-9]+$';
|
|
84
|
-
ALTER TABLE adcp_reporting_obligations ALTER COLUMN period_ordinal SET NOT NULL;
|
|
85
|
-
CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_obligations_configuration_ordinal
|
|
86
|
-
ON adcp_reporting_obligations (configuration_id, period_ordinal);
|
|
87
|
-
|
|
88
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_due
|
|
89
|
-
ON adcp_reporting_obligations (next_attempt_at, obligation_id)
|
|
90
|
-
WHERE state = 'pending';
|
|
91
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_account_due
|
|
92
|
-
ON adcp_reporting_obligations (account_id, next_attempt_at, obligation_id)
|
|
93
|
-
WHERE state = 'pending';
|
|
94
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_account_period
|
|
95
|
-
ON adcp_reporting_obligations (account_id, period_start, obligation_id);
|
|
96
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_obligations_changed
|
|
97
|
-
ON adcp_reporting_obligations (account_id, changed_at);
|
|
98
|
-
|
|
99
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_revisions (
|
|
100
|
-
revision_id TEXT PRIMARY KEY,
|
|
101
|
-
obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
|
|
102
|
-
revision_number INTEGER NOT NULL,
|
|
103
|
-
finality TEXT NOT NULL,
|
|
104
|
-
kind TEXT NOT NULL,
|
|
105
|
-
supersedes_revision_id TEXT REFERENCES adcp_reporting_revisions(revision_id),
|
|
106
|
-
content_sha256 TEXT NOT NULL,
|
|
107
|
-
data JSONB NOT NULL,
|
|
108
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
109
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
110
|
-
UNIQUE (obligation_id, revision_number),
|
|
111
|
-
CHECK (finality IN ('snapshot', 'official')),
|
|
112
|
-
CHECK (kind IN ('snapshot', 'official'))
|
|
113
|
-
);
|
|
114
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_revisions_obligation
|
|
115
|
-
ON adcp_reporting_revisions (obligation_id, revision_number);
|
|
116
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_revisions_created
|
|
117
|
-
ON adcp_reporting_revisions (obligation_id, recorded_at);
|
|
118
|
-
|
|
119
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_adjustments (
|
|
120
|
-
adjustment_id TEXT PRIMARY KEY,
|
|
121
|
-
obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
|
|
122
|
-
adjusts_revision_id TEXT NOT NULL REFERENCES adcp_reporting_revisions(revision_id),
|
|
123
|
-
adjustment_number INTEGER NOT NULL,
|
|
124
|
-
content_sha256 TEXT NOT NULL,
|
|
125
|
-
data JSONB NOT NULL,
|
|
126
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
127
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
128
|
-
UNIQUE (obligation_id, adjustment_number)
|
|
129
|
-
);
|
|
130
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_adjustments_obligation
|
|
131
|
-
ON adcp_reporting_adjustments (obligation_id, adjustment_number);
|
|
132
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_adjustments_created
|
|
133
|
-
ON adcp_reporting_adjustments (obligation_id, recorded_at);
|
|
134
|
-
|
|
135
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_consumer_statuses (
|
|
136
|
-
account_id TEXT NOT NULL,
|
|
137
|
-
consumer_id TEXT NOT NULL,
|
|
138
|
-
consumer_status_id TEXT NOT NULL,
|
|
139
|
-
chain_key TEXT NOT NULL,
|
|
140
|
-
revision_id TEXT REFERENCES adcp_reporting_revisions(revision_id),
|
|
141
|
-
obligation_id TEXT REFERENCES adcp_reporting_obligations(obligation_id),
|
|
142
|
-
supersedes_consumer_status_id TEXT,
|
|
143
|
-
is_current BOOLEAN NOT NULL DEFAULT true,
|
|
144
|
-
semantic_fingerprint TEXT NOT NULL,
|
|
145
|
-
data JSONB NOT NULL,
|
|
146
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
147
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
148
|
-
PRIMARY KEY (account_id, consumer_id, consumer_status_id)
|
|
149
|
-
);
|
|
150
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN revision_id DROP NOT NULL;
|
|
151
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN obligation_id DROP NOT NULL;
|
|
152
|
-
ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS account_id TEXT;
|
|
153
|
-
ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS consumer_id TEXT;
|
|
154
|
-
ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS chain_key TEXT;
|
|
155
|
-
ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS is_current BOOLEAN NOT NULL DEFAULT true;
|
|
156
|
-
ALTER TABLE adcp_reporting_consumer_statuses ADD COLUMN IF NOT EXISTS semantic_fingerprint TEXT;
|
|
157
|
-
-- Preserve predecessor API rows under a reserved compatibility principal;
|
|
158
|
-
-- putConsumerStatus/listConsumerStatuses continue to serve this namespace.
|
|
159
|
-
UPDATE adcp_reporting_consumer_statuses AS status
|
|
160
|
-
SET account_id = COALESCE(
|
|
161
|
-
status.account_id,
|
|
162
|
-
obligation.data->'account'->>'account_id',
|
|
163
|
-
'__legacy_unscoped_account__'
|
|
164
|
-
),
|
|
165
|
-
consumer_id = COALESCE(status.consumer_id, '__legacy_unscoped_consumer__'),
|
|
166
|
-
chain_key = COALESCE(status.chain_key, 'legacy:' || status.consumer_status_id),
|
|
167
|
-
semantic_fingerprint = COALESCE(status.semantic_fingerprint, 'legacy:' || status.consumer_status_id)
|
|
168
|
-
FROM adcp_reporting_obligations AS obligation
|
|
169
|
-
WHERE status.obligation_id = obligation.obligation_id
|
|
170
|
-
AND (status.account_id IS NULL OR status.consumer_id IS NULL OR status.chain_key IS NULL
|
|
171
|
-
OR status.semantic_fingerprint IS NULL);
|
|
172
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN account_id SET NOT NULL;
|
|
173
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN consumer_id SET NOT NULL;
|
|
174
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN chain_key SET NOT NULL;
|
|
175
|
-
ALTER TABLE adcp_reporting_consumer_statuses ALTER COLUMN semantic_fingerprint SET NOT NULL;
|
|
176
|
-
DO $migration$
|
|
177
|
-
DECLARE self_fk RECORD;
|
|
178
|
-
BEGIN
|
|
179
|
-
FOR self_fk IN
|
|
180
|
-
SELECT conname
|
|
181
|
-
FROM pg_constraint
|
|
182
|
-
WHERE conrelid = 'adcp_reporting_consumer_statuses'::regclass
|
|
183
|
-
AND confrelid = 'adcp_reporting_consumer_statuses'::regclass
|
|
184
|
-
AND contype = 'f'
|
|
185
|
-
LOOP
|
|
186
|
-
EXECUTE format(
|
|
187
|
-
'ALTER TABLE adcp_reporting_consumer_statuses DROP CONSTRAINT %I',
|
|
188
|
-
self_fk.conname
|
|
189
|
-
);
|
|
190
|
-
END LOOP;
|
|
191
|
-
END
|
|
192
|
-
$migration$;
|
|
193
|
-
ALTER TABLE adcp_reporting_consumer_statuses DROP CONSTRAINT IF EXISTS adcp_reporting_consumer_statuses_pkey;
|
|
194
|
-
ALTER TABLE adcp_reporting_consumer_statuses
|
|
195
|
-
ADD CONSTRAINT adcp_reporting_consumer_statuses_pkey PRIMARY KEY (account_id, consumer_id, consumer_status_id);
|
|
196
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_revision
|
|
197
|
-
ON adcp_reporting_consumer_statuses (revision_id, created_at, consumer_status_id);
|
|
198
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_scope
|
|
199
|
-
ON adcp_reporting_consumer_statuses (account_id, consumer_id, created_at, consumer_status_id);
|
|
200
|
-
CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_current
|
|
201
|
-
ON adcp_reporting_consumer_statuses (account_id, consumer_id, chain_key) WHERE is_current;
|
|
202
|
-
-- The managed state digest is read inside the apply transaction, under the
|
|
203
|
-
-- account lock. Filtering this table by obligation alone matched no index, so
|
|
204
|
-
-- every apply scanned every consumer status in the deployment while holding
|
|
205
|
-
-- that lock and pushed concurrent Core writes past their lock timeout.
|
|
206
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_consumer_statuses_obligation
|
|
207
|
-
ON adcp_reporting_consumer_statuses (obligation_id, created_at, consumer_status_id);
|
|
208
|
-
|
|
209
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_consumer_status_batches (
|
|
210
|
-
account_id TEXT NOT NULL,
|
|
211
|
-
consumer_id TEXT NOT NULL,
|
|
212
|
-
idempotency_key TEXT NOT NULL,
|
|
213
|
-
request_fingerprint TEXT NOT NULL,
|
|
214
|
-
status_ids JSONB NOT NULL,
|
|
215
|
-
results JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
216
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
217
|
-
PRIMARY KEY (account_id, consumer_id, idempotency_key)
|
|
218
|
-
);
|
|
219
|
-
ALTER TABLE adcp_reporting_consumer_status_batches ADD COLUMN IF NOT EXISTS results JSONB NOT NULL DEFAULT '[]'::jsonb;
|
|
220
|
-
|
|
221
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_issues (
|
|
222
|
-
issue_id TEXT PRIMARY KEY,
|
|
223
|
-
obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
|
|
224
|
-
data JSONB NOT NULL,
|
|
225
|
-
observed_at TIMESTAMPTZ NOT NULL,
|
|
226
|
-
resolved_at TIMESTAMPTZ,
|
|
227
|
-
changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
|
228
|
-
);
|
|
229
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_issues_obligation
|
|
230
|
-
ON adcp_reporting_issues (obligation_id, observed_at, issue_id);
|
|
231
|
-
|
|
232
|
-
-- Per-obligation reconciliation watermark. A reconcile that changes nothing
|
|
233
|
-
-- still has to record that it looked, or a change with no health effect stays
|
|
234
|
-
-- a candidate forever and starves newer work behind it. Core, not managed:
|
|
235
|
-
-- the lifecycle sweep is a Core concern and must not depend on the add-on.
|
|
236
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_lifecycle_state (
|
|
237
|
-
obligation_id TEXT PRIMARY KEY REFERENCES adcp_reporting_obligations(obligation_id),
|
|
238
|
-
processed_state_version TEXT,
|
|
239
|
-
processed_roster_version TEXT,
|
|
240
|
-
processed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
|
241
|
-
);
|
|
242
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_lifecycle_state_processed
|
|
243
|
-
ON adcp_reporting_lifecycle_state (processed_at, obligation_id);
|
|
244
|
-
ALTER TABLE adcp_reporting_lifecycle_state
|
|
245
|
-
ADD COLUMN IF NOT EXISTS current_roster_version TEXT;
|
|
246
|
-
ALTER TABLE adcp_reporting_lifecycle_state
|
|
247
|
-
ADD COLUMN IF NOT EXISTS failure_count INTEGER NOT NULL DEFAULT 0;
|
|
248
|
-
ALTER TABLE adcp_reporting_lifecycle_state
|
|
249
|
-
ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ;
|
|
250
|
-
ALTER TABLE adcp_reporting_lifecycle_state
|
|
251
|
-
ADD COLUMN IF NOT EXISTS roster_refreshed_at TIMESTAMPTZ;
|
|
252
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_lifecycle_state_roster_refreshed
|
|
253
|
-
ON adcp_reporting_lifecycle_state (roster_refreshed_at, obligation_id);
|
|
254
|
-
|
|
255
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_transitions (
|
|
256
|
-
transition_id TEXT PRIMARY KEY,
|
|
257
|
-
transition_sequence BIGSERIAL NOT NULL UNIQUE,
|
|
258
|
-
obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
|
|
259
|
-
data JSONB NOT NULL,
|
|
260
|
-
occurred_at TIMESTAMPTZ NOT NULL,
|
|
261
|
-
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
|
262
|
-
);
|
|
263
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_transitions_obligation
|
|
264
|
-
ON adcp_reporting_transitions (obligation_id, occurred_at, transition_id);
|
|
265
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_transitions_pending
|
|
266
|
-
ON adcp_reporting_transitions (recorded_at, transition_id)
|
|
267
|
-
WHERE NOT (data ? 'notifiedAt');
|
|
268
|
-
|
|
269
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_snapshots (
|
|
270
|
-
snapshot_id UUID PRIMARY KEY,
|
|
271
|
-
account_id TEXT NOT NULL,
|
|
272
|
-
query_fingerprint TEXT NOT NULL,
|
|
273
|
-
data JSONB NOT NULL,
|
|
274
|
-
byte_count BIGINT NOT NULL,
|
|
275
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
276
|
-
expires_at TIMESTAMPTZ NOT NULL
|
|
277
|
-
);
|
|
278
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_snapshots_created
|
|
279
|
-
ON adcp_reporting_snapshots (created_at);
|
|
280
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_snapshots_expiry
|
|
281
|
-
ON adcp_reporting_snapshots (expires_at);
|
|
282
|
-
|
|
283
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_checkpoints (
|
|
284
|
-
checkpoint_id UUID PRIMARY KEY,
|
|
285
|
-
account_id TEXT NOT NULL,
|
|
286
|
-
scope_fingerprint TEXT NOT NULL,
|
|
287
|
-
ledger_as_of TIMESTAMPTZ NOT NULL,
|
|
288
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
289
|
-
expires_at TIMESTAMPTZ NOT NULL
|
|
290
|
-
);
|
|
291
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_checkpoints_expiry
|
|
292
|
-
ON adcp_reporting_checkpoints (expires_at);
|
|
293
|
-
|
|
294
|
-
-- A retired period keeps one compact record: it refuses replays that would
|
|
295
|
-
-- resurrect the period and preserves the audit conclusion after the period's
|
|
296
|
-
-- ledger records are deleted.
|
|
297
|
-
CREATE TABLE IF NOT EXISTS adcp_reporting_obligation_tombstones (
|
|
298
|
-
obligation_id TEXT PRIMARY KEY,
|
|
299
|
-
account_id TEXT NOT NULL,
|
|
300
|
-
configuration_id TEXT NOT NULL,
|
|
301
|
-
period_ordinal BIGINT NOT NULL,
|
|
302
|
-
period_start TIMESTAMPTZ NOT NULL,
|
|
303
|
-
period_end TIMESTAMPTZ NOT NULL,
|
|
304
|
-
state TEXT NOT NULL CHECK (state IN ('retiring', 'retired')),
|
|
305
|
-
final_revision_id TEXT,
|
|
306
|
-
final_content_sha256 TEXT,
|
|
307
|
-
revision_count INTEGER NOT NULL CHECK (revision_count >= 0),
|
|
308
|
-
adjustment_count INTEGER NOT NULL CHECK (adjustment_count >= 0),
|
|
309
|
-
retiring_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
310
|
-
retired_at TIMESTAMPTZ,
|
|
311
|
-
UNIQUE (configuration_id, period_ordinal),
|
|
312
|
-
CHECK ((state = 'retired') = (retired_at IS NOT NULL))
|
|
313
|
-
);
|
|
314
|
-
CREATE INDEX IF NOT EXISTS adcp_reporting_obligation_tombstones_account
|
|
315
|
-
ON adcp_reporting_obligation_tombstones (account_id, configuration_id, period_ordinal);
|
|
316
|
-
-- Retirement retry state. A period whose external deletes fail on every pass
|
|
317
|
-
-- backs off instead of holding a slot at the head of every pass. Guarded on the
|
|
318
|
-
-- catalog because ADD COLUMN IF NOT EXISTS takes ACCESS EXCLUSIVE even when the
|
|
319
|
-
-- column exists, which would block a rerun behind ordinary traffic.
|
|
320
|
-
DO $tombstone_retry$
|
|
321
|
-
DECLARE
|
|
322
|
-
tombstones regclass := to_regclass('adcp_reporting_obligation_tombstones');
|
|
323
|
-
BEGIN
|
|
324
|
-
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'attempts' AND NOT attisdropped) THEN
|
|
325
|
-
ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
326
|
-
END IF;
|
|
327
|
-
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'next_attempt_at' AND NOT attisdropped) THEN
|
|
328
|
-
ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN next_attempt_at TIMESTAMPTZ;
|
|
329
|
-
END IF;
|
|
330
|
-
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tombstones AND attname = 'last_failure' AND NOT attisdropped) THEN
|
|
331
|
-
ALTER TABLE adcp_reporting_obligation_tombstones ADD COLUMN last_failure TEXT;
|
|
332
|
-
END IF;
|
|
333
|
-
END
|
|
334
|
-
$tombstone_retry$;
|
|
335
|
-
`.trim();
|
|
336
|
-
const REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT = "adcp_reporting_transitions_finality_recorded";
|
|
337
|
-
const REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION = `
|
|
338
|
-
DO $$
|
|
339
|
-
BEGIN
|
|
340
|
-
IF NOT EXISTS (
|
|
341
|
-
SELECT 1 FROM pg_constraint
|
|
342
|
-
WHERE conrelid = 'adcp_reporting_transitions'::regclass
|
|
343
|
-
AND conname = '${REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT}'
|
|
344
|
-
) THEN
|
|
345
|
-
ALTER TABLE adcp_reporting_transitions
|
|
346
|
-
ADD CONSTRAINT ${REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT}
|
|
347
|
-
CHECK (data ? 'finality') NOT VALID;
|
|
348
|
-
END IF;
|
|
349
|
-
END $$;
|
|
350
|
-
`.trim();
|
|
351
|
-
const MAX_SNAPSHOT_ITEMS = 1e4;
|
|
352
|
-
const MAX_LIFECYCLE_RECEIPT_LEAVES = 1e5;
|
|
353
|
-
const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
|
|
354
|
-
const MAX_ACTIVE_SNAPSHOTS_PER_ACCOUNT = 32;
|
|
355
|
-
const MAX_ACTIVE_SNAPSHOT_BYTES_PER_ACCOUNT = 128 * 1024 * 1024;
|
|
356
|
-
const MAX_CONSUMER_STATUS_BATCHES = 1e4;
|
|
357
|
-
const MAX_CONSUMER_STATUS_STATEMENTS = 1e5;
|
|
358
|
-
const SNAPSHOT_RETENTION_MS = 15 * 60 * 1e3;
|
|
359
|
-
const CHECKPOINT_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
360
|
-
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
361
|
-
class ReportingLedgerLeaseLostError extends Error {
|
|
362
|
-
constructor(message = "Reporting ledger lease was lost before commit") {
|
|
363
|
-
super(message);
|
|
364
|
-
this.name = "ReportingLedgerLeaseLostError";
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
class ReportingLedgerPeriodRetiredError extends Error {
|
|
368
|
-
constructor(message = "Reporting obligation period was retired by retention") {
|
|
369
|
-
super(message);
|
|
370
|
-
this.name = "ReportingLedgerPeriodRetiredError";
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
class ReportingLedgerContinuityError extends Error {
|
|
374
|
-
constructor(message = "Reporting ledger revision continuity invariant failed") {
|
|
375
|
-
super(message);
|
|
376
|
-
this.name = "ReportingLedgerContinuityError";
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const REPORTING_PERSISTENT_FAILURE_ATTEMPTS = 5;
|
|
380
|
-
function backoffIntervalSql(attemptsExpr) {
|
|
381
|
-
return `(LEAST(21600, 60 * power(2, LEAST(${attemptsExpr} - 1, 12))) * INTERVAL '1 second')`;
|
|
382
|
-
}
|
|
383
|
-
async function sweepExpiredReportingLedgerState(pool, limit = 1e3) {
|
|
384
|
-
positiveInteger(limit, "limit");
|
|
385
|
-
if (limit > 1e4) throw new RangeError("limit must not exceed 10000");
|
|
386
|
-
try {
|
|
387
|
-
const snapshots = await pool.query(
|
|
388
|
-
`WITH expired AS (
|
|
389
|
-
SELECT snapshot_id FROM adcp_reporting_snapshots
|
|
390
|
-
WHERE expires_at <= clock_timestamp()
|
|
391
|
-
ORDER BY expires_at, snapshot_id
|
|
392
|
-
FOR UPDATE SKIP LOCKED LIMIT $1
|
|
393
|
-
)
|
|
394
|
-
DELETE FROM adcp_reporting_snapshots target
|
|
395
|
-
USING expired WHERE target.snapshot_id = expired.snapshot_id`,
|
|
396
|
-
[limit]
|
|
397
|
-
);
|
|
398
|
-
const checkpoints = await pool.query(
|
|
399
|
-
`WITH expired AS (
|
|
400
|
-
SELECT checkpoint_id FROM adcp_reporting_checkpoints
|
|
401
|
-
WHERE expires_at <= clock_timestamp()
|
|
402
|
-
ORDER BY expires_at, checkpoint_id
|
|
403
|
-
FOR UPDATE SKIP LOCKED LIMIT $1
|
|
404
|
-
)
|
|
405
|
-
DELETE FROM adcp_reporting_checkpoints target
|
|
406
|
-
USING expired WHERE target.checkpoint_id = expired.checkpoint_id`,
|
|
407
|
-
[limit]
|
|
408
|
-
);
|
|
409
|
-
return { snapshotsDeleted: snapshots.rowCount ?? 0, checkpointsDeleted: checkpoints.rowCount ?? 0 };
|
|
410
|
-
} catch (cause) {
|
|
411
|
-
throw new Error("Reporting ledger expiry cleanup failed", { cause });
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
class PostgresReportingLedgerStore {
|
|
415
|
-
constructor(pool, options = {}) {
|
|
416
|
-
this.pool = pool;
|
|
417
|
-
this.managedDelivery = options.managedDelivery === true;
|
|
418
|
-
this.obligatedConsumers = options.obligatedConsumers;
|
|
419
|
-
this[import_types.REPORTING_LEDGER_AUTHORITY] = { substrate: pool, managedDelivery: this.managedDelivery };
|
|
420
|
-
const development = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development";
|
|
421
|
-
if (!development && !options.acknowledgeIsolatedDatabase) {
|
|
422
|
-
throw new Error(
|
|
423
|
-
"PostgresReportingLedgerStore requires an isolated database/schema or acknowledgeIsolatedDatabase: true"
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
this.consumerMismatchEscalation = (0, import_health.assertReportingConsumerMismatchEscalation)(options.consumerMismatchEscalation);
|
|
427
|
-
this.notificationActivityPort = options.notificationActivityPort;
|
|
428
|
-
this.transactionalNotificationActivity = options.notificationActivityPort !== void 0;
|
|
429
|
-
this.rowStorage = new import_row_storage.ReportingRowStorageV1(pool, typeof options.rowStorage === "object" ? options.rowStorage : {});
|
|
430
|
-
this.rowStorageWrites = options.rowStorage !== void 0 && options.rowStorage !== false;
|
|
431
|
-
this.changeFeed = options.changeFeed === true;
|
|
432
|
-
}
|
|
433
|
-
pool;
|
|
434
|
-
/**
|
|
435
|
-
* Exposed so `createReportingStatusHandler` can inherit it and refuse a
|
|
436
|
-
* disagreement. The store applies `health` while building the snapshot and
|
|
437
|
-
* the handler projects severity afterwards, so two independently configured
|
|
438
|
-
* copies of the escalation window would let a filtered periods read
|
|
439
|
-
* contradict the summary.
|
|
440
|
-
*/
|
|
441
|
-
consumerMismatchEscalation;
|
|
442
|
-
[import_types.REPORTING_LEDGER_AUTHORITY];
|
|
443
|
-
managedDelivery;
|
|
444
|
-
obligatedConsumers;
|
|
445
|
-
transactionalNotificationActivity;
|
|
446
|
-
notificationActivityPort;
|
|
447
|
-
rowStorage;
|
|
448
|
-
rowStorageWrites;
|
|
449
|
-
changeFeed;
|
|
450
|
-
/** Whether this store writes the host change feed (`changeFeed: true`). */
|
|
451
|
-
get changeFeedEnabled() {
|
|
452
|
-
return this.changeFeed;
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Delete expired cursor snapshots and changes checkpoints, a bounded batch
|
|
456
|
-
* at a time. The store-bound form of `sweepExpiredReportingLedgerState`, for
|
|
457
|
-
* hosts that hold the store but not the pool.
|
|
458
|
-
*/
|
|
459
|
-
async sweepExpiredState(limit = 1e3) {
|
|
460
|
-
return sweepExpiredReportingLedgerState(this.pool, limit);
|
|
461
|
-
}
|
|
462
|
-
/** Append one host change-feed row inside the caller's account-locked transaction. */
|
|
463
|
-
async appendChange(client, kind, accountId, recordId, obligationId) {
|
|
464
|
-
if (!this.changeFeed) return;
|
|
465
|
-
await client.query(
|
|
466
|
-
`INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id, obligation_id) VALUES ($1, $2, $3, $4)`,
|
|
467
|
-
[accountId, kind, recordId, obligationId]
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Defense in depth for snapshot compaction (see `compactSupersededSnapshots`): throws `INVALID_INPUT`
|
|
472
|
-
* if a writable object row binding's key template omits `{revision_key}`. Every such binding is
|
|
473
|
-
* already refused when the store is configured, so this only guards a configuration changed after
|
|
474
|
-
* construction; `readOnly` bindings are skipped. Called by `compactSupersededSnapshots` and by
|
|
475
|
-
* service construction when compaction is enabled.
|
|
476
|
-
*/
|
|
477
|
-
assertCompactionSafeRowBindings() {
|
|
478
|
-
this.rowStorage.assertCompactionSafeKeyTemplates();
|
|
479
|
-
}
|
|
480
|
-
/** Probe and register row storage when enabled. Safe to call repeatedly. */
|
|
481
|
-
async readyRowStorage() {
|
|
482
|
-
if (this.rowStorageWrites) await this.rowStorage.ready();
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Move legacy inline revision and adjustment rows into row storage, a
|
|
486
|
-
* bounded batch at a time. Each row set is re-encoded, proven to reproduce
|
|
487
|
-
* its committed binding, written, and only then removed from the ledger
|
|
488
|
-
* document, in one transaction under the account lock. A document whose
|
|
489
|
-
* rows do not reproduce its binding is never rewritten: it is reported in
|
|
490
|
-
* `quarantined` and left inline. Pass the returned `cursor` back to resume
|
|
491
|
-
* after the last examined document; it is absent once every candidate has
|
|
492
|
-
* been examined. Reclaiming the freed space needs `VACUUM FULL` or an online
|
|
493
|
-
* repack.
|
|
494
|
-
*/
|
|
495
|
-
async migrateInlineRows(input = {}) {
|
|
496
|
-
if (!this.rowStorageWrites) throw new Error("migrateInlineRows requires rowStorage");
|
|
497
|
-
const limit = input.limit ?? 100;
|
|
498
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
|
|
499
|
-
throw new RangeError("migrateInlineRows limit must be between 1 and 10000");
|
|
500
|
-
}
|
|
501
|
-
let after;
|
|
502
|
-
if (input.cursor !== void 0) {
|
|
503
|
-
try {
|
|
504
|
-
const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
|
|
505
|
-
if (typeof parsed.at !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
|
|
506
|
-
after = { at: parsed.at, id: parsed.id };
|
|
507
|
-
} catch {
|
|
508
|
-
throw new RangeError("migrateInlineRows cursor is invalid");
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
await this.rowStorage.ready();
|
|
512
|
-
const candidates = await this.query(
|
|
513
|
-
`SELECT kind, id, account_id, to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS at
|
|
514
|
-
FROM (
|
|
515
|
-
SELECT 'revision' AS kind, revision.revision_id AS id, obligation.account_id, revision.recorded_at
|
|
516
|
-
FROM adcp_reporting_revisions revision
|
|
517
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
518
|
-
WHERE revision.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
|
|
519
|
-
UNION ALL
|
|
520
|
-
SELECT 'adjustment', adjustment.adjustment_id, obligation.account_id, adjustment.recorded_at
|
|
521
|
-
FROM adcp_reporting_adjustments adjustment
|
|
522
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
|
|
523
|
-
WHERE adjustment.data ? 'rows' AND ($2::text IS NULL OR obligation.account_id = $2)
|
|
524
|
-
) inline_rows
|
|
525
|
-
WHERE $3::timestamptz IS NULL OR (recorded_at, id) > ($3::timestamptz, $4::text)
|
|
526
|
-
ORDER BY recorded_at, id
|
|
527
|
-
LIMIT $1`,
|
|
528
|
-
[limit + 1, input.account_id ?? null, after?.at ?? null, after?.id ?? null]
|
|
529
|
-
);
|
|
530
|
-
let migrated = 0;
|
|
531
|
-
const quarantined = [];
|
|
532
|
-
for (const candidate of candidates.rows.slice(0, limit)) {
|
|
533
|
-
input.signal?.throwIfAborted();
|
|
534
|
-
const table = candidate.kind === "revision" ? "adcp_reporting_revisions" : "adcp_reporting_adjustments";
|
|
535
|
-
const idColumn = candidate.kind === "revision" ? "revision_id" : "adjustment_id";
|
|
536
|
-
const outcome = await this.transaction(
|
|
537
|
-
async (client) => {
|
|
538
|
-
const locked = await client.query(
|
|
539
|
-
`SELECT data FROM ${table} WHERE ${idColumn} = $1 AND data ? 'rows' FOR UPDATE`,
|
|
540
|
-
[candidate.id]
|
|
541
|
-
);
|
|
542
|
-
const document = locked.rows[0]?.data;
|
|
543
|
-
if (!document) return "skipped";
|
|
544
|
-
let prepared;
|
|
545
|
-
try {
|
|
546
|
-
prepared = await this.rowStorage.prepare({
|
|
547
|
-
rowSetId: candidate.id,
|
|
548
|
-
rowSetKind: candidate.kind,
|
|
549
|
-
accountId: candidate.account_id,
|
|
550
|
-
obligationId: document.reporting_obligation_id,
|
|
551
|
-
rows: document.rows,
|
|
552
|
-
...candidate.kind === "revision" ? { finality: document.finality, controlTotals: document.wireRevision.control_totals } : {},
|
|
553
|
-
binding: document.binding,
|
|
554
|
-
...input.bindingId ? { bindingId: input.bindingId } : {}
|
|
555
|
-
});
|
|
556
|
-
} catch (error) {
|
|
557
|
-
if ((0, import_row_storage.isReportingRowStoreError)(error) && error.code === "INVALID_INPUT") return "quarantined";
|
|
558
|
-
throw error;
|
|
559
|
-
}
|
|
560
|
-
if (prepared.binding.kind !== "postgres") {
|
|
561
|
-
throw new import_row_storage.ReportingRowStoreError(
|
|
562
|
-
"INVALID_INPUT",
|
|
563
|
-
"migrateInlineRows writes PostgreSQL-kind row sets; select a postgres binding for migrated rows"
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
await this.rowStorage.writeInTransaction(client, prepared);
|
|
567
|
-
await client.query(`UPDATE ${table} SET data = data - 'rows' WHERE ${idColumn} = $1`, [candidate.id]);
|
|
568
|
-
return "migrated";
|
|
569
|
-
},
|
|
570
|
-
{ preBeginAdvisoryLock: accountLock(candidate.account_id) }
|
|
571
|
-
);
|
|
572
|
-
if (outcome === "migrated") migrated += 1;
|
|
573
|
-
if (outcome === "quarantined") quarantined.push(candidate.id);
|
|
574
|
-
}
|
|
575
|
-
const last = candidates.rows.length > limit ? candidates.rows[limit - 1] : void 0;
|
|
576
|
-
return {
|
|
577
|
-
migrated,
|
|
578
|
-
quarantined,
|
|
579
|
-
...last ? { cursor: Buffer.from(JSON.stringify({ at: last.at, id: last.id }), "utf8").toString("base64url") } : {}
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
async putConfiguration(configuration) {
|
|
583
|
-
return this.transaction(
|
|
584
|
-
async (client) => {
|
|
585
|
-
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
586
|
-
`adcp-reporting-config:${configuration.account.account_id}:${configuration.delivery_config_id}`
|
|
587
|
-
]);
|
|
588
|
-
const newer = await client.query(
|
|
589
|
-
`SELECT MAX(delivery_config_version)::integer AS highest
|
|
590
|
-
FROM adcp_reporting_configurations WHERE account_id = $1 AND delivery_config_id = $2`,
|
|
591
|
-
[configuration.account.account_id, configuration.delivery_config_id]
|
|
592
|
-
);
|
|
593
|
-
if ((newer.rows[0]?.highest ?? -1) > configuration.delivery_config_version) {
|
|
594
|
-
throw new Error("Reporting configuration version cannot regress");
|
|
595
|
-
}
|
|
596
|
-
const inserted = await client.query(
|
|
597
|
-
`INSERT INTO adcp_reporting_configurations
|
|
598
|
-
(configuration_id, account_id, delivery_config_id, delivery_config_version,
|
|
599
|
-
semantic_fingerprint, data, created_at)
|
|
600
|
-
VALUES ($1, $2, $3, $4, $5, $6::jsonb, clock_timestamp())
|
|
601
|
-
ON CONFLICT DO NOTHING RETURNING data`,
|
|
602
|
-
[
|
|
603
|
-
configuration.configurationId,
|
|
604
|
-
configuration.account.account_id,
|
|
605
|
-
configuration.delivery_config_id,
|
|
606
|
-
configuration.delivery_config_version,
|
|
607
|
-
configuration.semanticFingerprint,
|
|
608
|
-
JSON.stringify(configuration)
|
|
609
|
-
]
|
|
610
|
-
);
|
|
611
|
-
const value = inserted.rows[0]?.data ?? (await client.query(
|
|
612
|
-
`SELECT data FROM adcp_reporting_configurations
|
|
613
|
-
WHERE account_id = $1 AND delivery_config_id = $2 AND delivery_config_version = $3`,
|
|
614
|
-
[
|
|
615
|
-
configuration.account.account_id,
|
|
616
|
-
configuration.delivery_config_id,
|
|
617
|
-
configuration.delivery_config_version
|
|
618
|
-
]
|
|
619
|
-
)).rows[0]?.data;
|
|
620
|
-
if (!value || value.semanticFingerprint !== configuration.semanticFingerprint) {
|
|
621
|
-
throw new Error("Immutable reporting configuration identity names different content");
|
|
622
|
-
}
|
|
623
|
-
return { inserted: inserted.rowCount === 1, value: clone(value) };
|
|
624
|
-
},
|
|
625
|
-
{ preBeginAdvisoryLock: accountLock(configuration.account.account_id) }
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
async listConfigurations(account_id) {
|
|
629
|
-
const result = await this.query(
|
|
630
|
-
`SELECT data FROM adcp_reporting_configurations
|
|
631
|
-
WHERE ($1::text IS NULL OR account_id = $1)
|
|
632
|
-
ORDER BY created_at, configuration_id`,
|
|
633
|
-
[account_id ?? null]
|
|
634
|
-
);
|
|
635
|
-
return result.rows.map((row) => clone(row.data));
|
|
636
|
-
}
|
|
637
|
-
async putObligation(obligation) {
|
|
638
|
-
const retired = await this.query(
|
|
639
|
-
`SELECT 1 FROM adcp_reporting_obligation_tombstones WHERE configuration_id = $1 AND period_ordinal = $2`,
|
|
640
|
-
[obligation.configurationId, obligation.periodOrdinal]
|
|
641
|
-
);
|
|
642
|
-
if (retired.rows.length > 0) throw new ReportingLedgerPeriodRetiredError();
|
|
643
|
-
return this.putImmutable(
|
|
644
|
-
`INSERT INTO adcp_reporting_obligations
|
|
645
|
-
(obligation_id, configuration_id, account_id, period_ordinal, period_start, period_end,
|
|
646
|
-
next_attempt_at, state, semantic_fingerprint, data, created_at)
|
|
647
|
-
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, clock_timestamp()
|
|
648
|
-
WHERE NOT EXISTS (
|
|
649
|
-
SELECT 1 FROM adcp_reporting_obligation_tombstones
|
|
650
|
-
WHERE configuration_id = $2 AND period_ordinal = $4
|
|
651
|
-
)
|
|
652
|
-
ON CONFLICT DO NOTHING RETURNING data`,
|
|
653
|
-
[
|
|
654
|
-
obligation.reporting_obligation_id,
|
|
655
|
-
obligation.configurationId,
|
|
656
|
-
obligation.account.account_id,
|
|
657
|
-
obligation.periodOrdinal,
|
|
658
|
-
obligation.period.start,
|
|
659
|
-
obligation.period.end,
|
|
660
|
-
obligation.nextAttemptAt,
|
|
661
|
-
obligation.state,
|
|
662
|
-
obligation.semanticFingerprint,
|
|
663
|
-
JSON.stringify(obligation)
|
|
664
|
-
],
|
|
665
|
-
`SELECT data FROM adcp_reporting_obligations
|
|
666
|
-
WHERE configuration_id = $1 AND period_ordinal = $2`,
|
|
667
|
-
[obligation.configurationId, obligation.periodOrdinal],
|
|
668
|
-
obligation,
|
|
669
|
-
(value) => value.semanticFingerprint,
|
|
670
|
-
accountLock(obligation.account.account_id),
|
|
671
|
-
void 0,
|
|
672
|
-
void 0,
|
|
673
|
-
this.changeFeed ? (client) => this.appendChange(
|
|
674
|
-
client,
|
|
675
|
-
"obligation",
|
|
676
|
-
obligation.account.account_id,
|
|
677
|
-
obligation.reporting_obligation_id,
|
|
678
|
-
obligation.reporting_obligation_id
|
|
679
|
-
) : void 0
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
async getObligation(id, account_id) {
|
|
683
|
-
return this.one(
|
|
684
|
-
`SELECT data FROM adcp_reporting_obligations
|
|
685
|
-
WHERE obligation_id = $1 AND ($2::text IS NULL OR account_id = $2)`,
|
|
686
|
-
[id, account_id ?? null]
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
/** Periods retired by retention; planners treat them as already covered. */
|
|
690
|
-
async listRetiredObligationOrdinals(account_id) {
|
|
691
|
-
const result = await this.query(
|
|
692
|
-
`SELECT configuration_id, period_ordinal FROM adcp_reporting_obligation_tombstones
|
|
693
|
-
WHERE ($1::text IS NULL OR account_id = $1) ORDER BY configuration_id, period_ordinal`,
|
|
694
|
-
[account_id ?? null]
|
|
695
|
-
);
|
|
696
|
-
return result.rows.map((row) => ({
|
|
697
|
-
configurationId: row.configuration_id,
|
|
698
|
-
periodOrdinal: Number(row.period_ordinal)
|
|
699
|
-
}));
|
|
700
|
-
}
|
|
701
|
-
async listObligations(account_id) {
|
|
702
|
-
const result = await this.query(
|
|
703
|
-
`SELECT data FROM adcp_reporting_obligations
|
|
704
|
-
WHERE ($1::text IS NULL OR account_id = $1) ORDER BY period_start, obligation_id`,
|
|
705
|
-
[account_id ?? null]
|
|
706
|
-
);
|
|
707
|
-
return result.rows.map((row) => clone(row.data));
|
|
708
|
-
}
|
|
709
|
-
async listLifecycleDueObligations(input) {
|
|
710
|
-
positiveInteger(input.limit, "limit");
|
|
711
|
-
if (input.limit > 1e3) throw new RangeError("limit must not exceed 1000");
|
|
712
|
-
if (!Number.isFinite(Date.parse(input.ledgerAsOf))) throw new TypeError("ledgerAsOf must be an RFC 3339 instant");
|
|
713
|
-
const managedDueArm = await this.managedDueTablesReady() ? MANAGED_DUE_ARM : "";
|
|
714
|
-
const result = await this.query(
|
|
715
|
-
`SELECT obligation.data FROM adcp_reporting_obligations obligation
|
|
716
|
-
LEFT JOIN LATERAL (
|
|
717
|
-
SELECT transition.data->>'health' AS health, transition.occurred_at
|
|
718
|
-
FROM adcp_reporting_transitions transition
|
|
719
|
-
WHERE transition.obligation_id = obligation.obligation_id
|
|
720
|
-
ORDER BY transition.transition_sequence DESC LIMIT 1
|
|
721
|
-
) latest ON TRUE
|
|
722
|
-
LEFT JOIN adcp_reporting_lifecycle_state state
|
|
723
|
-
ON state.obligation_id = obligation.obligation_id
|
|
724
|
-
CROSS JOIN LATERAL (
|
|
725
|
-
-- A reconcile that changed no health still has to count as work done,
|
|
726
|
-
-- or a managed change with no health effect keeps the obligation due
|
|
727
|
-
-- forever and, at small page sizes, starves everything behind it.
|
|
728
|
-
SELECT GREATEST(
|
|
729
|
-
COALESCE(state.processed_at, obligation.created_at),
|
|
730
|
-
COALESCE(latest.occurred_at, obligation.created_at)
|
|
731
|
-
) AS since
|
|
732
|
-
) watermark
|
|
733
|
-
WHERE ($1::text IS NULL OR obligation.account_id = $1)
|
|
734
|
-
AND ((COALESCE(latest.health, 'waiting') = 'waiting'
|
|
735
|
-
AND (obligation.data->>'expectedAt')::timestamptz <= $2)
|
|
736
|
-
OR (latest.health = 'delayed'
|
|
737
|
-
AND (obligation.data->>'recoveryDeadlineAt')::timestamptz <= $2)
|
|
738
|
-
OR (obligation.state = 'terminal'
|
|
739
|
-
AND COALESCE(latest.health, 'waiting') <> 'complete'
|
|
740
|
-
AND EXISTS (
|
|
741
|
-
SELECT 1 FROM adcp_reporting_revisions revision
|
|
742
|
-
WHERE revision.obligation_id = obligation.obligation_id
|
|
743
|
-
))
|
|
744
|
-
${managedDueArm} )
|
|
745
|
-
-- A tenant that keeps failing must not monopolise every sweep. Its
|
|
746
|
-
-- backoff cursor holds it out of selection until it is due again,
|
|
747
|
-
-- while processed_at ordering still favours whoever has waited
|
|
748
|
-
-- longest among the eligible.
|
|
749
|
-
AND (state.next_attempt_at IS NULL OR state.next_attempt_at <= $2)
|
|
750
|
-
ORDER BY watermark.since, LEAST(
|
|
751
|
-
(obligation.data->>'expectedAt')::timestamptz,
|
|
752
|
-
(obligation.data->>'recoveryDeadlineAt')::timestamptz
|
|
753
|
-
), obligation.obligation_id
|
|
754
|
-
LIMIT $3`,
|
|
755
|
-
[input.account_id ?? null, input.ledgerAsOf, input.limit]
|
|
756
|
-
);
|
|
757
|
-
return result.rows.map((row) => clone(row.data));
|
|
758
|
-
}
|
|
759
|
-
async updateObligation(obligation, lease, issue) {
|
|
760
|
-
assertLeaseTarget(obligation.reporting_obligation_id, lease);
|
|
761
|
-
await this.transaction(
|
|
762
|
-
async (client) => {
|
|
763
|
-
const result = await client.query(
|
|
764
|
-
`UPDATE adcp_reporting_obligations
|
|
765
|
-
SET next_attempt_at = $2, state = $3, data = $4::jsonb, changed_at = clock_timestamp()
|
|
766
|
-
WHERE obligation_id = $1 AND semantic_fingerprint = $5
|
|
767
|
-
AND lease_owner = $6 AND lease_generation = $7 AND lease_expires_at > clock_timestamp()`,
|
|
768
|
-
[
|
|
769
|
-
obligation.reporting_obligation_id,
|
|
770
|
-
obligation.nextAttemptAt,
|
|
771
|
-
obligation.state,
|
|
772
|
-
JSON.stringify(obligation),
|
|
773
|
-
obligation.semanticFingerprint,
|
|
774
|
-
lease.owner,
|
|
775
|
-
lease.generation
|
|
776
|
-
]
|
|
777
|
-
);
|
|
778
|
-
if (result.rowCount !== 1) throw new ReportingLedgerLeaseLostError();
|
|
779
|
-
await this.appendChange(
|
|
780
|
-
client,
|
|
781
|
-
"obligation",
|
|
782
|
-
lease.obligation.account.account_id,
|
|
783
|
-
obligation.reporting_obligation_id,
|
|
784
|
-
obligation.reporting_obligation_id
|
|
785
|
-
);
|
|
786
|
-
if (issue) {
|
|
787
|
-
if (issue.reporting_obligation_id !== obligation.reporting_obligation_id) {
|
|
788
|
-
throw new Error("Reporting issue must belong to the leased obligation");
|
|
789
|
-
}
|
|
790
|
-
const issueResult = await client.query(
|
|
791
|
-
`INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
|
|
792
|
-
VALUES ($1, $2, $3::jsonb, $4, $5)
|
|
793
|
-
-- rc.3 fixes opened_at at first emission: it MUST NOT advance while
|
|
794
|
-
-- the same issue_id is re-emitted, including across the delayed ->
|
|
795
|
-
-- action_required severity change that re-upserts this row. Keep the
|
|
796
|
-
-- stored anchor and let every other field advance.
|
|
797
|
-
ON CONFLICT (issue_id) DO UPDATE SET
|
|
798
|
-
data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
|
|
799
|
-
observed_at = EXCLUDED.observed_at,
|
|
800
|
-
resolved_at = EXCLUDED.resolved_at,
|
|
801
|
-
changed_at = clock_timestamp()
|
|
802
|
-
WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
|
|
803
|
-
[
|
|
804
|
-
issue.issueId,
|
|
805
|
-
issue.reporting_obligation_id,
|
|
806
|
-
JSON.stringify(issue),
|
|
807
|
-
issue.observedAt,
|
|
808
|
-
issue.resolvedAt ?? null
|
|
809
|
-
]
|
|
810
|
-
);
|
|
811
|
-
if (issueResult.rowCount !== 1) throw new Error("Reporting issue identity belongs to another obligation");
|
|
812
|
-
}
|
|
813
|
-
},
|
|
814
|
-
{ preBeginAdvisoryLock: accountLock(obligation.account.account_id) }
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
async claimObligation(input) {
|
|
818
|
-
positiveInteger(input.leaseMilliseconds, "leaseMilliseconds");
|
|
819
|
-
if (!Number.isFinite(Date.parse(input.now))) throw new TypeError("now must be an RFC 3339 instant");
|
|
820
|
-
const result = await this.query(
|
|
821
|
-
`WITH candidate AS (
|
|
822
|
-
SELECT obligation_id FROM adcp_reporting_obligations
|
|
823
|
-
WHERE state = 'pending' AND next_attempt_at <= $2
|
|
824
|
-
AND ($4::text IS NULL OR account_id = $4)
|
|
825
|
-
AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
|
|
826
|
-
ORDER BY next_attempt_at, obligation_id
|
|
827
|
-
FOR UPDATE SKIP LOCKED LIMIT 1
|
|
828
|
-
)
|
|
829
|
-
UPDATE adcp_reporting_obligations obligation
|
|
830
|
-
SET lease_owner = $1, lease_generation = lease_generation + 1,
|
|
831
|
-
lease_expires_at = clock_timestamp() + ($3::bigint * INTERVAL '1 millisecond')
|
|
832
|
-
FROM candidate WHERE obligation.obligation_id = candidate.obligation_id
|
|
833
|
-
RETURNING obligation.data, obligation.lease_generation::text, obligation.lease_expires_at`,
|
|
834
|
-
[input.owner, input.now, input.leaseMilliseconds, input.account_id ?? null]
|
|
835
|
-
);
|
|
836
|
-
const row = result.rows[0];
|
|
837
|
-
return row ? {
|
|
838
|
-
obligation: clone(row.data),
|
|
839
|
-
owner: input.owner,
|
|
840
|
-
generation: Number(row.lease_generation),
|
|
841
|
-
expiresAt: row.lease_expires_at.toISOString()
|
|
842
|
-
} : null;
|
|
843
|
-
}
|
|
844
|
-
async releaseObligationLease(lease) {
|
|
845
|
-
await this.query(
|
|
846
|
-
`UPDATE adcp_reporting_obligations SET lease_owner = NULL, lease_expires_at = NULL
|
|
847
|
-
WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3`,
|
|
848
|
-
[lease.obligation.reporting_obligation_id, lease.owner, lease.generation]
|
|
849
|
-
);
|
|
850
|
-
}
|
|
851
|
-
async commitRevision(revision, lease) {
|
|
852
|
-
assertLeaseTarget(revision.reporting_obligation_id, lease);
|
|
853
|
-
validateRevisionBinding(revision);
|
|
854
|
-
const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
|
|
855
|
-
rowSetId: revision.reporting_revision_id,
|
|
856
|
-
rowSetKind: "revision",
|
|
857
|
-
accountId: lease.obligation.account.account_id,
|
|
858
|
-
obligationId: revision.reporting_obligation_id,
|
|
859
|
-
rows: revision.rows,
|
|
860
|
-
finality: revision.finality,
|
|
861
|
-
periodStart: lease.obligation.period.start,
|
|
862
|
-
sourceTimezone: lease.obligation.period.sourceTimezone,
|
|
863
|
-
binding: revision.binding,
|
|
864
|
-
controlTotals: revision.wireRevision.control_totals
|
|
865
|
-
}) : void 0;
|
|
866
|
-
const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_revisions", "revision_id") : void 0;
|
|
867
|
-
const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
|
|
868
|
-
const result = await this.putImmutable(
|
|
869
|
-
`WITH leased_obligation AS (
|
|
870
|
-
SELECT * FROM adcp_reporting_obligations obligation
|
|
871
|
-
WHERE obligation.obligation_id = $2
|
|
872
|
-
AND obligation.lease_owner = $9 AND obligation.lease_generation = $10
|
|
873
|
-
AND obligation.lease_expires_at > clock_timestamp()
|
|
874
|
-
FOR UPDATE
|
|
875
|
-
)
|
|
876
|
-
INSERT INTO adcp_reporting_revisions
|
|
877
|
-
(revision_id, obligation_id, revision_number, finality, kind,
|
|
878
|
-
supersedes_revision_id, content_sha256, data, created_at)
|
|
879
|
-
SELECT $1, $2, $3, $4, $5, $6, $7, $8::jsonb, clock_timestamp()
|
|
880
|
-
FROM leased_obligation obligation
|
|
881
|
-
WHERE NOT EXISTS (
|
|
882
|
-
SELECT 1 FROM adcp_reporting_revisions terminal_revision
|
|
883
|
-
WHERE terminal_revision.obligation_id = obligation.obligation_id
|
|
884
|
-
AND terminal_revision.finality = 'official'
|
|
885
|
-
)
|
|
886
|
-
AND $3 = COALESCE((
|
|
887
|
-
SELECT MAX(sequence_revision.revision_number) + 1
|
|
888
|
-
FROM adcp_reporting_revisions sequence_revision
|
|
889
|
-
WHERE sequence_revision.obligation_id = obligation.obligation_id
|
|
890
|
-
), 1)
|
|
891
|
-
AND (($3 = 1 AND $6::text IS NULL) OR $6 = (
|
|
892
|
-
SELECT predecessor.revision_id FROM adcp_reporting_revisions predecessor
|
|
893
|
-
WHERE predecessor.obligation_id = obligation.obligation_id
|
|
894
|
-
AND predecessor.revision_number = $3 - 1
|
|
895
|
-
))
|
|
896
|
-
ON CONFLICT DO NOTHING RETURNING data`,
|
|
897
|
-
[
|
|
898
|
-
revision.reporting_revision_id,
|
|
899
|
-
revision.reporting_obligation_id,
|
|
900
|
-
revision.revisionNumber,
|
|
901
|
-
revision.finality,
|
|
902
|
-
revision.kind,
|
|
903
|
-
revision.supersedes_reporting_revision_id ?? null,
|
|
904
|
-
revision.binding.sha256,
|
|
905
|
-
JSON.stringify(prepared ? withoutRows(revision) : revision),
|
|
906
|
-
lease.owner,
|
|
907
|
-
lease.generation
|
|
908
|
-
],
|
|
909
|
-
"SELECT data FROM adcp_reporting_revisions WHERE obligation_id = $1 AND revision_number = $2",
|
|
910
|
-
[revision.reporting_obligation_id, revision.revisionNumber],
|
|
911
|
-
revision,
|
|
912
|
-
revisionIdentityFingerprint,
|
|
913
|
-
accountLock(lease.obligation.account.account_id),
|
|
914
|
-
{
|
|
915
|
-
obligationId: revision.reporting_obligation_id,
|
|
916
|
-
owner: lease.owner,
|
|
917
|
-
generation: lease.generation
|
|
918
|
-
},
|
|
919
|
-
revisionLegacyCanonicalDigestReplay,
|
|
920
|
-
prepared || port || this.changeFeed ? async (client, committed) => {
|
|
921
|
-
if (prepared) await this.writePreparedRowSet(client, prepared, upload);
|
|
922
|
-
await this.appendChange(
|
|
923
|
-
client,
|
|
924
|
-
"revision",
|
|
925
|
-
lease.obligation.account.account_id,
|
|
926
|
-
revision.reporting_revision_id,
|
|
927
|
-
revision.reporting_obligation_id
|
|
928
|
-
);
|
|
929
|
-
await port?.recordLedgerChanged(
|
|
930
|
-
{ obligation: lease.obligation, revision: withRows(committed, revision.rows) },
|
|
931
|
-
client
|
|
932
|
-
);
|
|
933
|
-
} : void 0
|
|
934
|
-
);
|
|
935
|
-
return { inserted: result.inserted, value: withRows(result.value, revision.rows) };
|
|
936
|
-
}
|
|
937
|
-
async getRevision(id, accountId) {
|
|
938
|
-
const revision = await this.one(
|
|
939
|
-
`SELECT revision.data FROM adcp_reporting_revisions revision
|
|
940
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
941
|
-
WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
|
|
942
|
-
[id, accountId]
|
|
943
|
-
);
|
|
944
|
-
return revision ? this.hydrateRevision(revision, accountId) : null;
|
|
945
|
-
}
|
|
946
|
-
async getRevisionMetadata(id, accountId) {
|
|
947
|
-
return this.one(
|
|
948
|
-
`SELECT revision.data - 'rows' AS data FROM adcp_reporting_revisions revision
|
|
949
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
950
|
-
WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
|
|
951
|
-
[id, accountId]
|
|
952
|
-
);
|
|
953
|
-
}
|
|
954
|
-
async readRevisionRows(input) {
|
|
955
|
-
if (!Number.isSafeInteger(input.offset) || input.offset < 0 || !Number.isSafeInteger(input.limit) || input.limit < 1) {
|
|
956
|
-
throw new RangeError("Reporting row page bounds are invalid");
|
|
957
|
-
}
|
|
958
|
-
const result = await this.query(
|
|
959
|
-
`SELECT revision.data ? 'rows' AS inline,
|
|
960
|
-
CASE WHEN revision.data ? 'rows' THEN jsonb_array_length(revision.data->'rows') END AS total,
|
|
961
|
-
CASE WHEN revision.data ? 'rows' THEN jsonb_path_query_array(
|
|
962
|
-
revision.data->'rows',
|
|
963
|
-
'$[$from to $to]',
|
|
964
|
-
jsonb_build_object('from', $3::bigint, 'to', $3::bigint + $4::bigint - 1)
|
|
965
|
-
) END AS rows
|
|
966
|
-
FROM adcp_reporting_revisions revision
|
|
967
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
968
|
-
WHERE revision.revision_id = $1 AND obligation.account_id = $2`,
|
|
969
|
-
[input.reporting_revision_id, input.account_id, input.offset, input.limit]
|
|
970
|
-
);
|
|
971
|
-
const row = result.rows[0];
|
|
972
|
-
if (!row) return null;
|
|
973
|
-
if (row.inline) return { rows: clone(row.rows ?? []), total: Number(row.total) };
|
|
974
|
-
const handle = await this.rowStorage.find(input.reporting_revision_id, input.account_id);
|
|
975
|
-
if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
|
|
976
|
-
if (input.offset >= handle.rowCount) return { rows: [], total: handle.rowCount };
|
|
977
|
-
return {
|
|
978
|
-
rows: await this.rowStorage.readPage(handle, input.offset, input.limit, input.signal),
|
|
979
|
-
total: handle.rowCount
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
async listRevisions(obligationId) {
|
|
983
|
-
const result = await this.query(
|
|
984
|
-
`SELECT revision.data, obligation.account_id FROM adcp_reporting_revisions revision
|
|
985
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
986
|
-
WHERE revision.obligation_id = $1
|
|
987
|
-
ORDER BY revision.revision_number, revision.revision_id`,
|
|
988
|
-
[obligationId]
|
|
989
|
-
);
|
|
990
|
-
const revisions = [];
|
|
991
|
-
for (const row of result.rows) {
|
|
992
|
-
try {
|
|
993
|
-
revisions.push(await this.hydrateRevision(clone(row.data), row.account_id));
|
|
994
|
-
} catch (error) {
|
|
995
|
-
if ((0, import_row_storage.isReportingRowStoreError)(error) && error.code === "ROWS_EXPIRED") continue;
|
|
996
|
-
throw error;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
return revisions;
|
|
1000
|
-
}
|
|
1001
|
-
async listRevisionMetadata(obligationId, accountId) {
|
|
1002
|
-
const result = await this.query(
|
|
1003
|
-
`SELECT revision.data - 'rows' AS data FROM adcp_reporting_revisions revision
|
|
1004
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
1005
|
-
WHERE revision.obligation_id = $1 AND ($2::text IS NULL OR obligation.account_id = $2)
|
|
1006
|
-
ORDER BY revision.revision_number, revision.revision_id`,
|
|
1007
|
-
[obligationId, accountId ?? null]
|
|
1008
|
-
);
|
|
1009
|
-
return result.rows.map((row) => clone(row.data));
|
|
1010
|
-
}
|
|
1011
|
-
async commitAdjustment(adjustment, lease) {
|
|
1012
|
-
assertLeaseTarget(adjustment.reporting_obligation_id, lease);
|
|
1013
|
-
validateBoundRows(adjustment);
|
|
1014
|
-
const prepared = this.rowStorageWrites ? await this.rowStorage.prepare({
|
|
1015
|
-
rowSetId: adjustment.reporting_adjustment_id,
|
|
1016
|
-
rowSetKind: "adjustment",
|
|
1017
|
-
accountId: lease.obligation.account.account_id,
|
|
1018
|
-
obligationId: adjustment.reporting_obligation_id,
|
|
1019
|
-
rows: adjustment.rows,
|
|
1020
|
-
periodStart: lease.obligation.period.start,
|
|
1021
|
-
sourceTimezone: lease.obligation.period.sourceTimezone,
|
|
1022
|
-
binding: adjustment.binding
|
|
1023
|
-
}) : void 0;
|
|
1024
|
-
const upload = prepared?.binding.kind === "object" ? await this.uploadRowSetUnlessCommitted(prepared, lease, "adcp_reporting_adjustments", "adjustment_id") : void 0;
|
|
1025
|
-
const port = this.notificationActivityPort?.recordLedgerChanged ? this.notificationActivityPort : void 0;
|
|
1026
|
-
const result = await this.putImmutable(
|
|
1027
|
-
`WITH leased_obligation AS (
|
|
1028
|
-
SELECT * FROM adcp_reporting_obligations obligation
|
|
1029
|
-
WHERE obligation.obligation_id = $2
|
|
1030
|
-
AND obligation.lease_owner = $7 AND obligation.lease_generation = $8
|
|
1031
|
-
AND obligation.lease_expires_at > clock_timestamp()
|
|
1032
|
-
FOR UPDATE
|
|
1033
|
-
)
|
|
1034
|
-
INSERT INTO adcp_reporting_adjustments
|
|
1035
|
-
(adjustment_id, obligation_id, adjusts_revision_id, adjustment_number,
|
|
1036
|
-
content_sha256, data, created_at)
|
|
1037
|
-
SELECT $1, $2, $3, $4, $5, $6::jsonb, clock_timestamp()
|
|
1038
|
-
FROM leased_obligation obligation
|
|
1039
|
-
WHERE EXISTS (
|
|
1040
|
-
SELECT 1 FROM adcp_reporting_revisions official_revision
|
|
1041
|
-
WHERE official_revision.obligation_id = obligation.obligation_id
|
|
1042
|
-
AND official_revision.revision_id = $3
|
|
1043
|
-
AND official_revision.finality = 'official'
|
|
1044
|
-
)
|
|
1045
|
-
AND $4 = COALESCE((
|
|
1046
|
-
SELECT MAX(sequence_adjustment.adjustment_number) + 1
|
|
1047
|
-
FROM adcp_reporting_adjustments sequence_adjustment
|
|
1048
|
-
WHERE sequence_adjustment.obligation_id = obligation.obligation_id
|
|
1049
|
-
), 1)
|
|
1050
|
-
ON CONFLICT DO NOTHING RETURNING data`,
|
|
1051
|
-
[
|
|
1052
|
-
adjustment.reporting_adjustment_id,
|
|
1053
|
-
adjustment.reporting_obligation_id,
|
|
1054
|
-
adjustment.adjusts_reporting_revision_id,
|
|
1055
|
-
adjustment.adjustmentNumber,
|
|
1056
|
-
adjustment.binding.sha256,
|
|
1057
|
-
JSON.stringify(prepared ? withoutRows(adjustment) : adjustment),
|
|
1058
|
-
lease.owner,
|
|
1059
|
-
lease.generation
|
|
1060
|
-
],
|
|
1061
|
-
"SELECT data FROM adcp_reporting_adjustments WHERE obligation_id = $1 AND adjustment_number = $2",
|
|
1062
|
-
[adjustment.reporting_obligation_id, adjustment.adjustmentNumber],
|
|
1063
|
-
adjustment,
|
|
1064
|
-
adjustmentIdentityFingerprint,
|
|
1065
|
-
accountLock(lease.obligation.account.account_id),
|
|
1066
|
-
{
|
|
1067
|
-
obligationId: adjustment.reporting_obligation_id,
|
|
1068
|
-
owner: lease.owner,
|
|
1069
|
-
generation: lease.generation
|
|
1070
|
-
},
|
|
1071
|
-
adjustmentLegacyCanonicalDigestReplay,
|
|
1072
|
-
prepared || port || this.changeFeed ? async (client, committed) => {
|
|
1073
|
-
if (prepared) await this.writePreparedRowSet(client, prepared, upload);
|
|
1074
|
-
await this.appendChange(
|
|
1075
|
-
client,
|
|
1076
|
-
"adjustment",
|
|
1077
|
-
lease.obligation.account.account_id,
|
|
1078
|
-
adjustment.reporting_adjustment_id,
|
|
1079
|
-
adjustment.reporting_obligation_id
|
|
1080
|
-
);
|
|
1081
|
-
await port?.recordLedgerChanged(
|
|
1082
|
-
{ obligation: lease.obligation, adjustment: withRows(committed, adjustment.rows) },
|
|
1083
|
-
client
|
|
1084
|
-
);
|
|
1085
|
-
} : void 0
|
|
1086
|
-
);
|
|
1087
|
-
return { inserted: result.inserted, value: withRows(result.value, adjustment.rows) };
|
|
1088
|
-
}
|
|
1089
|
-
async listAdjustments(obligationId) {
|
|
1090
|
-
const result = await this.query(
|
|
1091
|
-
`SELECT adjustment.data, obligation.account_id FROM adcp_reporting_adjustments adjustment
|
|
1092
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
|
|
1093
|
-
WHERE adjustment.obligation_id = $1
|
|
1094
|
-
ORDER BY adjustment.adjustment_number, adjustment.adjustment_id`,
|
|
1095
|
-
[obligationId]
|
|
1096
|
-
);
|
|
1097
|
-
const adjustments = [];
|
|
1098
|
-
for (const row of result.rows) {
|
|
1099
|
-
const adjustment = clone(row.data);
|
|
1100
|
-
if (Array.isArray(adjustment.rows)) {
|
|
1101
|
-
adjustments.push(adjustment);
|
|
1102
|
-
continue;
|
|
1103
|
-
}
|
|
1104
|
-
const handle = await this.rowStorage.find(adjustment.reporting_adjustment_id, row.account_id);
|
|
1105
|
-
if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "adjustment rows are not recorded");
|
|
1106
|
-
adjustments.push({ ...adjustment, rows: await this.rowStorage.readAll(handle) });
|
|
1107
|
-
}
|
|
1108
|
-
return adjustments;
|
|
1109
|
-
}
|
|
1110
|
-
/**
|
|
1111
|
-
* The obligation's current revision header: its official revision if one
|
|
1112
|
-
* exists, otherwise its highest-numbered snapshot. An official revision is
|
|
1113
|
-
* terminal, so this is always the highest revision number — one index
|
|
1114
|
-
* lookup. Rows are read separately through `readRevisionRows`.
|
|
1115
|
-
*/
|
|
1116
|
-
async getCurrentRevision(input) {
|
|
1117
|
-
return this.one(
|
|
1118
|
-
`SELECT revision.data - 'rows' AS data
|
|
1119
|
-
FROM adcp_reporting_obligations obligation
|
|
1120
|
-
JOIN LATERAL (
|
|
1121
|
-
SELECT data FROM adcp_reporting_revisions current_revision
|
|
1122
|
-
WHERE current_revision.obligation_id = obligation.obligation_id
|
|
1123
|
-
ORDER BY current_revision.revision_number DESC LIMIT 1
|
|
1124
|
-
) revision ON TRUE
|
|
1125
|
-
WHERE obligation.obligation_id = $1 AND obligation.account_id = $2`,
|
|
1126
|
-
[input.reporting_obligation_id, input.account_id]
|
|
1127
|
-
);
|
|
1128
|
-
}
|
|
1129
|
-
/**
|
|
1130
|
-
* Current revision headers for an account's obligations whose period starts
|
|
1131
|
-
* in `[period_start_from, period_start_to)`, ordered by period start.
|
|
1132
|
-
* Obligations without a revision yet are omitted. Page with `cursor`.
|
|
1133
|
-
*/
|
|
1134
|
-
async listCurrentRevisions(input) {
|
|
1135
|
-
const limit = input.limit ?? 100;
|
|
1136
|
-
positiveInteger(limit, "limit");
|
|
1137
|
-
if (limit > 1e3) throw new RangeError("listCurrentRevisions limit must not exceed 1000");
|
|
1138
|
-
let after;
|
|
1139
|
-
if (input.cursor !== void 0) {
|
|
1140
|
-
try {
|
|
1141
|
-
const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString("utf8"));
|
|
1142
|
-
if (typeof parsed.start !== "string" || typeof parsed.id !== "string") throw new Error("invalid");
|
|
1143
|
-
after = { start: parsed.start, id: parsed.id };
|
|
1144
|
-
} catch {
|
|
1145
|
-
throw new RangeError("listCurrentRevisions cursor is invalid");
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
const result = await this.query(
|
|
1149
|
-
`SELECT revision.data - 'rows' AS data,
|
|
1150
|
-
to_char(obligation.period_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS start,
|
|
1151
|
-
obligation.obligation_id AS id
|
|
1152
|
-
FROM adcp_reporting_obligations obligation
|
|
1153
|
-
JOIN LATERAL (
|
|
1154
|
-
SELECT data FROM adcp_reporting_revisions current_revision
|
|
1155
|
-
WHERE current_revision.obligation_id = obligation.obligation_id
|
|
1156
|
-
ORDER BY current_revision.revision_number DESC LIMIT 1
|
|
1157
|
-
) revision ON TRUE
|
|
1158
|
-
WHERE obligation.account_id = $1
|
|
1159
|
-
AND obligation.period_start >= $2::timestamptz AND obligation.period_start < $3::timestamptz
|
|
1160
|
-
AND ($4::text IS NULL OR obligation.data->>'delivery_config_id' = $4)
|
|
1161
|
-
AND ($5::timestamptz IS NULL
|
|
1162
|
-
OR (obligation.period_start, obligation.obligation_id) > ($5::timestamptz, $6::text))
|
|
1163
|
-
ORDER BY obligation.period_start, obligation.obligation_id
|
|
1164
|
-
LIMIT $7`,
|
|
1165
|
-
[
|
|
1166
|
-
input.account_id,
|
|
1167
|
-
input.period_start_from,
|
|
1168
|
-
input.period_start_to,
|
|
1169
|
-
input.delivery_config_id ?? null,
|
|
1170
|
-
after?.start ?? null,
|
|
1171
|
-
after?.id ?? null,
|
|
1172
|
-
limit + 1
|
|
1173
|
-
]
|
|
1174
|
-
);
|
|
1175
|
-
const page = result.rows.slice(0, limit);
|
|
1176
|
-
const last = result.rows.length > limit ? page.at(-1) : void 0;
|
|
1177
|
-
return {
|
|
1178
|
-
revisions: page.map((row) => clone(row.data)),
|
|
1179
|
-
...last ? { cursor: Buffer.from(JSON.stringify({ start: last.start, id: last.id }), "utf8").toString("base64url") } : {}
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
/**
|
|
1183
|
-
* Host change feed. Omit `cursor` to start from the oldest retained change.
|
|
1184
|
-
* Account-scoped feeds order by sequence; the deployment-wide feed orders by
|
|
1185
|
-
* `(xid, seq)` and returns only changes whose transaction precedes every
|
|
1186
|
-
* transaction still in flight, so no commit is ever skipped. An account page
|
|
1187
|
-
* also ends before the first change whose transaction has not finished, as
|
|
1188
|
-
* defense in depth against a change that commits late. An empty page can
|
|
1189
|
-
* therefore mean either caught up or held back by an older open transaction.
|
|
1190
|
-
* A ledger database must have exactly one writer SDK; see
|
|
1191
|
-
* `docs/guides/REPORTING-LEDGER.md`. A cursor older
|
|
1192
|
-
* than the retained history throws `ReportingChangeCursorExpiredError`;
|
|
1193
|
-
* resynchronize from `listCurrentRevisions`.
|
|
1194
|
-
*
|
|
1195
|
-
* A read without a `cursor` can throw `ReportingChangeCursorExpiredError`
|
|
1196
|
-
* (`code === 'CURSOR_EXPIRED'`) too. The horizon is re-read after the page,
|
|
1197
|
-
* so a prune that commits mid-read rejects the page instead of returning one
|
|
1198
|
-
* that silently skips the pruned changes. Retrying the read starts from the
|
|
1199
|
-
* new, later horizon. Match on `error.code` rather than `instanceof`, which
|
|
1200
|
-
* fails when the error crosses a CJS/ESM module boundary.
|
|
1201
|
-
*/
|
|
1202
|
-
async changesAfter(input = {}) {
|
|
1203
|
-
if (!this.changeFeed) throw new Error("changesAfter requires the store changeFeed option");
|
|
1204
|
-
const limit = input.limit ?? 500;
|
|
1205
|
-
positiveInteger(limit, "limit");
|
|
1206
|
-
if (limit > 1e4) throw new RangeError("changesAfter limit must not exceed 10000");
|
|
1207
|
-
const account = input.account_id ?? null;
|
|
1208
|
-
const cursor = input.cursor === void 0 ? void 0 : (0, import_change_feed.decodeReportingChangeCursorV1)(input.cursor, account);
|
|
1209
|
-
const readHorizon = async () => account !== null ? {
|
|
1210
|
-
seq: (await this.query(
|
|
1211
|
-
`SELECT pruned_through_seq::text AS seq FROM adcp_reporting_change_account_horizon
|
|
1212
|
-
WHERE account_id = $1`,
|
|
1213
|
-
[account]
|
|
1214
|
-
)).rows[0]?.seq ?? "0"
|
|
1215
|
-
} : (await this.query(
|
|
1216
|
-
`SELECT pruned_through_xid::text AS xid, pruned_through_seq::text AS seq
|
|
1217
|
-
FROM adcp_reporting_change_horizon WHERE singleton`
|
|
1218
|
-
)).rows[0] ?? { xid: "0", seq: "0" };
|
|
1219
|
-
const assertRetained = (position2, horizon2) => {
|
|
1220
|
-
const behind = account !== null ? BigInt(horizon2.seq) > BigInt(position2.seq) : BigInt(horizon2.xid) > BigInt(position2.xid) || BigInt(horizon2.xid) === BigInt(position2.xid) && BigInt(horizon2.seq) > BigInt(position2.seq);
|
|
1221
|
-
if (behind) {
|
|
1222
|
-
throw new import_change_feed.ReportingChangeCursorExpiredError(
|
|
1223
|
-
(0, import_change_feed.encodeReportingChangeCursorV1)({
|
|
1224
|
-
v: 1,
|
|
1225
|
-
account,
|
|
1226
|
-
seq: position2.seq,
|
|
1227
|
-
...account === null ? { xid: position2.xid } : {}
|
|
1228
|
-
})
|
|
1229
|
-
);
|
|
1230
|
-
}
|
|
1231
|
-
};
|
|
1232
|
-
const horizon = await readHorizon();
|
|
1233
|
-
if (cursor) assertRetained(cursor, horizon);
|
|
1234
|
-
const position = cursor ?? {
|
|
1235
|
-
v: 1,
|
|
1236
|
-
account,
|
|
1237
|
-
seq: horizon.seq,
|
|
1238
|
-
...account === null ? { xid: horizon.xid } : {}
|
|
1239
|
-
};
|
|
1240
|
-
const kinds = input.kinds?.length ? [...input.kinds] : null;
|
|
1241
|
-
const columns = `seq::text AS seq, xid::text AS xid, account_id, record_kind, record_id, obligation_id,
|
|
1242
|
-
to_char(recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS recorded_at`;
|
|
1243
|
-
const result = account !== null ? await this.query(
|
|
1244
|
-
`SELECT ${columns}, xid >= pg_snapshot_xmin(pg_current_snapshot()) AS in_flight
|
|
1245
|
-
FROM adcp_reporting_changes
|
|
1246
|
-
WHERE account_id = $1 AND seq > $2::bigint AND ($3::text[] IS NULL OR record_kind = ANY($3))
|
|
1247
|
-
ORDER BY adcp_reporting_changes.seq LIMIT $4`,
|
|
1248
|
-
[account, position.seq, kinds, limit]
|
|
1249
|
-
) : await this.query(
|
|
1250
|
-
`SELECT ${columns} FROM adcp_reporting_changes
|
|
1251
|
-
WHERE (xid, seq) > ($1::text::xid8, $2::bigint)
|
|
1252
|
-
AND xid < pg_snapshot_xmin(pg_current_snapshot())
|
|
1253
|
-
AND ($3::text[] IS NULL OR record_kind = ANY($3))
|
|
1254
|
-
ORDER BY adcp_reporting_changes.xid, adcp_reporting_changes.seq LIMIT $4`,
|
|
1255
|
-
[position.xid ?? "0", position.seq, kinds, limit]
|
|
1256
|
-
);
|
|
1257
|
-
if (account !== null) {
|
|
1258
|
-
const blocked = result.rows.findIndex((row) => row.in_flight);
|
|
1259
|
-
if (blocked !== -1) result.rows.length = blocked;
|
|
1260
|
-
}
|
|
1261
|
-
assertRetained(position, await readHorizon());
|
|
1262
|
-
const last = result.rows.at(-1);
|
|
1263
|
-
const next = last ? (0, import_change_feed.encodeReportingChangeCursorV1)({ v: 1, account, seq: last.seq, ...account === null ? { xid: last.xid } : {} }) : input.cursor ?? (0, import_change_feed.encodeReportingChangeCursorV1)(position);
|
|
1264
|
-
return {
|
|
1265
|
-
records: result.rows.map((row) => ({
|
|
1266
|
-
kind: row.record_kind,
|
|
1267
|
-
record_id: row.record_id,
|
|
1268
|
-
reporting_obligation_id: row.obligation_id,
|
|
1269
|
-
account_id: row.account_id,
|
|
1270
|
-
recorded_at: row.recorded_at
|
|
1271
|
-
})),
|
|
1272
|
-
cursor: next
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
/**
|
|
1276
|
-
* Record a named durable consumer's position. Registered consumers hold
|
|
1277
|
-
* back change-table pruning until `maxFeedHoldDays` after their last update.
|
|
1278
|
-
*
|
|
1279
|
-
* Checkpoints are forward-only within a feed scope: a cursor behind the
|
|
1280
|
-
* saved one is ignored, so a delayed or stale acknowledgment cannot pull the
|
|
1281
|
-
* retention hold back. Resolves `true` when the checkpoint was written and
|
|
1282
|
-
* `false` when it was ignored (behind the saved position, or equal with
|
|
1283
|
-
* `refreshUnchanged: false`). To rewind a consumer deliberately, for example
|
|
1284
|
-
* to replay changes, pass `rewind: true`; the saved position and retention
|
|
1285
|
-
* hold then move to `cursor` and the write always applies, even with
|
|
1286
|
-
* `refreshUnchanged: false`. The cursor is not checked against the
|
|
1287
|
-
* retention horizon: it must still be inside the retained history or the
|
|
1288
|
-
* next read throws `ReportingChangeCursorExpiredError`. Any change of
|
|
1289
|
-
* `account_id` (account to deployment, deployment to account, or between
|
|
1290
|
-
* accounts) always replaces the position. The feed scope is read from the
|
|
1291
|
-
* cursor itself, so only pass cursors your own code obtained from
|
|
1292
|
-
* `changesAfter`, never ones supplied by an untrusted caller.
|
|
1293
|
-
*
|
|
1294
|
-
* Set `refreshUnchanged: false` to recover an acknowledgment without
|
|
1295
|
-
* renewing the retention hold when its position has not advanced.
|
|
1296
|
-
*/
|
|
1297
|
-
async saveFeedConsumerCursor(name, cursor, options = {}) {
|
|
1298
|
-
if (!this.changeFeed) throw new Error("saveFeedConsumerCursor requires the store changeFeed option");
|
|
1299
|
-
let account;
|
|
1300
|
-
try {
|
|
1301
|
-
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1302
|
-
account = typeof parsed.account === "string" ? parsed.account : null;
|
|
1303
|
-
} catch {
|
|
1304
|
-
throw new RangeError("feed consumer cursor is invalid");
|
|
1305
|
-
}
|
|
1306
|
-
const decoded = (0, import_change_feed.decodeReportingChangeCursorV1)(cursor, account);
|
|
1307
|
-
const result = await this.query(
|
|
1308
|
-
`INSERT INTO adcp_reporting_feed_consumers (consumer_name, account_id, cursor_xid, cursor_seq)
|
|
1309
|
-
VALUES ($1, $2, $3::text::xid8, $4::bigint)
|
|
1310
|
-
ON CONFLICT (consumer_name) DO UPDATE
|
|
1311
|
-
SET account_id = EXCLUDED.account_id, cursor_xid = EXCLUDED.cursor_xid,
|
|
1312
|
-
cursor_seq = EXCLUDED.cursor_seq, updated_at = clock_timestamp()
|
|
1313
|
-
WHERE $6::boolean
|
|
1314
|
-
OR adcp_reporting_feed_consumers.account_id IS DISTINCT FROM EXCLUDED.account_id
|
|
1315
|
-
OR CASE WHEN EXCLUDED.account_id IS NULL
|
|
1316
|
-
THEN (adcp_reporting_feed_consumers.cursor_xid, adcp_reporting_feed_consumers.cursor_seq)
|
|
1317
|
-
< (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq)
|
|
1318
|
-
OR ($5::boolean AND (adcp_reporting_feed_consumers.cursor_xid, adcp_reporting_feed_consumers.cursor_seq)
|
|
1319
|
-
= (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq))
|
|
1320
|
-
ELSE adcp_reporting_feed_consumers.cursor_seq < EXCLUDED.cursor_seq
|
|
1321
|
-
OR ($5::boolean AND adcp_reporting_feed_consumers.cursor_seq = EXCLUDED.cursor_seq) END
|
|
1322
|
-
RETURNING consumer_name`,
|
|
1323
|
-
[
|
|
1324
|
-
name,
|
|
1325
|
-
account,
|
|
1326
|
-
account === null ? decoded.xid : null,
|
|
1327
|
-
decoded.seq,
|
|
1328
|
-
options.refreshUnchanged !== false,
|
|
1329
|
-
options.rewind === true
|
|
1330
|
-
]
|
|
1331
|
-
);
|
|
1332
|
-
return result.rows.length > 0;
|
|
1333
|
-
}
|
|
1334
|
-
/**
|
|
1335
|
-
* Unregister a durable consumer so it stops holding back change-table
|
|
1336
|
-
* pruning and period retention. Resolves `true` when a registration was
|
|
1337
|
-
* removed. Saving a cursor under the same name registers it again.
|
|
1338
|
-
*/
|
|
1339
|
-
async removeFeedConsumer(name) {
|
|
1340
|
-
if (!this.changeFeed) throw new Error("removeFeedConsumer requires the store changeFeed option");
|
|
1341
|
-
const result = await this.query(
|
|
1342
|
-
`DELETE FROM adcp_reporting_feed_consumers WHERE consumer_name = $1 RETURNING consumer_name`,
|
|
1343
|
-
[name]
|
|
1344
|
-
);
|
|
1345
|
-
return result.rows.length > 0;
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* Delete change rows older than `changeRetentionDays` that every consumer
|
|
1349
|
-
* updated within `maxFeedHoldDays` has passed, and advance the expiry
|
|
1350
|
-
* horizon so older cursors fail closed.
|
|
1351
|
-
*/
|
|
1352
|
-
async pruneChanges(input = {}) {
|
|
1353
|
-
if (!this.changeFeed) return { deleted: 0 };
|
|
1354
|
-
const retentionDays = input.changeRetentionDays ?? 30;
|
|
1355
|
-
const holdDays = input.maxFeedHoldDays ?? 7;
|
|
1356
|
-
positiveInteger(retentionDays, "changeRetentionDays");
|
|
1357
|
-
positiveInteger(holdDays, "maxFeedHoldDays");
|
|
1358
|
-
const limit = input.limit ?? 1e4;
|
|
1359
|
-
positiveInteger(limit, "limit");
|
|
1360
|
-
const deleted = await this.transaction(async (client) => {
|
|
1361
|
-
const victims = await client.query(
|
|
1362
|
-
`WITH victims AS (
|
|
1363
|
-
SELECT change.seq FROM adcp_reporting_changes change
|
|
1364
|
-
WHERE change.recorded_at < clock_timestamp() - ($1::integer * INTERVAL '1 day')
|
|
1365
|
-
AND NOT EXISTS (
|
|
1366
|
-
SELECT 1 FROM adcp_reporting_feed_consumers consumer
|
|
1367
|
-
WHERE consumer.updated_at > clock_timestamp() - ($2::integer * INTERVAL '1 day')
|
|
1368
|
-
AND ((consumer.account_id IS NULL
|
|
1369
|
-
AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
|
|
1370
|
-
OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq))
|
|
1371
|
-
)
|
|
1372
|
-
ORDER BY change.seq LIMIT $3
|
|
1373
|
-
)
|
|
1374
|
-
DELETE FROM adcp_reporting_changes change USING victims
|
|
1375
|
-
WHERE change.seq = victims.seq
|
|
1376
|
-
RETURNING change.seq::text AS seq, change.xid::text AS xid, change.account_id`,
|
|
1377
|
-
[retentionDays, holdDays, limit]
|
|
1378
|
-
);
|
|
1379
|
-
if (victims.rows.length === 0) return 0;
|
|
1380
|
-
const highest = victims.rows.reduce(
|
|
1381
|
-
(max, row) => BigInt(row.xid) > BigInt(max.xid) || row.xid === max.xid && BigInt(row.seq) > BigInt(max.seq) ? row : max
|
|
1382
|
-
);
|
|
1383
|
-
await client.query(
|
|
1384
|
-
`UPDATE adcp_reporting_change_horizon
|
|
1385
|
-
SET (pruned_through_xid, pruned_through_seq) = ($1::text::xid8, $2::bigint)
|
|
1386
|
-
WHERE singleton AND ($1::text::xid8, $2::bigint) > (pruned_through_xid, pruned_through_seq)`,
|
|
1387
|
-
[highest.xid, highest.seq]
|
|
1388
|
-
);
|
|
1389
|
-
const perAccount = /* @__PURE__ */ new Map();
|
|
1390
|
-
for (const row of victims.rows) {
|
|
1391
|
-
const seq = BigInt(row.seq);
|
|
1392
|
-
if (seq > (perAccount.get(row.account_id) ?? 0n)) perAccount.set(row.account_id, seq);
|
|
1393
|
-
}
|
|
1394
|
-
for (const [accountId, seq] of perAccount) {
|
|
1395
|
-
await client.query(
|
|
1396
|
-
`INSERT INTO adcp_reporting_change_account_horizon (account_id, pruned_through_seq) VALUES ($1, $2::bigint)
|
|
1397
|
-
ON CONFLICT (account_id) DO UPDATE
|
|
1398
|
-
SET pruned_through_seq = GREATEST(adcp_reporting_change_account_horizon.pruned_through_seq,
|
|
1399
|
-
EXCLUDED.pruned_through_seq)`,
|
|
1400
|
-
[accountId, seq.toString()]
|
|
1401
|
-
);
|
|
1402
|
-
}
|
|
1403
|
-
return victims.rows.length;
|
|
1404
|
-
});
|
|
1405
|
-
return { deleted };
|
|
1406
|
-
}
|
|
1407
|
-
/**
|
|
1408
|
-
* Retire whole reporting periods whose retention has elapsed (shared SDK
|
|
1409
|
-
* persistence spec §4). A period is eligible once both its end and its
|
|
1410
|
-
* latest publication are older than `max(statusRetentionDays,
|
|
1411
|
-
* recordRetentionDays)`, no worker holds its lease, and no Managed Delivery
|
|
1412
|
-
* materialization still references it. Retirement is period-aligned and
|
|
1413
|
-
* resumable:
|
|
1414
|
-
*
|
|
1415
|
-
* 1. under the account lock, record a `retiring` tombstone and mark the
|
|
1416
|
-
* period's row sets `pruning`, so exact reads answer as expired;
|
|
1417
|
-
* 2. delete the period's external row objects at their recorded versions;
|
|
1418
|
-
* 3. under the account lock, delete the period's ledger records (rows,
|
|
1419
|
-
* revisions, adjustments, consumer statuses, issues, transitions,
|
|
1420
|
-
* lifecycle state, obligation) and mark the tombstone `retired`.
|
|
1421
|
-
*
|
|
1422
|
-
* Planners skip retired periods, and `ledger_retained_from` advances past
|
|
1423
|
-
* them because their obligations no longer exist.
|
|
1424
|
-
*
|
|
1425
|
-
* When the change-feed tables exist, a period that is not yet retiring is
|
|
1426
|
-
* also held while any live registered feed consumer (updated within
|
|
1427
|
-
* `maxFeedHoldDays`, default 7) has not passed that period's change rows in
|
|
1428
|
-
* the consumer's own feed order, the same rule `pruneChanges` applies. A
|
|
1429
|
-
* held period is simply not a candidate this pass. A consumer that stays
|
|
1430
|
-
* silent past `maxFeedHoldDays` stops holding, so retention is never
|
|
1431
|
-
* blocked forever; it then falls behind retention and a sink sees the
|
|
1432
|
-
* retired revisions as missing.
|
|
1433
|
-
*/
|
|
1434
|
-
async retireExpiredPeriods(input) {
|
|
1435
|
-
if (!Number.isSafeInteger(input.statusRetentionDays) || input.statusRetentionDays < 1) {
|
|
1436
|
-
throw new RangeError("statusRetentionDays must be a positive integer");
|
|
1437
|
-
}
|
|
1438
|
-
const recordDays = input.recordRetentionDays ?? input.statusRetentionDays;
|
|
1439
|
-
if (!Number.isSafeInteger(recordDays) || recordDays < input.statusRetentionDays) {
|
|
1440
|
-
throw new RangeError("recordRetentionDays must be an integer no shorter than statusRetentionDays");
|
|
1441
|
-
}
|
|
1442
|
-
const limit = input.limit ?? 100;
|
|
1443
|
-
positiveInteger(limit, "limit");
|
|
1444
|
-
const holdDays = input.maxFeedHoldDays ?? 7;
|
|
1445
|
-
positiveInteger(holdDays, "maxFeedHoldDays");
|
|
1446
|
-
const tables = await this.optionalTables();
|
|
1447
|
-
const materializationHold = tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (SELECT 1 FROM adcp_reporting_materializations hold WHERE hold.obligation_id = obligation.obligation_id)` : "";
|
|
1448
|
-
const feedHold = tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
|
|
1449
|
-
SELECT 1 FROM adcp_reporting_changes change
|
|
1450
|
-
JOIN adcp_reporting_feed_consumers consumer
|
|
1451
|
-
ON consumer.updated_at > clock_timestamp() - ($4::integer * INTERVAL '1 day')
|
|
1452
|
-
WHERE change.obligation_id = obligation.obligation_id
|
|
1453
|
-
AND ((consumer.account_id IS NULL
|
|
1454
|
-
AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
|
|
1455
|
-
OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : "";
|
|
1456
|
-
const candidates = await this.query(
|
|
1457
|
-
`SELECT obligation_id, account_id FROM (
|
|
1458
|
-
SELECT tombstone.obligation_id, tombstone.account_id, 0 AS resume, tombstone.attempts, tombstone.period_end,
|
|
1459
|
-
CASE WHEN row_number() OVER (ORDER BY tombstone.attempts, tombstone.period_end, tombstone.obligation_id)
|
|
1460
|
-
<= (($1::integer + 1) / 2) THEN 0 ELSE 1 END AS tier
|
|
1461
|
-
FROM adcp_reporting_obligation_tombstones tombstone
|
|
1462
|
-
WHERE tombstone.state = 'retiring' AND ($2::text IS NULL OR tombstone.account_id = $2)
|
|
1463
|
-
AND (tombstone.next_attempt_at IS NULL OR tombstone.next_attempt_at <= clock_timestamp())
|
|
1464
|
-
UNION ALL
|
|
1465
|
-
(SELECT obligation.obligation_id, obligation.account_id, 1, 0, obligation.period_end, 0
|
|
1466
|
-
FROM adcp_reporting_obligations obligation
|
|
1467
|
-
WHERE ($2::text IS NULL OR obligation.account_id = $2)
|
|
1468
|
-
AND obligation.period_end + ($3::integer * INTERVAL '1 day') <= clock_timestamp()
|
|
1469
|
-
AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
|
|
1470
|
-
AND COALESCE((SELECT MAX(revision.recorded_at) FROM adcp_reporting_revisions revision
|
|
1471
|
-
WHERE revision.obligation_id = obligation.obligation_id), obligation.created_at)
|
|
1472
|
-
+ ($3::integer * INTERVAL '1 day') <= clock_timestamp()
|
|
1473
|
-
AND COALESCE((SELECT MAX(adjustment.recorded_at) FROM adcp_reporting_adjustments adjustment
|
|
1474
|
-
WHERE adjustment.obligation_id = obligation.obligation_id), '-infinity'::timestamptz)
|
|
1475
|
-
+ ($3::integer * INTERVAL '1 day') <= clock_timestamp()
|
|
1476
|
-
AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
|
|
1477
|
-
WHERE tombstone.obligation_id = obligation.obligation_id)
|
|
1478
|
-
${materializationHold}
|
|
1479
|
-
${feedHold}
|
|
1480
|
-
ORDER BY obligation.period_end, obligation.obligation_id
|
|
1481
|
-
LIMIT $1)
|
|
1482
|
-
) candidate
|
|
1483
|
-
ORDER BY tier, resume, attempts, period_end, obligation_id
|
|
1484
|
-
LIMIT $1`,
|
|
1485
|
-
feedHold ? [limit, input.account_id ?? null, recordDays, holdDays] : [limit, input.account_id ?? null, recordDays]
|
|
1486
|
-
);
|
|
1487
|
-
let retired = 0;
|
|
1488
|
-
const failed = [];
|
|
1489
|
-
const failures = [];
|
|
1490
|
-
for (const candidate of candidates.rows) {
|
|
1491
|
-
input.signal?.throwIfAborted();
|
|
1492
|
-
try {
|
|
1493
|
-
await this.retirePeriod(candidate.obligation_id, candidate.account_id, tables, input.signal);
|
|
1494
|
-
retired += 1;
|
|
1495
|
-
} catch (error) {
|
|
1496
|
-
if (error instanceof ReportingLedgerPeriodRetiredError) continue;
|
|
1497
|
-
const cause = (0, import_row_storage.isReportingRowStoreError)(error) ? error.code : error instanceof Error ? error.name : "Error";
|
|
1498
|
-
failed.push(candidate.obligation_id);
|
|
1499
|
-
failures.push({ reporting_obligation_id: candidate.obligation_id, cause });
|
|
1500
|
-
if (!input.signal?.aborted) await this.recordRetirementFailure(candidate.obligation_id, cause);
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
const scope = input.account_id ?? null;
|
|
1504
|
-
const deferred = Number(
|
|
1505
|
-
(await this.query(
|
|
1506
|
-
`SELECT count(*) AS n FROM adcp_reporting_obligation_tombstones
|
|
1507
|
-
WHERE state = 'retiring' AND next_attempt_at > clock_timestamp()
|
|
1508
|
-
AND ($1::text IS NULL OR account_id = $1)`,
|
|
1509
|
-
[scope]
|
|
1510
|
-
)).rows[0].n
|
|
1511
|
-
);
|
|
1512
|
-
const stuck = (await this.query(
|
|
1513
|
-
`SELECT obligation_id, attempts, last_failure FROM adcp_reporting_obligation_tombstones
|
|
1514
|
-
WHERE state = 'retiring' AND attempts >= $2 AND ($1::text IS NULL OR account_id = $1)
|
|
1515
|
-
ORDER BY attempts DESC, retiring_at, obligation_id LIMIT 20`,
|
|
1516
|
-
[scope, REPORTING_PERSISTENT_FAILURE_ATTEMPTS]
|
|
1517
|
-
)).rows.map((row) => ({
|
|
1518
|
-
reporting_obligation_id: row.obligation_id,
|
|
1519
|
-
attempts: row.attempts,
|
|
1520
|
-
cause: row.last_failure ?? "Error"
|
|
1521
|
-
}));
|
|
1522
|
-
return { retired, failed, failures, deferred, stuck };
|
|
1523
|
-
}
|
|
1524
|
-
/**
|
|
1525
|
-
* Record a failed retirement attempt on the period's tombstone and push its next
|
|
1526
|
-
* attempt out by an exponential backoff. Best effort: a failure to record only
|
|
1527
|
-
* means the period is retried on the next pass.
|
|
1528
|
-
*/
|
|
1529
|
-
async recordRetirementFailure(obligationId, cause) {
|
|
1530
|
-
try {
|
|
1531
|
-
await this.query(
|
|
1532
|
-
`UPDATE adcp_reporting_obligation_tombstones
|
|
1533
|
-
SET attempts = attempts + 1, last_failure = $2,
|
|
1534
|
-
next_attempt_at = clock_timestamp() + ${backoffIntervalSql("attempts + 1")}
|
|
1535
|
-
WHERE obligation_id = $1 AND state = 'retiring'`,
|
|
1536
|
-
[obligationId, cause]
|
|
1537
|
-
);
|
|
1538
|
-
} catch {
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
async retirePeriod(obligationId, accountId, tables, signal) {
|
|
1542
|
-
const rowStorage = tables.has("adcp_reporting_row_sets");
|
|
1543
|
-
await this.transaction(
|
|
1544
|
-
async (client) => {
|
|
1545
|
-
await client.query(
|
|
1546
|
-
`INSERT INTO adcp_reporting_obligation_tombstones
|
|
1547
|
-
(obligation_id, account_id, configuration_id, period_ordinal, period_start, period_end, state,
|
|
1548
|
-
final_revision_id, final_content_sha256, revision_count, adjustment_count)
|
|
1549
|
-
SELECT obligation.obligation_id, obligation.account_id, obligation.configuration_id,
|
|
1550
|
-
obligation.period_ordinal, obligation.period_start, obligation.period_end, 'retiring',
|
|
1551
|
-
latest.revision_id, latest.content_sha256,
|
|
1552
|
-
(SELECT count(*) FROM adcp_reporting_revisions revision WHERE revision.obligation_id = obligation.obligation_id),
|
|
1553
|
-
(SELECT count(*) FROM adcp_reporting_adjustments adjustment WHERE adjustment.obligation_id = obligation.obligation_id)
|
|
1554
|
-
FROM adcp_reporting_obligations obligation
|
|
1555
|
-
LEFT JOIN LATERAL (
|
|
1556
|
-
SELECT revision.revision_id, revision.content_sha256 FROM adcp_reporting_revisions revision
|
|
1557
|
-
WHERE revision.obligation_id = obligation.obligation_id
|
|
1558
|
-
ORDER BY revision.revision_number DESC LIMIT 1
|
|
1559
|
-
) latest ON TRUE
|
|
1560
|
-
WHERE obligation.obligation_id = $1
|
|
1561
|
-
ON CONFLICT (obligation_id) DO NOTHING`,
|
|
1562
|
-
[obligationId]
|
|
1563
|
-
);
|
|
1564
|
-
if (rowStorage) {
|
|
1565
|
-
await client.query(
|
|
1566
|
-
`UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
|
|
1567
|
-
WHERE obligation_id = $1 AND rows_state IN ('live', 'unavailable')`,
|
|
1568
|
-
[obligationId]
|
|
1569
|
-
);
|
|
1570
|
-
}
|
|
1571
|
-
},
|
|
1572
|
-
{ preBeginAdvisoryLock: accountLock(accountId) }
|
|
1573
|
-
);
|
|
1574
|
-
if (rowStorage) {
|
|
1575
|
-
const objects = await this.query(
|
|
1576
|
-
`SELECT DISTINCT row_set.row_binding_id, chunk.object_ref, chunk.native_version_ref
|
|
1577
|
-
FROM adcp_reporting_row_chunks chunk
|
|
1578
|
-
JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
|
|
1579
|
-
WHERE chunk.obligation_id = $1 AND chunk.object_ref IS NOT NULL`,
|
|
1580
|
-
[obligationId]
|
|
1581
|
-
);
|
|
1582
|
-
const byBinding = /* @__PURE__ */ new Map();
|
|
1583
|
-
for (const row of objects.rows) {
|
|
1584
|
-
const list = byBinding.get(row.row_binding_id) ?? [];
|
|
1585
|
-
list.push({ objectRef: row.object_ref, nativeVersionRef: row.native_version_ref });
|
|
1586
|
-
byBinding.set(row.row_binding_id, list);
|
|
1587
|
-
}
|
|
1588
|
-
for (const [bindingId, list] of byBinding) {
|
|
1589
|
-
await this.deleteUnreferencedObjects(bindingId, list, { obligationId }, signal);
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
await this.transaction(
|
|
1593
|
-
async (client) => {
|
|
1594
|
-
const revisionIds = `SELECT revision_id FROM adcp_reporting_revisions WHERE obligation_id = $1`;
|
|
1595
|
-
if (rowStorage) {
|
|
1596
|
-
await client.query(`DELETE FROM adcp_reporting_row_chunks WHERE obligation_id = $1`, [obligationId]);
|
|
1597
|
-
await client.query(`DELETE FROM adcp_reporting_row_sets WHERE obligation_id = $1`, [obligationId]);
|
|
1598
|
-
await client.query(`DELETE FROM adcp_reporting_chunk_bodies WHERE account_id = $2 AND obligation_id = $1`, [
|
|
1599
|
-
obligationId,
|
|
1600
|
-
accountId
|
|
1601
|
-
]);
|
|
1602
|
-
}
|
|
1603
|
-
await client.query(
|
|
1604
|
-
`DELETE FROM adcp_reporting_consumer_statuses
|
|
1605
|
-
WHERE obligation_id = $1 OR revision_id IN (${revisionIds})`,
|
|
1606
|
-
[obligationId]
|
|
1607
|
-
);
|
|
1608
|
-
await client.query(`DELETE FROM adcp_reporting_adjustments WHERE obligation_id = $1`, [obligationId]);
|
|
1609
|
-
await client.query(`DELETE FROM adcp_reporting_revisions WHERE obligation_id = $1`, [obligationId]);
|
|
1610
|
-
await client.query(`DELETE FROM adcp_reporting_issues WHERE obligation_id = $1`, [obligationId]);
|
|
1611
|
-
await client.query(`DELETE FROM adcp_reporting_transitions WHERE obligation_id = $1`, [obligationId]);
|
|
1612
|
-
await client.query(`DELETE FROM adcp_reporting_lifecycle_state WHERE obligation_id = $1`, [obligationId]);
|
|
1613
|
-
await client.query(`DELETE FROM adcp_reporting_obligations WHERE obligation_id = $1`, [obligationId]);
|
|
1614
|
-
const retiredNow = await client.query(
|
|
1615
|
-
`UPDATE adcp_reporting_obligation_tombstones SET state = 'retired', retired_at = clock_timestamp()
|
|
1616
|
-
WHERE obligation_id = $1 AND state = 'retiring'`,
|
|
1617
|
-
[obligationId]
|
|
1618
|
-
);
|
|
1619
|
-
if (retiredNow.rowCount === 1) {
|
|
1620
|
-
await this.appendChange(client, "retirement", accountId, obligationId, obligationId);
|
|
1621
|
-
}
|
|
1622
|
-
},
|
|
1623
|
-
{ preBeginAdvisoryLock: accountLock(accountId) }
|
|
1624
|
-
);
|
|
1625
|
-
}
|
|
1626
|
-
/**
|
|
1627
|
-
* Compact superseded snapshot revisions to metadata only, as adcontextprotocol/adcp#8100
|
|
1628
|
-
* permits (`reporting-revision.json` `content_retention`). Opt-in: nothing calls this
|
|
1629
|
-
* unless the host enables `compaction` in maintenance, and it must not be enabled in
|
|
1630
|
-
* production before the AdCP release that takes compaction into effect (the next
|
|
1631
|
-
* eligible minor after November 20, 2026).
|
|
1632
|
-
*
|
|
1633
|
-
* A snapshot revision is compacted only when every one of these holds:
|
|
1634
|
-
*
|
|
1635
|
-
* - it is superseded: a later revision names it in `supersedes_reporting_revision_id`
|
|
1636
|
-
* (so a current revision, and every official revision, is never compacted);
|
|
1637
|
-
* - it is not the revision the current, unsuperseded revision names;
|
|
1638
|
-
* - its recovery grace has passed: `max(automatedRecoveryWindowSeconds, 3600)` seconds
|
|
1639
|
-
* since its successor's `created_at`;
|
|
1640
|
-
* - while its window is open (`statusRetentionDays` since its successor's `created_at`),
|
|
1641
|
-
* no caller's reporting receipt (any status) names it and no caller's current consumer
|
|
1642
|
-
* status statement of `received`, `unreadable` or `content_mismatch` names it. The
|
|
1643
|
-
* check spans every caller, so content any caller is entitled to is kept;
|
|
1644
|
-
* - it has rows (nothing to reclaim otherwise, and an empty exact read is still correct);
|
|
1645
|
-
* - no live obligation lease, no pending materialization of it, and no live change-feed
|
|
1646
|
-
* consumer that has not yet passed its change row (the holds retirement applies,
|
|
1647
|
-
* narrowed to this revision).
|
|
1648
|
-
*
|
|
1649
|
-
* Only row content is dropped: the revision header, its manifest and every metadata
|
|
1650
|
-
* listing stay. The row set is marked `pruned`, so an exact read answers
|
|
1651
|
-
* `ROWS_EXPIRED`, which the handlers surface as the same nondisclosing
|
|
1652
|
-
* `REFERENCE_NOT_FOUND` as an unknown ID. No change-feed row is recorded: the revision's
|
|
1653
|
-
* metadata is unchanged, and a sink still catching up holds compaction back.
|
|
1654
|
-
*
|
|
1655
|
-
* Compaction is resumable and runs in the same three phases as retirement, under the
|
|
1656
|
-
* account lock where eligibility is decided:
|
|
1657
|
-
*
|
|
1658
|
-
* 1. re-check eligibility and mark the row set `pruning`, so reads answer as expired and
|
|
1659
|
-
* no new revision can share its chunks;
|
|
1660
|
-
* 2. delete external objects at their recorded versions, skipping any object another
|
|
1661
|
-
* row set that is not being pruned still references (chunk sharing);
|
|
1662
|
-
* 3. delete the chunk manifest rows and any PostgreSQL chunk body nothing else
|
|
1663
|
-
* references, and mark the row set `pruned`.
|
|
1664
|
-
*
|
|
1665
|
-
* A crash after phase 1 leaves a `pruning` row set that the next pass resumes.
|
|
1666
|
-
*/
|
|
1667
|
-
async compactSupersededSnapshots(input) {
|
|
1668
|
-
this.assertCompactionSafeRowBindings();
|
|
1669
|
-
positiveInteger(input.statusRetentionDays, "statusRetentionDays");
|
|
1670
|
-
positiveInteger(input.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
|
|
1671
|
-
const limit = input.limit ?? 100;
|
|
1672
|
-
positiveInteger(limit, "limit");
|
|
1673
|
-
const holdDays = input.maxFeedHoldDays ?? 7;
|
|
1674
|
-
positiveInteger(holdDays, "maxFeedHoldDays");
|
|
1675
|
-
const tables = await this.optionalTables();
|
|
1676
|
-
if (!tables.has("adcp_reporting_row_sets")) return { compacted: 0, failed: [], failures: [] };
|
|
1677
|
-
const graceSeconds = Math.max(input.automatedRecoveryWindowSeconds, 3600);
|
|
1678
|
-
const eligible = `
|
|
1679
|
-
FROM adcp_reporting_revisions revision
|
|
1680
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = revision.obligation_id
|
|
1681
|
-
JOIN adcp_reporting_row_sets row_set
|
|
1682
|
-
ON row_set.row_set_id = revision.revision_id AND row_set.row_set_kind = 'revision'
|
|
1683
|
-
JOIN LATERAL (
|
|
1684
|
-
SELECT MAX(later.created_at) AS created_at FROM adcp_reporting_revisions later
|
|
1685
|
-
WHERE later.supersedes_revision_id = revision.revision_id
|
|
1686
|
-
) successor ON successor.created_at IS NOT NULL
|
|
1687
|
-
WHERE revision.finality = 'snapshot' AND revision.kind = 'snapshot'
|
|
1688
|
-
AND ($4::text IS NULL OR obligation.account_id = $4)
|
|
1689
|
-
AND ($5::text IS NULL OR revision.revision_id = $5)
|
|
1690
|
-
AND $3::integer > 0
|
|
1691
|
-
AND row_set.row_count > 0
|
|
1692
|
-
AND row_set.rows_state IN ('live', 'unavailable')
|
|
1693
|
-
AND successor.created_at + ($2::bigint * INTERVAL '1 second') <= clock_timestamp()
|
|
1694
|
-
AND NOT EXISTS (
|
|
1695
|
-
SELECT 1 FROM adcp_reporting_revisions current_revision
|
|
1696
|
-
WHERE current_revision.supersedes_revision_id = revision.revision_id
|
|
1697
|
-
AND NOT EXISTS (
|
|
1698
|
-
SELECT 1 FROM adcp_reporting_revisions next_revision
|
|
1699
|
-
WHERE next_revision.supersedes_revision_id = current_revision.revision_id
|
|
1700
|
-
)
|
|
1701
|
-
)
|
|
1702
|
-
AND (obligation.lease_expires_at IS NULL OR obligation.lease_expires_at <= clock_timestamp())
|
|
1703
|
-
AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
|
|
1704
|
-
WHERE tombstone.obligation_id = obligation.obligation_id)
|
|
1705
|
-
AND (
|
|
1706
|
-
successor.created_at + ($1::integer * INTERVAL '1 day') <= clock_timestamp()
|
|
1707
|
-
OR (
|
|
1708
|
-
NOT EXISTS (
|
|
1709
|
-
SELECT 1 FROM adcp_reporting_consumer_statuses statement
|
|
1710
|
-
WHERE statement.revision_id = revision.revision_id AND statement.is_current
|
|
1711
|
-
AND COALESCE(statement.data->>'consumer_status', statement.data->'status'->>'consumer_status')
|
|
1712
|
-
IN ('received', 'unreadable', 'content_mismatch')
|
|
1713
|
-
)
|
|
1714
|
-
${tables.has("adcp_reporting_receipts") ? `AND NOT EXISTS (
|
|
1715
|
-
SELECT 1 FROM adcp_reporting_receipts receipt
|
|
1716
|
-
WHERE receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
|
|
1717
|
-
)` : ""}
|
|
1718
|
-
)
|
|
1719
|
-
)
|
|
1720
|
-
${tables.has("adcp_reporting_materializations") ? `AND NOT EXISTS (
|
|
1721
|
-
SELECT 1 FROM adcp_reporting_materializations hold
|
|
1722
|
-
WHERE hold.revision_id = revision.revision_id AND hold.status = 'pending'
|
|
1723
|
-
)` : ""}
|
|
1724
|
-
${tables.has("adcp_reporting_changes") && tables.has("adcp_reporting_feed_consumers") ? `AND NOT EXISTS (
|
|
1725
|
-
SELECT 1 FROM adcp_reporting_changes change
|
|
1726
|
-
JOIN adcp_reporting_feed_consumers consumer
|
|
1727
|
-
ON consumer.updated_at > clock_timestamp() - ($3::integer * INTERVAL '1 day')
|
|
1728
|
-
WHERE change.record_kind = 'revision' AND change.record_id = revision.revision_id
|
|
1729
|
-
AND ((consumer.account_id IS NULL
|
|
1730
|
-
AND (change.xid, change.seq) > (consumer.cursor_xid, consumer.cursor_seq))
|
|
1731
|
-
OR (consumer.account_id = change.account_id AND change.seq > consumer.cursor_seq)))` : ""}`;
|
|
1732
|
-
const baseValues = [input.statusRetentionDays, graceSeconds, holdDays, input.account_id ?? null];
|
|
1733
|
-
const candidates = await this.query(
|
|
1734
|
-
`SELECT revision_id, obligation_id, account_id FROM (
|
|
1735
|
-
SELECT row_set.row_set_id AS revision_id, row_set.obligation_id, row_set.account_id, 0 AS resume,
|
|
1736
|
-
row_set.recorded_at
|
|
1737
|
-
FROM adcp_reporting_row_sets row_set
|
|
1738
|
-
WHERE row_set.row_set_kind = 'revision' AND row_set.rows_state = 'pruning'
|
|
1739
|
-
AND ($4::text IS NULL OR row_set.account_id = $4)
|
|
1740
|
-
AND EXISTS (SELECT 1 FROM adcp_reporting_revisions revision
|
|
1741
|
-
WHERE revision.revision_id = row_set.row_set_id)
|
|
1742
|
-
AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
|
|
1743
|
-
WHERE tombstone.obligation_id = row_set.obligation_id)
|
|
1744
|
-
UNION ALL
|
|
1745
|
-
SELECT revision.revision_id, revision.obligation_id, obligation.account_id, 1, row_set.recorded_at
|
|
1746
|
-
${eligible}
|
|
1747
|
-
) candidate
|
|
1748
|
-
ORDER BY resume, recorded_at, revision_id
|
|
1749
|
-
LIMIT $6`,
|
|
1750
|
-
[...baseValues, null, limit]
|
|
1751
|
-
);
|
|
1752
|
-
let compacted = 0;
|
|
1753
|
-
const failed = [];
|
|
1754
|
-
const failures = [];
|
|
1755
|
-
for (const candidate of candidates.rows) {
|
|
1756
|
-
input.signal?.throwIfAborted();
|
|
1757
|
-
try {
|
|
1758
|
-
const done = await this.compactSnapshot(
|
|
1759
|
-
candidate.revision_id,
|
|
1760
|
-
candidate.obligation_id,
|
|
1761
|
-
candidate.account_id,
|
|
1762
|
-
eligible,
|
|
1763
|
-
baseValues,
|
|
1764
|
-
input.signal
|
|
1765
|
-
);
|
|
1766
|
-
if (done) compacted += 1;
|
|
1767
|
-
} catch (error) {
|
|
1768
|
-
failed.push(candidate.revision_id);
|
|
1769
|
-
failures.push({
|
|
1770
|
-
reporting_obligation_id: candidate.obligation_id,
|
|
1771
|
-
reporting_revision_id: candidate.revision_id,
|
|
1772
|
-
cause: (0, import_row_storage.isReportingRowStoreError)(error) ? error.code : error instanceof Error ? error.name : "Error"
|
|
1773
|
-
});
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
return { compacted, failed, failures };
|
|
1777
|
-
}
|
|
1778
|
-
async compactSnapshot(revisionId, obligationId, accountId, eligible, baseValues, signal) {
|
|
1779
|
-
const claimed = await this.transaction(
|
|
1780
|
-
async (client) => {
|
|
1781
|
-
const marked = await client.query(
|
|
1782
|
-
`UPDATE adcp_reporting_row_sets SET rows_state = 'pruning', rows_state_changed_at = clock_timestamp()
|
|
1783
|
-
WHERE row_set_id = $5 AND rows_state IN ('live', 'unavailable')
|
|
1784
|
-
AND row_set_id IN (SELECT revision.revision_id ${eligible})`,
|
|
1785
|
-
[...baseValues, revisionId]
|
|
1786
|
-
);
|
|
1787
|
-
if (marked.rowCount === 1) return true;
|
|
1788
|
-
const resumed = await client.query(
|
|
1789
|
-
`SELECT 1 FROM adcp_reporting_row_sets row_set
|
|
1790
|
-
WHERE row_set.row_set_id = $1 AND row_set.rows_state = 'pruning'
|
|
1791
|
-
AND NOT EXISTS (SELECT 1 FROM adcp_reporting_obligation_tombstones tombstone
|
|
1792
|
-
WHERE tombstone.obligation_id = row_set.obligation_id)`,
|
|
1793
|
-
[revisionId]
|
|
1794
|
-
);
|
|
1795
|
-
return resumed.rows.length === 1;
|
|
1796
|
-
},
|
|
1797
|
-
{ preBeginAdvisoryLock: accountLock(accountId) }
|
|
1798
|
-
);
|
|
1799
|
-
if (!claimed) return false;
|
|
1800
|
-
const binding = await this.query(
|
|
1801
|
-
`SELECT row_binding_id FROM adcp_reporting_row_sets WHERE row_set_id = $1`,
|
|
1802
|
-
[revisionId]
|
|
1803
|
-
);
|
|
1804
|
-
if (binding.rows.length === 0) return false;
|
|
1805
|
-
const objects = await this.query(
|
|
1806
|
-
`SELECT DISTINCT chunk.object_ref, chunk.native_version_ref
|
|
1807
|
-
FROM adcp_reporting_row_chunks chunk
|
|
1808
|
-
WHERE chunk.row_set_id = $1 AND chunk.object_ref IS NOT NULL`,
|
|
1809
|
-
[revisionId]
|
|
1810
|
-
);
|
|
1811
|
-
if (objects.rows.length > 0) {
|
|
1812
|
-
await this.deleteUnreferencedObjects(
|
|
1813
|
-
binding.rows[0].row_binding_id,
|
|
1814
|
-
objects.rows.map((row) => ({ objectRef: row.object_ref, nativeVersionRef: row.native_version_ref })),
|
|
1815
|
-
{ rowSetId: revisionId },
|
|
1816
|
-
signal
|
|
1817
|
-
);
|
|
1818
|
-
}
|
|
1819
|
-
await this.transaction(
|
|
1820
|
-
async (client) => {
|
|
1821
|
-
const dropped = await client.query(
|
|
1822
|
-
`DELETE FROM adcp_reporting_row_chunks WHERE row_set_id = $1 RETURNING sha256, object_ref`,
|
|
1823
|
-
[revisionId]
|
|
1824
|
-
);
|
|
1825
|
-
const bodies = [...new Set(dropped.rows.filter((row) => row.object_ref === null).map((row) => row.sha256))];
|
|
1826
|
-
if (bodies.length > 0) {
|
|
1827
|
-
await client.query(
|
|
1828
|
-
`DELETE FROM adcp_reporting_chunk_bodies body
|
|
1829
|
-
WHERE body.account_id = $1 AND body.obligation_id = $2 AND body.sha256 = ANY($3::text[])
|
|
1830
|
-
AND NOT EXISTS (
|
|
1831
|
-
SELECT 1 FROM adcp_reporting_row_chunks other
|
|
1832
|
-
WHERE other.account_id = body.account_id AND other.obligation_id = body.obligation_id
|
|
1833
|
-
AND other.sha256 = body.sha256 AND other.object_ref IS NULL
|
|
1834
|
-
)`,
|
|
1835
|
-
[accountId, obligationId, bodies]
|
|
1836
|
-
);
|
|
1837
|
-
}
|
|
1838
|
-
await client.query(
|
|
1839
|
-
`UPDATE adcp_reporting_row_sets SET rows_state = 'pruned', rows_state_changed_at = clock_timestamp()
|
|
1840
|
-
WHERE row_set_id = $1 AND rows_state = 'pruning'`,
|
|
1841
|
-
[revisionId]
|
|
1842
|
-
);
|
|
1843
|
-
},
|
|
1844
|
-
{ preBeginAdvisoryLock: accountLock(accountId) }
|
|
1845
|
-
);
|
|
1846
|
-
return true;
|
|
1847
|
-
}
|
|
1848
|
-
/**
|
|
1849
|
-
* Resolve abandoned object uploads (spec §4.3). An expired `open` write
|
|
1850
|
-
* intent whose ledger commit never happened is moved to `sweeping` under
|
|
1851
|
-
* the account lock, so no writer can commit against it, then only the
|
|
1852
|
-
* objects that upload created — never adopted ones, never any object a
|
|
1853
|
-
* committed chunk references — are deleted at their recorded versions.
|
|
1854
|
-
* Interrupted sweeps resume.
|
|
1855
|
-
*/
|
|
1856
|
-
async sweepRowWriteIntents(input = {}) {
|
|
1857
|
-
const limit = input.limit ?? 100;
|
|
1858
|
-
positiveInteger(limit, "limit");
|
|
1859
|
-
const overdueAfter = input.overdueAfterSeconds ?? 24 * 3600;
|
|
1860
|
-
positiveInteger(overdueAfter, "overdueAfterSeconds");
|
|
1861
|
-
const tables = await this.optionalTables();
|
|
1862
|
-
if (!tables.has("adcp_reporting_row_write_intents")) {
|
|
1863
|
-
return { swept: 0, objectsDeleted: 0, failed: 0, skippedUnconfigured: 0, deferred: 0, overdue: 0 };
|
|
1864
|
-
}
|
|
1865
|
-
const configured = this.rowStorage.objectBindingIds();
|
|
1866
|
-
const due = `($1::text IS NULL OR account_id = $1) AND (state = 'sweeping' OR expires_at <= clock_timestamp())`;
|
|
1867
|
-
const expired = await this.query(
|
|
1868
|
-
`SELECT row_binding_id, account_id, row_set_id, content_sha256 FROM adcp_reporting_row_write_intents
|
|
1869
|
-
WHERE ${due}
|
|
1870
|
-
AND (next_sweep_at IS NULL OR next_sweep_at <= clock_timestamp())
|
|
1871
|
-
AND (row_binding_id = ANY($2::text[]) OR created_objects = '[]'::jsonb)
|
|
1872
|
-
ORDER BY sweep_attempts, expires_at LIMIT $3`,
|
|
1873
|
-
[input.account_id ?? null, configured, limit]
|
|
1874
|
-
);
|
|
1875
|
-
let swept = 0;
|
|
1876
|
-
let objectsDeleted = 0;
|
|
1877
|
-
let failed = 0;
|
|
1878
|
-
for (const intent of expired.rows) {
|
|
1879
|
-
input.signal?.throwIfAborted();
|
|
1880
|
-
const key = [intent.row_binding_id, intent.row_set_id, intent.content_sha256];
|
|
1881
|
-
try {
|
|
1882
|
-
const claimed = await this.transaction(
|
|
1883
|
-
async (client) => {
|
|
1884
|
-
const updated = await client.query(
|
|
1885
|
-
`UPDATE adcp_reporting_row_write_intents SET state = 'sweeping'
|
|
1886
|
-
WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
|
|
1887
|
-
AND (state = 'sweeping' OR expires_at <= clock_timestamp())
|
|
1888
|
-
RETURNING created_objects`,
|
|
1889
|
-
key
|
|
1890
|
-
);
|
|
1891
|
-
return updated.rows[0]?.created_objects.map((object) => ({
|
|
1892
|
-
objectRef: object.object_ref ?? object.objectKey,
|
|
1893
|
-
nativeVersionRef: object.native_version_ref ?? object.nativeVersion
|
|
1894
|
-
})).filter(
|
|
1895
|
-
(object) => typeof object.objectRef === "string" && typeof object.nativeVersionRef === "string"
|
|
1896
|
-
);
|
|
1897
|
-
},
|
|
1898
|
-
{ preBeginAdvisoryLock: accountLock(intent.account_id) }
|
|
1899
|
-
);
|
|
1900
|
-
if (!claimed) continue;
|
|
1901
|
-
const deleted = await this.deleteUnreferencedObjects(intent.row_binding_id, claimed, {}, input.signal);
|
|
1902
|
-
await this.query(
|
|
1903
|
-
`DELETE FROM adcp_reporting_row_write_intents
|
|
1904
|
-
WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3 AND state = 'sweeping'`,
|
|
1905
|
-
key
|
|
1906
|
-
);
|
|
1907
|
-
swept += 1;
|
|
1908
|
-
objectsDeleted += deleted;
|
|
1909
|
-
} catch {
|
|
1910
|
-
failed += 1;
|
|
1911
|
-
if (!input.signal?.aborted) {
|
|
1912
|
-
await this.query(
|
|
1913
|
-
`UPDATE adcp_reporting_row_write_intents
|
|
1914
|
-
SET sweep_attempts = sweep_attempts + 1,
|
|
1915
|
-
next_sweep_at = clock_timestamp() + ${backoffIntervalSql("sweep_attempts + 1")}
|
|
1916
|
-
WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3`,
|
|
1917
|
-
key
|
|
1918
|
-
).catch(() => void 0);
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
const backlog = (await this.query(
|
|
1923
|
-
`SELECT count(*) FILTER (WHERE row_binding_id <> ALL($2::text[]) AND created_objects <> '[]'::jsonb) AS skipped,
|
|
1924
|
-
count(*) FILTER (WHERE (row_binding_id = ANY($2::text[]) OR created_objects = '[]'::jsonb)
|
|
1925
|
-
AND next_sweep_at > clock_timestamp()) AS deferred,
|
|
1926
|
-
count(*) FILTER (WHERE expires_at <= clock_timestamp() - ($3::integer * INTERVAL '1 second')) AS overdue
|
|
1927
|
-
FROM adcp_reporting_row_write_intents
|
|
1928
|
-
WHERE ${due}`,
|
|
1929
|
-
[input.account_id ?? null, configured, overdueAfter]
|
|
1930
|
-
)).rows[0];
|
|
1931
|
-
return {
|
|
1932
|
-
swept,
|
|
1933
|
-
objectsDeleted,
|
|
1934
|
-
failed,
|
|
1935
|
-
skippedUnconfigured: Number(backlog.skipped),
|
|
1936
|
-
deferred: Number(backlog.deferred),
|
|
1937
|
-
overdue: Number(backlog.overdue)
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1940
|
-
/**
|
|
1941
|
-
* Delete external row objects, skipping any object another row set still references.
|
|
1942
|
-
*
|
|
1943
|
-
* Object keys are unique per row set (every writable template includes `{revision_key}`), but header-only
|
|
1944
|
-
* revisions share chunks within an obligation and rows written under an older template can name one
|
|
1945
|
-
* object from several row sets. An object is therefore deleted only when no
|
|
1946
|
-
* chunk of a live (or unavailable) row set outside this operation references the same binding,
|
|
1947
|
-
* key and native version, and no other open write intent created it. Row sets already `pruning`
|
|
1948
|
-
* or `pruned` do not count: their own operation deletes the object, and counting them would let
|
|
1949
|
-
* two concurrent retirements each skip the object and leak it. `scope` names what this
|
|
1950
|
-
* operation is deleting: an obligation (retirement) or a row set (compaction).
|
|
1951
|
-
*
|
|
1952
|
-
* The check and the provider delete run in one transaction under a per-object advisory lock.
|
|
1953
|
-
* A commit that adopts an existing object takes the same lock before it inserts its chunk rows
|
|
1954
|
-
* and re-verifies the object under it (`writePreparedRowSet`), and holds it until commit. So
|
|
1955
|
-
* either the delete wins the lock and the adopting commit then fails cleanly, or the commit wins
|
|
1956
|
-
* and the delete sees its chunk rows and skips. A skipped object stays for the row set that owns
|
|
1957
|
-
* it: that row set's own retirement, compaction or sweep deletes it, because by then nothing
|
|
1958
|
-
* else references it. Locks are taken one object at a time, never while waiting on the account
|
|
1959
|
-
* lock, so commits (account lock, then object locks) cannot deadlock with these deletes.
|
|
1960
|
-
*/
|
|
1961
|
-
async deleteUnreferencedObjects(bindingId, objects, scope, signal) {
|
|
1962
|
-
let deleted = 0;
|
|
1963
|
-
for (const object of objects) {
|
|
1964
|
-
signal?.throwIfAborted();
|
|
1965
|
-
const removed = await this.transaction(async (client) => {
|
|
1966
|
-
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
1967
|
-
objectLock(bindingId, object.objectRef)
|
|
1968
|
-
]);
|
|
1969
|
-
const referenced = await client.query(
|
|
1970
|
-
`(SELECT 1 FROM adcp_reporting_row_chunks chunk
|
|
1971
|
-
JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id
|
|
1972
|
-
WHERE row_set.row_binding_id = $1 AND chunk.object_ref = $2 AND chunk.native_version_ref = $3
|
|
1973
|
-
AND row_set.rows_state IN ('live', 'unavailable')
|
|
1974
|
-
AND ($4::text IS NULL OR chunk.obligation_id <> $4)
|
|
1975
|
-
AND ($5::text IS NULL OR chunk.row_set_id <> $5)
|
|
1976
|
-
LIMIT 1)
|
|
1977
|
-
UNION ALL
|
|
1978
|
-
(SELECT 1 FROM adcp_reporting_row_write_intents intent
|
|
1979
|
-
WHERE intent.row_binding_id = $1 AND intent.state = 'open'
|
|
1980
|
-
AND (intent.created_objects @> $6::jsonb OR intent.created_objects @> $7::jsonb)
|
|
1981
|
-
LIMIT 1)`,
|
|
1982
|
-
[
|
|
1983
|
-
bindingId,
|
|
1984
|
-
object.objectRef,
|
|
1985
|
-
object.nativeVersionRef,
|
|
1986
|
-
scope.obligationId ?? null,
|
|
1987
|
-
scope.rowSetId ?? null,
|
|
1988
|
-
JSON.stringify([{ object_ref: object.objectRef, native_version_ref: object.nativeVersionRef }]),
|
|
1989
|
-
// An older writer still running during a rolling upgrade records the pre-alignment keys.
|
|
1990
|
-
JSON.stringify([{ objectKey: object.objectRef, nativeVersion: object.nativeVersionRef }])
|
|
1991
|
-
]
|
|
1992
|
-
);
|
|
1993
|
-
if (referenced.rows.length > 0) return false;
|
|
1994
|
-
await this.rowStorage.deleteObjects(bindingId, [object], signal);
|
|
1995
|
-
return true;
|
|
1996
|
-
});
|
|
1997
|
-
if (removed) deleted += 1;
|
|
1998
|
-
}
|
|
1999
|
-
return deleted;
|
|
2000
|
-
}
|
|
2001
|
-
async optionalTables() {
|
|
2002
|
-
const result = await this.query(
|
|
2003
|
-
`SELECT table_name AS name FROM information_schema.tables
|
|
2004
|
-
WHERE table_schema = current_schema()
|
|
2005
|
-
AND table_name IN ('adcp_reporting_row_sets', 'adcp_reporting_row_write_intents',
|
|
2006
|
-
'adcp_reporting_materializations', 'adcp_reporting_changes',
|
|
2007
|
-
'adcp_reporting_feed_consumers', 'adcp_reporting_receipts')`
|
|
2008
|
-
);
|
|
2009
|
-
return new Set(result.rows.map((row) => row.name));
|
|
2010
|
-
}
|
|
2011
|
-
/**
|
|
2012
|
-
* Upload an object-kind row set before its ledger commit (spec §3.5):
|
|
2013
|
-
* skip when the document already exists (replay), refuse unless the lease
|
|
2014
|
-
* still covers the upload deadline, open or take over an expired write
|
|
2015
|
-
* intent under the account lock, then write every chunk create-only,
|
|
2016
|
-
* recording each object this upload created in the intent first.
|
|
2017
|
-
*/
|
|
2018
|
-
async uploadRowSetUnlessCommitted(prepared, lease, table, idColumn) {
|
|
2019
|
-
const { input } = prepared;
|
|
2020
|
-
const committed = await this.query(`SELECT 1 FROM ${table} WHERE ${idColumn} = $1`, [input.rowSetId]);
|
|
2021
|
-
if (committed.rows.length > 0) return void 0;
|
|
2022
|
-
const shared = await this.rowStorage.findShareable(this.pool, prepared);
|
|
2023
|
-
if (shared) return { locators: shared.locators, sharedFrom: shared.rowSetId };
|
|
2024
|
-
const ownerToken = (0, import_row_storage.reportingRowWriteIntentIdV1)();
|
|
2025
|
-
const budget = this.rowStorage.writeDeadlineMilliseconds;
|
|
2026
|
-
const intentKey = [prepared.bindingId, input.rowSetId, input.binding.sha256];
|
|
2027
|
-
await this.transaction(
|
|
2028
|
-
async (client) => {
|
|
2029
|
-
const opened = await client.query(
|
|
2030
|
-
`INSERT INTO adcp_reporting_row_write_intents
|
|
2031
|
-
(row_binding_id, account_id, row_set_id, content_sha256, state, owner_token, expires_at)
|
|
2032
|
-
SELECT $1, $2, $3, $4, 'open', $5, obligation.lease_expires_at + ($6::bigint * INTERVAL '1 millisecond')
|
|
2033
|
-
FROM adcp_reporting_obligations obligation
|
|
2034
|
-
WHERE obligation.obligation_id = $7 AND obligation.lease_owner = $8
|
|
2035
|
-
AND obligation.lease_generation = $9
|
|
2036
|
-
AND obligation.lease_expires_at - clock_timestamp() >= (($6::bigint + 5000) * INTERVAL '1 millisecond')
|
|
2037
|
-
ON CONFLICT (row_binding_id, row_set_id, content_sha256) DO UPDATE
|
|
2038
|
-
SET owner_token = EXCLUDED.owner_token, expires_at = EXCLUDED.expires_at
|
|
2039
|
-
WHERE adcp_reporting_row_write_intents.state = 'open'
|
|
2040
|
-
AND adcp_reporting_row_write_intents.expires_at <= clock_timestamp()
|
|
2041
|
-
RETURNING owner_token`,
|
|
2042
|
-
[
|
|
2043
|
-
prepared.bindingId,
|
|
2044
|
-
input.accountId,
|
|
2045
|
-
input.rowSetId,
|
|
2046
|
-
input.binding.sha256,
|
|
2047
|
-
ownerToken,
|
|
2048
|
-
budget,
|
|
2049
|
-
input.obligationId,
|
|
2050
|
-
lease.owner,
|
|
2051
|
-
lease.generation
|
|
2052
|
-
]
|
|
2053
|
-
);
|
|
2054
|
-
if (opened.rows.length === 1) return;
|
|
2055
|
-
const held = await client.query(
|
|
2056
|
-
`SELECT lease_expires_at - clock_timestamp() >= (($4::bigint + 5000) * INTERVAL '1 millisecond') AS budget
|
|
2057
|
-
FROM adcp_reporting_obligations
|
|
2058
|
-
WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3
|
|
2059
|
-
AND lease_expires_at > clock_timestamp()`,
|
|
2060
|
-
[input.obligationId, lease.owner, lease.generation, budget]
|
|
2061
|
-
);
|
|
2062
|
-
if (held.rows.length === 0) throw new ReportingLedgerLeaseLostError();
|
|
2063
|
-
if (!held.rows[0].budget) {
|
|
2064
|
-
throw new import_row_storage.ReportingRowStoreError("DEADLINE_EXCEEDED", "the lease does not cover the row upload deadline");
|
|
2065
|
-
}
|
|
2066
|
-
throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "another writer holds this row upload");
|
|
2067
|
-
},
|
|
2068
|
-
{ preBeginAdvisoryLock: accountLock(input.accountId) }
|
|
2069
|
-
);
|
|
2070
|
-
const locators = await this.rowStorage.upload(prepared, ownerToken, async (object) => {
|
|
2071
|
-
const recorded = await this.query(
|
|
2072
|
-
`UPDATE adcp_reporting_row_write_intents
|
|
2073
|
-
SET created_objects = created_objects || $5::jsonb
|
|
2074
|
-
WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
|
|
2075
|
-
AND owner_token = $4 AND state = 'open'`,
|
|
2076
|
-
[
|
|
2077
|
-
...intentKey,
|
|
2078
|
-
ownerToken,
|
|
2079
|
-
JSON.stringify([{ object_ref: object.objectRef, native_version_ref: object.nativeVersionRef }])
|
|
2080
|
-
]
|
|
2081
|
-
);
|
|
2082
|
-
if (recorded.rowCount !== 1) {
|
|
2083
|
-
throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
|
|
2084
|
-
}
|
|
2085
|
-
});
|
|
2086
|
-
return { locators, ownerToken };
|
|
2087
|
-
}
|
|
2088
|
-
/** Persist a prepared row set and close its upload intent in the ledger commit transaction. */
|
|
2089
|
-
async writePreparedRowSet(client, prepared, upload) {
|
|
2090
|
-
if (prepared.binding.kind === "postgres") {
|
|
2091
|
-
const shared = await this.rowStorage.findShareable(client, prepared);
|
|
2092
|
-
await this.rowStorage.writeInTransaction(client, prepared, void 0, shared?.rowSetId);
|
|
2093
|
-
return;
|
|
2094
|
-
}
|
|
2095
|
-
if (!upload) throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "object rows were not uploaded");
|
|
2096
|
-
if (upload.sharedFrom) {
|
|
2097
|
-
const live = await client.query(
|
|
2098
|
-
`SELECT 1 FROM adcp_reporting_row_sets WHERE row_set_id = $1 AND rows_state = 'live' FOR SHARE`,
|
|
2099
|
-
[upload.sharedFrom]
|
|
2100
|
-
);
|
|
2101
|
-
if (live.rows.length !== 1) {
|
|
2102
|
-
throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the shared row set is no longer live");
|
|
2103
|
-
}
|
|
2104
|
-
} else {
|
|
2105
|
-
const closed = await client.query(
|
|
2106
|
-
`DELETE FROM adcp_reporting_row_write_intents
|
|
2107
|
-
WHERE row_binding_id = $1 AND row_set_id = $2 AND content_sha256 = $3
|
|
2108
|
-
AND owner_token = $4 AND state = 'open'`,
|
|
2109
|
-
[prepared.bindingId, prepared.input.rowSetId, prepared.input.binding.sha256, upload.ownerToken]
|
|
2110
|
-
);
|
|
2111
|
-
if (closed.rowCount !== 1) {
|
|
2112
|
-
throw new import_row_storage.ReportingRowStoreError("STATE_UNAVAILABLE", "the row upload intent was taken over or swept");
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
if (!upload.sharedFrom) {
|
|
2116
|
-
const adopted = upload.locators.filter((locator) => !locator.created).sort((left, right) => left.objectRef < right.objectRef ? -1 : left.objectRef > right.objectRef ? 1 : 0);
|
|
2117
|
-
for (const locator of adopted) {
|
|
2118
|
-
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
2119
|
-
objectLock(prepared.bindingId, locator.objectRef)
|
|
2120
|
-
]);
|
|
2121
|
-
}
|
|
2122
|
-
if (adopted.length > 0) await this.rowStorage.assertObjectsPresent(prepared.bindingId, adopted);
|
|
2123
|
-
}
|
|
2124
|
-
await this.rowStorage.writeInTransaction(client, prepared, upload.locators, upload.sharedFrom);
|
|
2125
|
-
}
|
|
2126
|
-
/** Attach verified rows to a revision document whose rows live in row storage. */
|
|
2127
|
-
async hydrateRevision(revision, accountId) {
|
|
2128
|
-
if (Array.isArray(revision.rows)) return revision;
|
|
2129
|
-
const handle = await this.rowStorage.find(revision.reporting_revision_id, accountId);
|
|
2130
|
-
if (!handle) throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "revision rows are not recorded");
|
|
2131
|
-
if (handle.contentSha256 !== revision.binding.sha256 || handle.rowCount !== revision.binding.rowCount) {
|
|
2132
|
-
throw new import_row_storage.ReportingRowStoreError("ROWS_INTEGRITY_FAILED", "row set does not match the revision binding");
|
|
2133
|
-
}
|
|
2134
|
-
return { ...revision, rows: await this.rowStorage.readAll(handle, revision.wireRevision.control_totals) };
|
|
2135
|
-
}
|
|
2136
|
-
async listAdjustmentMetadata(obligationId) {
|
|
2137
|
-
const result = await this.query(
|
|
2138
|
-
`SELECT data - 'rows' AS data FROM adcp_reporting_adjustments WHERE obligation_id = $1
|
|
2139
|
-
ORDER BY adjustment_number, adjustment_id`,
|
|
2140
|
-
[obligationId]
|
|
2141
|
-
);
|
|
2142
|
-
return result.rows.map((row) => clone(row.data));
|
|
2143
|
-
}
|
|
2144
|
-
async putConsumerStatus(status) {
|
|
2145
|
-
const obligation = await this.getObligation(status.reporting_obligation_id);
|
|
2146
|
-
if (!obligation) throw new Error("Reporting consumer status obligation is unavailable");
|
|
2147
|
-
return this.putImmutable(
|
|
2148
|
-
`INSERT INTO adcp_reporting_consumer_statuses
|
|
2149
|
-
(account_id, consumer_id, consumer_status_id, chain_key, revision_id, obligation_id,
|
|
2150
|
-
supersedes_consumer_status_id, is_current, semantic_fingerprint, data, created_at)
|
|
2151
|
-
SELECT $1, '__legacy_unscoped_consumer__', $2, $3, $4, $5, $6, true, $7, $8::jsonb, $9
|
|
2152
|
-
WHERE NOT EXISTS (
|
|
2153
|
-
SELECT 1 FROM adcp_reporting_consumer_statuses
|
|
2154
|
-
WHERE consumer_id = '__legacy_unscoped_consumer__' AND consumer_status_id = $2
|
|
2155
|
-
)
|
|
2156
|
-
ON CONFLICT DO NOTHING RETURNING data`,
|
|
2157
|
-
[
|
|
2158
|
-
obligation.account.account_id,
|
|
2159
|
-
status.consumerStatusId,
|
|
2160
|
-
`legacy:${status.consumerStatusId}`,
|
|
2161
|
-
status.reporting_revision_id,
|
|
2162
|
-
status.reporting_obligation_id,
|
|
2163
|
-
status.supersedesConsumerStatusId ?? null,
|
|
2164
|
-
digest(status),
|
|
2165
|
-
JSON.stringify(status),
|
|
2166
|
-
status.createdAt
|
|
2167
|
-
],
|
|
2168
|
-
`SELECT data FROM adcp_reporting_consumer_statuses
|
|
2169
|
-
WHERE consumer_id = '__legacy_unscoped_consumer__' AND consumer_status_id = $1
|
|
2170
|
-
ORDER BY created_at LIMIT 1`,
|
|
2171
|
-
[status.consumerStatusId],
|
|
2172
|
-
status,
|
|
2173
|
-
(value) => digest(value),
|
|
2174
|
-
`adcp-reporting-legacy-consumer-status:${status.consumerStatusId}`
|
|
2175
|
-
);
|
|
2176
|
-
}
|
|
2177
|
-
async getConsumerStatusBatchReplay(input) {
|
|
2178
|
-
const batch = await this.query(
|
|
2179
|
-
`SELECT request_fingerprint, results FROM adcp_reporting_consumer_status_batches
|
|
2180
|
-
WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
|
|
2181
|
-
[input.account_id, input.consumerId, input.idempotencyKey]
|
|
2182
|
-
);
|
|
2183
|
-
if (!batch.rows[0]) return null;
|
|
2184
|
-
if (batch.rows[0].request_fingerprint !== input.requestFingerprint) {
|
|
2185
|
-
throw new import_types2.ReportingConsumerStatusConflictError("Reporting status idempotency key was reused");
|
|
2186
|
-
}
|
|
2187
|
-
const replay = [];
|
|
2188
|
-
for (const result of batch.rows[0].results) {
|
|
2189
|
-
if (result.kind === "failed") {
|
|
2190
|
-
replay.push({
|
|
2191
|
-
inserted: false,
|
|
2192
|
-
reporting_status_id: result.id,
|
|
2193
|
-
errorCode: result.errorCode ?? "VALIDATION_ERROR",
|
|
2194
|
-
recovery: result.recovery,
|
|
2195
|
-
retryAfterSeconds: result.retryAfterSeconds,
|
|
2196
|
-
safeMessage: boundedConsumerStatusSafeMessage(result.safeMessage ?? "Reporting consumer status was rejected"),
|
|
2197
|
-
...boundedConsumerStatusErrorField(result.errorField),
|
|
2198
|
-
...boundedConsumerStatusErrorKeyword(result.errorKeyword)
|
|
2199
|
-
});
|
|
2200
|
-
continue;
|
|
2201
|
-
}
|
|
2202
|
-
const row = await this.query(
|
|
2203
|
-
`SELECT data FROM adcp_reporting_consumer_statuses
|
|
2204
|
-
WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
|
|
2205
|
-
[result.id, input.account_id, input.consumerId]
|
|
2206
|
-
);
|
|
2207
|
-
if (!row.rows[0]) throw new import_types2.ReportingConsumerStatusConflictError("Reporting status replay is unavailable");
|
|
2208
|
-
replay.push({ inserted: result.kind === "recorded", value: clone(row.rows[0].data) });
|
|
2209
|
-
}
|
|
2210
|
-
return replay;
|
|
2211
|
-
}
|
|
2212
|
-
async syncConsumerStatusBatch(input) {
|
|
2213
|
-
return this.transaction(
|
|
2214
|
-
async (client) => {
|
|
2215
|
-
const priorBatch = await client.query(
|
|
2216
|
-
`SELECT request_fingerprint, results FROM adcp_reporting_consumer_status_batches
|
|
2217
|
-
WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
|
|
2218
|
-
[input.account_id, input.consumerId, input.idempotencyKey]
|
|
2219
|
-
);
|
|
2220
|
-
if (priorBatch.rows[0]) {
|
|
2221
|
-
if (priorBatch.rows[0].request_fingerprint !== input.requestFingerprint) {
|
|
2222
|
-
throw new import_types2.ReportingConsumerStatusConflictError("Reporting status idempotency key was reused");
|
|
2223
|
-
}
|
|
2224
|
-
const replay = [];
|
|
2225
|
-
for (const result of priorBatch.rows[0].results) {
|
|
2226
|
-
if (result.kind === "failed") {
|
|
2227
|
-
replay.push({
|
|
2228
|
-
inserted: false,
|
|
2229
|
-
reporting_status_id: result.id,
|
|
2230
|
-
errorCode: result.errorCode ?? "VALIDATION_ERROR",
|
|
2231
|
-
recovery: result.recovery,
|
|
2232
|
-
retryAfterSeconds: result.retryAfterSeconds,
|
|
2233
|
-
safeMessage: boundedConsumerStatusSafeMessage(
|
|
2234
|
-
result.safeMessage ?? "Reporting consumer status was rejected"
|
|
2235
|
-
),
|
|
2236
|
-
...boundedConsumerStatusErrorField(result.errorField),
|
|
2237
|
-
...boundedConsumerStatusErrorKeyword(result.errorKeyword)
|
|
2238
|
-
});
|
|
2239
|
-
continue;
|
|
2240
|
-
}
|
|
2241
|
-
const row = await client.query(
|
|
2242
|
-
`SELECT data FROM adcp_reporting_consumer_statuses
|
|
2243
|
-
WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
|
|
2244
|
-
[result.id, input.account_id, input.consumerId]
|
|
2245
|
-
);
|
|
2246
|
-
if (!row.rows[0]) throw new import_types2.ReportingConsumerStatusConflictError("Reporting status replay is unavailable");
|
|
2247
|
-
replay.push({
|
|
2248
|
-
inserted: input.replayOriginalResults !== false && result.kind === "recorded",
|
|
2249
|
-
value: clone(row.rows[0].data)
|
|
2250
|
-
});
|
|
2251
|
-
}
|
|
2252
|
-
return replay;
|
|
2253
|
-
}
|
|
2254
|
-
const normalizedIds = (0, import_consumer_status_identity.normalizeReportingConsumerStatusIdsV1)(input.entries);
|
|
2255
|
-
const chainKeys = input.entries.map(
|
|
2256
|
-
(entry) => "status" in entry ? (0, import_consumer_status_identity.reportingConsumerStatusChainKeyV1)(entry.status) : entry.chainIdentity ? (0, import_consumer_status_identity.reportingConsumerStatusChainKeyFromIdentityV1)(entry.chainIdentity) : null
|
|
2257
|
-
);
|
|
2258
|
-
const duplicateChains = new Set(
|
|
2259
|
-
chainKeys.filter(
|
|
2260
|
-
(value, index) => value !== null && (chainKeys.indexOf(value) !== index || chainKeys.lastIndexOf(value) !== index)
|
|
2261
|
-
)
|
|
2262
|
-
);
|
|
2263
|
-
const duplicateStatusIds = new Set(
|
|
2264
|
-
normalizedIds.values.filter((_, index) => !normalizedIds.invalidIndexes.has(index)).filter((value, index, values) => values.indexOf(value) !== index || values.lastIndexOf(value) !== index)
|
|
2265
|
-
);
|
|
2266
|
-
const capacity = await client.query(
|
|
2267
|
-
`SELECT
|
|
2268
|
-
(SELECT COUNT(*) FROM adcp_reporting_consumer_status_batches
|
|
2269
|
-
WHERE account_id = $1 AND consumer_id = $2)::text AS batches,
|
|
2270
|
-
(SELECT COUNT(*) FROM adcp_reporting_consumer_statuses
|
|
2271
|
-
WHERE account_id = $1 AND consumer_id = $2)::text AS statuses`,
|
|
2272
|
-
[input.account_id, input.consumerId]
|
|
2273
|
-
);
|
|
2274
|
-
const batchCapacityExhausted = Number(capacity.rows[0]?.batches ?? 0) >= MAX_CONSUMER_STATUS_BATCHES;
|
|
2275
|
-
let remainingStatements = MAX_CONSUMER_STATUS_STATEMENTS - Number(capacity.rows[0]?.statuses ?? 0);
|
|
2276
|
-
const results = [];
|
|
2277
|
-
const storedResults = [];
|
|
2278
|
-
const fail = (statusId, errorCode, safeMessage, errorField, recovery = (0, import_error_codes.getErrorRecovery)(errorCode) ?? import_error_codes.DEFAULT_UNKNOWN_ERROR_RECOVERY, errorKeyword, retryAfterSeconds) => {
|
|
2279
|
-
const boundedErrorField = boundedConsumerStatusErrorField(errorField);
|
|
2280
|
-
const boundedErrorKeyword = boundedConsumerStatusErrorKeyword(errorKeyword);
|
|
2281
|
-
const boundedSafeMessage = boundedConsumerStatusSafeMessage(safeMessage);
|
|
2282
|
-
results.push({
|
|
2283
|
-
inserted: false,
|
|
2284
|
-
reporting_status_id: statusId,
|
|
2285
|
-
errorCode,
|
|
2286
|
-
recovery,
|
|
2287
|
-
retryAfterSeconds,
|
|
2288
|
-
safeMessage: boundedSafeMessage,
|
|
2289
|
-
...boundedErrorField,
|
|
2290
|
-
...boundedErrorKeyword
|
|
2291
|
-
});
|
|
2292
|
-
storedResults.push({
|
|
2293
|
-
kind: "failed",
|
|
2294
|
-
id: statusId,
|
|
2295
|
-
errorCode,
|
|
2296
|
-
recovery,
|
|
2297
|
-
retryAfterSeconds,
|
|
2298
|
-
safeMessage: boundedSafeMessage,
|
|
2299
|
-
...boundedErrorField,
|
|
2300
|
-
...boundedErrorKeyword
|
|
2301
|
-
});
|
|
2302
|
-
};
|
|
2303
|
-
for (const [index, entry] of input.entries.entries()) {
|
|
2304
|
-
const statusId = normalizedIds.values[index];
|
|
2305
|
-
if (normalizedIds.invalidIndexes.has(index)) {
|
|
2306
|
-
fail(statusId, "VALIDATION_ERROR", "reporting_status_id is invalid", void 0, "correctable", "pattern");
|
|
2307
|
-
continue;
|
|
2308
|
-
}
|
|
2309
|
-
if (duplicateStatusIds.has(statusId)) {
|
|
2310
|
-
fail(statusId, "VALIDATION_ERROR", "reporting_status_id must be unique in a batch");
|
|
2311
|
-
continue;
|
|
2312
|
-
}
|
|
2313
|
-
if (!("status" in entry)) {
|
|
2314
|
-
fail(
|
|
2315
|
-
statusId,
|
|
2316
|
-
"VALIDATION_ERROR",
|
|
2317
|
-
entry.validationError,
|
|
2318
|
-
entry.validationField,
|
|
2319
|
-
"correctable",
|
|
2320
|
-
entry.validationKeyword
|
|
2321
|
-
);
|
|
2322
|
-
continue;
|
|
2323
|
-
}
|
|
2324
|
-
const status = entry.status;
|
|
2325
|
-
if (duplicateChains.has(chainKeys[index])) {
|
|
2326
|
-
fail(status.reporting_status_id, "VALIDATION_ERROR", "A batch may update each status chain only once");
|
|
2327
|
-
continue;
|
|
2328
|
-
}
|
|
2329
|
-
if (status.account_id !== input.account_id || status.consumerId !== input.consumerId) {
|
|
2330
|
-
fail(status.reporting_status_id, "PERMISSION_DENIED", "Reporting status scope is unavailable");
|
|
2331
|
-
continue;
|
|
2332
|
-
}
|
|
2333
|
-
const fingerprint = (0, import_consumer_status_identity.reportingConsumerStatusFingerprintV1)(status);
|
|
2334
|
-
const existing = await client.query(
|
|
2335
|
-
`SELECT data, semantic_fingerprint, account_id, consumer_id FROM adcp_reporting_consumer_statuses
|
|
2336
|
-
WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3 FOR UPDATE`,
|
|
2337
|
-
[status.reporting_status_id, input.account_id, input.consumerId]
|
|
2338
|
-
);
|
|
2339
|
-
if (existing.rows[0]) {
|
|
2340
|
-
if (existing.rows[0].semantic_fingerprint !== fingerprint) {
|
|
2341
|
-
fail(status.reporting_status_id, "IDEMPOTENCY_CONFLICT", "reporting_status_id is unavailable");
|
|
2342
|
-
continue;
|
|
2343
|
-
}
|
|
2344
|
-
results.push({ inserted: false, value: clone(existing.rows[0].data) });
|
|
2345
|
-
storedResults.push({ kind: "unchanged", id: status.reporting_status_id });
|
|
2346
|
-
continue;
|
|
2347
|
-
}
|
|
2348
|
-
const prevalidation = entry.validationError;
|
|
2349
|
-
if (prevalidation) {
|
|
2350
|
-
fail(
|
|
2351
|
-
status.reporting_status_id,
|
|
2352
|
-
"VALIDATION_ERROR",
|
|
2353
|
-
prevalidation,
|
|
2354
|
-
entry.validationField,
|
|
2355
|
-
"correctable",
|
|
2356
|
-
entry.validationKeyword
|
|
2357
|
-
);
|
|
2358
|
-
continue;
|
|
2359
|
-
}
|
|
2360
|
-
if (Buffer.byteLength(JSON.stringify(status), "utf8") > import_types.REPORTING_CONSUMER_STATUS_MAX_BYTES) {
|
|
2361
|
-
fail(
|
|
2362
|
-
status.reporting_status_id,
|
|
2363
|
-
"REPORTING_STATUS_TOO_LARGE",
|
|
2364
|
-
"Reporting consumer status exceeds 64 KiB",
|
|
2365
|
-
void 0,
|
|
2366
|
-
"correctable"
|
|
2367
|
-
);
|
|
2368
|
-
continue;
|
|
2369
|
-
}
|
|
2370
|
-
if (batchCapacityExhausted || remainingStatements <= 0) {
|
|
2371
|
-
fail(
|
|
2372
|
-
status.reporting_status_id,
|
|
2373
|
-
"REPORTING_STATUS_CAPACITY_EXHAUSTED",
|
|
2374
|
-
"Reporting consumer status capacity is exhausted",
|
|
2375
|
-
void 0,
|
|
2376
|
-
"terminal"
|
|
2377
|
-
);
|
|
2378
|
-
continue;
|
|
2379
|
-
}
|
|
2380
|
-
const chainKey = chainKeys[index];
|
|
2381
|
-
const current = await client.query(
|
|
2382
|
-
`SELECT data FROM adcp_reporting_consumer_statuses
|
|
2383
|
-
WHERE account_id = $1 AND consumer_id = $2 AND chain_key = $3 AND is_current
|
|
2384
|
-
FOR UPDATE`,
|
|
2385
|
-
[input.account_id, input.consumerId, chainKey]
|
|
2386
|
-
);
|
|
2387
|
-
const leaf = current.rows[0]?.data;
|
|
2388
|
-
if ((leaf?.reporting_status_id ?? void 0) !== status.supersedes_reporting_status_id) {
|
|
2389
|
-
fail(status.reporting_status_id, "IDEMPOTENCY_CONFLICT", "Status must supersede the exact current leaf");
|
|
2390
|
-
continue;
|
|
2391
|
-
}
|
|
2392
|
-
if (leaf && (0, import_instant.compareReportingInstants)(status.status_as_of, leaf.status_as_of) < 0) {
|
|
2393
|
-
fail(status.reporting_status_id, "VALIDATION_ERROR", "Status time cannot regress");
|
|
2394
|
-
continue;
|
|
2395
|
-
}
|
|
2396
|
-
if (leaf) {
|
|
2397
|
-
await client.query(
|
|
2398
|
-
`UPDATE adcp_reporting_consumer_statuses SET is_current = false
|
|
2399
|
-
WHERE consumer_status_id = $1 AND account_id = $2 AND consumer_id = $3`,
|
|
2400
|
-
[leaf.reporting_status_id, input.account_id, input.consumerId]
|
|
2401
|
-
);
|
|
2402
|
-
}
|
|
2403
|
-
const recorded = await client.query(
|
|
2404
|
-
`SELECT GREATEST(
|
|
2405
|
-
clock_timestamp(),
|
|
2406
|
-
COALESCE((
|
|
2407
|
-
SELECT MAX(ledger_as_of) + INTERVAL '1 millisecond'
|
|
2408
|
-
FROM adcp_reporting_checkpoints
|
|
2409
|
-
WHERE account_id = $1
|
|
2410
|
-
), '-infinity'::timestamptz)
|
|
2411
|
-
) AS recorded_at`,
|
|
2412
|
-
[input.account_id]
|
|
2413
|
-
);
|
|
2414
|
-
const value = {
|
|
2415
|
-
...status,
|
|
2416
|
-
recorded_at: recorded.rows[0].recorded_at.toISOString()
|
|
2417
|
-
};
|
|
2418
|
-
await client.query(
|
|
2419
|
-
`INSERT INTO adcp_reporting_consumer_statuses
|
|
2420
|
-
(consumer_status_id, account_id, consumer_id, chain_key, revision_id, obligation_id,
|
|
2421
|
-
supersedes_consumer_status_id, is_current, semantic_fingerprint, data, created_at, recorded_at)
|
|
2422
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $9::jsonb, $10, $10)`,
|
|
2423
|
-
[
|
|
2424
|
-
status.reporting_status_id,
|
|
2425
|
-
input.account_id,
|
|
2426
|
-
input.consumerId,
|
|
2427
|
-
chainKey,
|
|
2428
|
-
status.reporting_revision_id ?? null,
|
|
2429
|
-
status.reporting_obligation_id ?? null,
|
|
2430
|
-
status.supersedes_reporting_status_id ?? null,
|
|
2431
|
-
fingerprint,
|
|
2432
|
-
JSON.stringify(value),
|
|
2433
|
-
value.recorded_at
|
|
2434
|
-
]
|
|
2435
|
-
);
|
|
2436
|
-
results.push({ inserted: true, value: clone(value) });
|
|
2437
|
-
storedResults.push({ kind: "recorded", id: status.reporting_status_id });
|
|
2438
|
-
remainingStatements -= 1;
|
|
2439
|
-
}
|
|
2440
|
-
boundStoredConsumerStatusResults(storedResults, results);
|
|
2441
|
-
if (!batchCapacityExhausted) {
|
|
2442
|
-
await client.query(
|
|
2443
|
-
`INSERT INTO adcp_reporting_consumer_status_batches
|
|
2444
|
-
(account_id, consumer_id, idempotency_key, request_fingerprint, status_ids, results)
|
|
2445
|
-
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`,
|
|
2446
|
-
[
|
|
2447
|
-
input.account_id,
|
|
2448
|
-
input.consumerId,
|
|
2449
|
-
input.idempotencyKey,
|
|
2450
|
-
input.requestFingerprint,
|
|
2451
|
-
JSON.stringify(
|
|
2452
|
-
results.map((value) => "value" in value ? value.value.reporting_status_id : value.reporting_status_id)
|
|
2453
|
-
),
|
|
2454
|
-
JSON.stringify(storedResults)
|
|
2455
|
-
]
|
|
2456
|
-
);
|
|
2457
|
-
}
|
|
2458
|
-
return results;
|
|
2459
|
-
},
|
|
2460
|
-
{ preBeginAdvisoryLock: accountLock(input.account_id) }
|
|
2461
|
-
);
|
|
2462
|
-
}
|
|
2463
|
-
async listConsumerStatuses(revisionId) {
|
|
2464
|
-
const result = await this.query(
|
|
2465
|
-
`SELECT data FROM adcp_reporting_consumer_statuses WHERE revision_id = $1
|
|
2466
|
-
AND consumer_id = '__legacy_unscoped_consumer__'
|
|
2467
|
-
ORDER BY recorded_at, consumer_status_id`,
|
|
2468
|
-
[revisionId]
|
|
2469
|
-
);
|
|
2470
|
-
return result.rows.map((row) => clone(row.data));
|
|
2471
|
-
}
|
|
2472
|
-
async putIssue(issue) {
|
|
2473
|
-
const lock = await this.accountLockForObligation(issue.reporting_obligation_id);
|
|
2474
|
-
await this.transaction(
|
|
2475
|
-
async (client) => {
|
|
2476
|
-
const result = await client.query(
|
|
2477
|
-
`INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
|
|
2478
|
-
VALUES ($1, $2, $3::jsonb, $4, $5)
|
|
2479
|
-
-- opened_at is fixed at first emission; see recordIssue.
|
|
2480
|
-
ON CONFLICT (issue_id) DO UPDATE SET
|
|
2481
|
-
data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
|
|
2482
|
-
observed_at = EXCLUDED.observed_at,
|
|
2483
|
-
resolved_at = EXCLUDED.resolved_at,
|
|
2484
|
-
changed_at = clock_timestamp()
|
|
2485
|
-
WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
|
|
2486
|
-
[
|
|
2487
|
-
issue.issueId,
|
|
2488
|
-
issue.reporting_obligation_id,
|
|
2489
|
-
JSON.stringify(issue),
|
|
2490
|
-
issue.observedAt,
|
|
2491
|
-
issue.resolvedAt ?? null
|
|
2492
|
-
]
|
|
2493
|
-
);
|
|
2494
|
-
if (result.rowCount !== 1) throw new Error("Reporting issue identity belongs to another obligation");
|
|
2495
|
-
},
|
|
2496
|
-
{ preBeginAdvisoryLock: lock }
|
|
2497
|
-
);
|
|
2498
|
-
}
|
|
2499
|
-
async resolveIssue(issueId, resolvedAt) {
|
|
2500
|
-
const lock = await this.accountLockForIssue(issueId);
|
|
2501
|
-
if (!lock) return;
|
|
2502
|
-
await this.transaction(
|
|
2503
|
-
async (client) => {
|
|
2504
|
-
await client.query(
|
|
2505
|
-
`UPDATE adcp_reporting_issues
|
|
2506
|
-
SET resolved_at = $2::text::timestamptz,
|
|
2507
|
-
data = data || jsonb_build_object('resolvedAt', $2::text),
|
|
2508
|
-
changed_at = clock_timestamp()
|
|
2509
|
-
WHERE issue_id = $1 AND resolved_at IS NULL`,
|
|
2510
|
-
[issueId, resolvedAt]
|
|
2511
|
-
);
|
|
2512
|
-
},
|
|
2513
|
-
{ preBeginAdvisoryLock: lock }
|
|
2514
|
-
);
|
|
2515
|
-
}
|
|
2516
|
-
async listIssues(obligationId) {
|
|
2517
|
-
const result = await this.query(
|
|
2518
|
-
`SELECT data FROM adcp_reporting_issues WHERE obligation_id = $1
|
|
2519
|
-
ORDER BY observed_at, issue_id`,
|
|
2520
|
-
[obligationId]
|
|
2521
|
-
);
|
|
2522
|
-
return result.rows.map((row) => clone(row.data));
|
|
2523
|
-
}
|
|
2524
|
-
/**
|
|
2525
|
-
* Resolves — and for pre-SDK-14 rows persists — the observed finality baseline
|
|
2526
|
-
* of the obligation's latest transition, so the lifecycle decision and this
|
|
2527
|
-
* store's compare-and-set read one committed value.
|
|
2528
|
-
*/
|
|
2529
|
-
async resolveTransitionFinalityBaseline(obligationId) {
|
|
2530
|
-
const lock = await this.accountLockForObligation(obligationId);
|
|
2531
|
-
return this.transaction((client) => resolveStoredFinalityBaseline(client, obligationId), {
|
|
2532
|
-
preBeginAdvisoryLock: lock
|
|
2533
|
-
});
|
|
2534
|
-
}
|
|
2535
|
-
async appendTransition(transition) {
|
|
2536
|
-
const lock = await this.accountLockForObligation(transition.reporting_obligation_id);
|
|
2537
|
-
return assertFinalityWriterFence(() => this.appendTransitionWithinLock(transition, lock));
|
|
2538
|
-
}
|
|
2539
|
-
async appendTransitionWithinLock(transition, lock) {
|
|
2540
|
-
return this.transaction(
|
|
2541
|
-
async (client) => {
|
|
2542
|
-
if (this.notificationActivityPort) {
|
|
2543
|
-
await assertNoLegacyPendingTransitions(client, transition.reporting_obligation_id);
|
|
2544
|
-
}
|
|
2545
|
-
const previousFinality = await resolveStoredFinalityBaseline(client, transition.reporting_obligation_id);
|
|
2546
|
-
const latest = await client.query(
|
|
2547
|
-
`SELECT data->>'health' AS health FROM adcp_reporting_transitions
|
|
2548
|
-
WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1`,
|
|
2549
|
-
[transition.reporting_obligation_id]
|
|
2550
|
-
);
|
|
2551
|
-
if ((latest.rows[0]?.health ?? "waiting") !== transition.previousHealth || transition.previousFinality !== void 0 && previousFinality !== transition.previousFinality) {
|
|
2552
|
-
return { inserted: false };
|
|
2553
|
-
}
|
|
2554
|
-
const storedTransition = this.notificationActivityPort ? { ...transition, notifiedAt: transition.occurredAt } : transition;
|
|
2555
|
-
const result = await client.query(
|
|
2556
|
-
`INSERT INTO adcp_reporting_transitions (transition_id, obligation_id, data, occurred_at)
|
|
2557
|
-
VALUES ($1, $2, $3::jsonb, $4)
|
|
2558
|
-
ON CONFLICT DO NOTHING RETURNING transition_id`,
|
|
2559
|
-
[
|
|
2560
|
-
transition.transitionId,
|
|
2561
|
-
transition.reporting_obligation_id,
|
|
2562
|
-
JSON.stringify(storedTransition),
|
|
2563
|
-
transition.occurredAt
|
|
2564
|
-
]
|
|
2565
|
-
);
|
|
2566
|
-
if (result.rowCount === 1 && this.notificationActivityPort) {
|
|
2567
|
-
const obligation = await client.query(
|
|
2568
|
-
"SELECT data FROM adcp_reporting_obligations WHERE obligation_id = $1",
|
|
2569
|
-
[transition.reporting_obligation_id]
|
|
2570
|
-
);
|
|
2571
|
-
if (!obligation.rows[0]) throw new Error("Reporting obligation is unavailable");
|
|
2572
|
-
await this.notificationActivityPort.recordTransition(
|
|
2573
|
-
{ transition, obligation: obligation.rows[0].data },
|
|
2574
|
-
client
|
|
2575
|
-
);
|
|
2576
|
-
}
|
|
2577
|
-
return { inserted: result.rowCount === 1 };
|
|
2578
|
-
},
|
|
2579
|
-
{ preBeginAdvisoryLock: lock }
|
|
2580
|
-
);
|
|
2581
|
-
}
|
|
2582
|
-
async applyLifecycleProjection(input) {
|
|
2583
|
-
const lock = await this.accountLockForObligation(input.reporting_obligation_id);
|
|
2584
|
-
return assertFinalityWriterFence(() => this.applyLifecycleProjectionWithinLock(input, lock));
|
|
2585
|
-
}
|
|
2586
|
-
async applyLifecycleProjectionWithinLock(input, lock) {
|
|
2587
|
-
return this.transaction(
|
|
2588
|
-
async (client) => {
|
|
2589
|
-
const obligations = await client.query(
|
|
2590
|
-
`SELECT data FROM adcp_reporting_obligations WHERE obligation_id = $1 FOR UPDATE`,
|
|
2591
|
-
[input.reporting_obligation_id]
|
|
2592
|
-
);
|
|
2593
|
-
if (this.notificationActivityPort) {
|
|
2594
|
-
await assertNoLegacyPendingTransitions(client, input.reporting_obligation_id);
|
|
2595
|
-
}
|
|
2596
|
-
const previousFinality = await resolveStoredFinalityBaseline(client, input.reporting_obligation_id);
|
|
2597
|
-
const revisions = await client.query(
|
|
2598
|
-
`SELECT revision_id FROM adcp_reporting_revisions
|
|
2599
|
-
WHERE obligation_id = $1 ORDER BY revision_number, revision_id`,
|
|
2600
|
-
[input.reporting_obligation_id]
|
|
2601
|
-
);
|
|
2602
|
-
const latest = await client.query(
|
|
2603
|
-
`SELECT data->>'health' AS health FROM adcp_reporting_transitions
|
|
2604
|
-
WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1`,
|
|
2605
|
-
[input.reporting_obligation_id]
|
|
2606
|
-
);
|
|
2607
|
-
const revisionIds = revisions.rows.map((value) => value.revision_id);
|
|
2608
|
-
const previousHealth = latest.rows[0]?.health ?? "waiting";
|
|
2609
|
-
const obligation = obligations.rows[0]?.data;
|
|
2610
|
-
if (!obligation || obligation.state !== input.expectedObligationState || obligation.attemptCount !== input.expectedAttemptCount || (0, import_source.canonicalJsonV1)(revisionIds) !== (0, import_source.canonicalJsonV1)(input.expectedRevisionIds) || previousHealth !== input.expectedPreviousHealth || input.expectedPreviousFinality !== void 0 && previousFinality !== input.expectedPreviousFinality) {
|
|
2611
|
-
return { applied: false, transitionInserted: false };
|
|
2612
|
-
}
|
|
2613
|
-
if (input.expectedManagedStateVersion !== void 0 && await this.readManagedStateVersion(client, input.reporting_obligation_id) !== input.expectedManagedStateVersion) {
|
|
2614
|
-
return { applied: false, transitionInserted: false };
|
|
2615
|
-
}
|
|
2616
|
-
if (input.expectedObligatedConsumerRosterVersion !== void 0) {
|
|
2617
|
-
const state = await client.query(
|
|
2618
|
-
`SELECT current_roster_version FROM adcp_reporting_lifecycle_state
|
|
2619
|
-
WHERE obligation_id = $1 FOR UPDATE`,
|
|
2620
|
-
[input.reporting_obligation_id]
|
|
2621
|
-
);
|
|
2622
|
-
const observed = state.rows[0]?.current_roster_version;
|
|
2623
|
-
if (observed !== void 0 && observed !== input.expectedObligatedConsumerRosterVersion) {
|
|
2624
|
-
return { applied: false, transitionInserted: false };
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
let transitionInserted = false;
|
|
2628
|
-
if (input.transition) {
|
|
2629
|
-
const storedTransition = this.notificationActivityPort ? { ...input.transition, notifiedAt: input.ledgerAsOf } : input.transition;
|
|
2630
|
-
const inserted = await client.query(
|
|
2631
|
-
`INSERT INTO adcp_reporting_transitions (transition_id, obligation_id, data, occurred_at)
|
|
2632
|
-
VALUES ($1, $2, $3::jsonb, $4)
|
|
2633
|
-
ON CONFLICT DO NOTHING RETURNING transition_id`,
|
|
2634
|
-
[
|
|
2635
|
-
input.transition.transitionId,
|
|
2636
|
-
input.reporting_obligation_id,
|
|
2637
|
-
JSON.stringify(storedTransition),
|
|
2638
|
-
input.transition.occurredAt
|
|
2639
|
-
]
|
|
2640
|
-
);
|
|
2641
|
-
if (inserted.rowCount !== 1) return { applied: false, transitionInserted: false };
|
|
2642
|
-
transitionInserted = true;
|
|
2643
|
-
if (this.notificationActivityPort) {
|
|
2644
|
-
await this.notificationActivityPort.recordTransition({ transition: input.transition, obligation }, client);
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
{
|
|
2648
|
-
await client.query(
|
|
2649
|
-
`INSERT INTO adcp_reporting_lifecycle_state
|
|
2650
|
-
(obligation_id, processed_state_version, processed_roster_version,
|
|
2651
|
-
current_roster_version, processed_at)
|
|
2652
|
-
VALUES ($1, $2, $3, $3, LEAST($4::timestamptz, clock_timestamp()))
|
|
2653
|
-
ON CONFLICT (obligation_id) DO UPDATE SET
|
|
2654
|
-
processed_state_version = EXCLUDED.processed_state_version,
|
|
2655
|
-
processed_roster_version = EXCLUDED.processed_roster_version,
|
|
2656
|
-
-- Reconciling also observes the roster, so the current version
|
|
2657
|
-
-- moves with the processed one. A later external change
|
|
2658
|
-
-- publishes a different current version and re-arms; without
|
|
2659
|
-
-- this the two never matched and the obligation stayed due.
|
|
2660
|
-
-- Never over a version this reconcile did not observe: if the
|
|
2661
|
-
-- row was created or advanced after the check above, that
|
|
2662
|
-
-- newer observation is what has to survive, or its re-arm is
|
|
2663
|
-
-- silently dropped.
|
|
2664
|
-
current_roster_version = CASE
|
|
2665
|
-
WHEN $5::text IS NOT NULL
|
|
2666
|
-
AND adcp_reporting_lifecycle_state.current_roster_version IS DISTINCT FROM $5::text
|
|
2667
|
-
THEN adcp_reporting_lifecycle_state.current_roster_version
|
|
2668
|
-
ELSE EXCLUDED.processed_roster_version END,
|
|
2669
|
-
processed_at = EXCLUDED.processed_at,
|
|
2670
|
-
failure_count = 0,
|
|
2671
|
-
next_attempt_at = NULL`,
|
|
2672
|
-
[
|
|
2673
|
-
input.reporting_obligation_id,
|
|
2674
|
-
input.processedManagedStateVersion ?? null,
|
|
2675
|
-
// Empty string, not null: null means "never reconciled", which
|
|
2676
|
-
// is a due condition. A deployment with no roster source would
|
|
2677
|
-
// otherwise stay due forever.
|
|
2678
|
-
input.processedObligatedConsumerRosterVersion ?? "",
|
|
2679
|
-
// The cutoff the projection actually read at, never the commit
|
|
2680
|
-
// instant. Watermarking at commit time swallowed everything
|
|
2681
|
-
// that landed between the two: excluded from the projection,
|
|
2682
|
-
// and then behind the watermark forever, so it never became
|
|
2683
|
-
// due again. It is clamped to this database's clock in SQL
|
|
2684
|
-
// below, because a cutoff ahead of the ledger buries
|
|
2685
|
-
// database-timestamped work the same way for the opposite
|
|
2686
|
-
// reason.
|
|
2687
|
-
input.ledgerAsOf,
|
|
2688
|
-
input.expectedObligatedConsumerRosterVersion ?? null
|
|
2689
|
-
]
|
|
2690
|
-
);
|
|
2691
|
-
}
|
|
2692
|
-
const projectedIds = input.projectedIssues.map((issue) => issue.issueId);
|
|
2693
|
-
for (const issue of input.projectedIssues) {
|
|
2694
|
-
await client.query(
|
|
2695
|
-
`INSERT INTO adcp_reporting_issues (issue_id, obligation_id, data, observed_at, resolved_at)
|
|
2696
|
-
VALUES ($1, $2, $3::jsonb, $4, NULL)
|
|
2697
|
-
-- opened_at is fixed at first emission; see recordIssue. A reopened
|
|
2698
|
-
-- issue keeps the anchor because it is the same issue_id.
|
|
2699
|
-
ON CONFLICT (issue_id) DO UPDATE SET
|
|
2700
|
-
data = jsonb_set(EXCLUDED.data, '{openedAt}', COALESCE(adcp_reporting_issues.data -> 'openedAt', EXCLUDED.data -> 'openedAt')),
|
|
2701
|
-
observed_at = EXCLUDED.observed_at,
|
|
2702
|
-
resolved_at = NULL, changed_at = clock_timestamp()
|
|
2703
|
-
WHERE adcp_reporting_issues.obligation_id = EXCLUDED.obligation_id`,
|
|
2704
|
-
[issue.issueId, input.reporting_obligation_id, JSON.stringify(issue), issue.observedAt]
|
|
2705
|
-
);
|
|
2706
|
-
}
|
|
2707
|
-
await client.query(
|
|
2708
|
-
`UPDATE adcp_reporting_issues
|
|
2709
|
-
SET resolved_at = $2::timestamptz,
|
|
2710
|
-
data = data || jsonb_build_object('resolvedAt', $2::text),
|
|
2711
|
-
changed_at = clock_timestamp()
|
|
2712
|
-
WHERE obligation_id = $1 AND resolved_at IS NULL
|
|
2713
|
-
-- Every code this projection can emit, not only the Core two.
|
|
2714
|
-
-- A managed DELIVERY_FAILED persisted by an earlier reconcile
|
|
2715
|
-
-- was never cleared, so after a successful redelivery the
|
|
2716
|
-
-- status kept publishing a stale delivery failure for as long
|
|
2717
|
-
-- as Core stayed degraded for some unrelated reason.
|
|
2718
|
-
AND data->>'code' IN (
|
|
2719
|
-
'REPORT_OVERDUE', 'REPORTING_COVERAGE_INCOMPLETE',
|
|
2720
|
-
'DELIVERY_FAILED', 'RESOURCE_EXPIRED',
|
|
2721
|
-
'RECEIPT_REQUIRED', 'RECEIPT_REJECTED',
|
|
2722
|
-
'ADJUSTMENT_RECEIPT_REQUIRED', 'ADJUSTMENT_RECEIPT_REJECTED'
|
|
2723
|
-
)
|
|
2724
|
-
AND NOT (issue_id = ANY($3::text[]))`,
|
|
2725
|
-
[input.reporting_obligation_id, input.ledgerAsOf, projectedIds]
|
|
2726
|
-
);
|
|
2727
|
-
return { applied: true, transitionInserted };
|
|
2728
|
-
},
|
|
2729
|
-
{ preBeginAdvisoryLock: lock }
|
|
2730
|
-
);
|
|
2731
|
-
}
|
|
2732
|
-
async markTransitionNotified(transitionId, notifiedAt) {
|
|
2733
|
-
const lock = await this.accountLockForTransition(transitionId);
|
|
2734
|
-
if (!lock) return;
|
|
2735
|
-
await this.transaction(
|
|
2736
|
-
async (client) => {
|
|
2737
|
-
await client.query(
|
|
2738
|
-
`UPDATE adcp_reporting_transitions
|
|
2739
|
-
SET data = data || jsonb_build_object('notifiedAt', $2::text)
|
|
2740
|
-
|| CASE WHEN data ? 'finality' THEN '{}'::jsonb ELSE jsonb_build_object('finality', 'none') END
|
|
2741
|
-
WHERE transition_id = $1 AND NOT (data ? 'notifiedAt')`,
|
|
2742
|
-
[transitionId, notifiedAt]
|
|
2743
|
-
);
|
|
2744
|
-
},
|
|
2745
|
-
{ preBeginAdvisoryLock: lock }
|
|
2746
|
-
);
|
|
2747
|
-
}
|
|
2748
|
-
async listTransitions(obligationId) {
|
|
2749
|
-
const result = await this.query(
|
|
2750
|
-
`SELECT data FROM adcp_reporting_transitions WHERE obligation_id = $1
|
|
2751
|
-
ORDER BY transition_sequence`,
|
|
2752
|
-
[obligationId]
|
|
2753
|
-
);
|
|
2754
|
-
return result.rows.map((row) => clone(row.data));
|
|
2755
|
-
}
|
|
2756
|
-
async listPendingTransitions(input = {}) {
|
|
2757
|
-
const limit = input.limit ?? 100;
|
|
2758
|
-
positiveInteger(limit, "limit");
|
|
2759
|
-
if (limit > 1e3) throw new RangeError("limit must not exceed 1000");
|
|
2760
|
-
const result = await this.query(
|
|
2761
|
-
`SELECT transition.data FROM adcp_reporting_transitions transition
|
|
2762
|
-
JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = transition.obligation_id
|
|
2763
|
-
WHERE NOT (transition.data ? 'notifiedAt')
|
|
2764
|
-
AND ($1::text IS NULL OR obligation.account_id = $1)
|
|
2765
|
-
ORDER BY transition.recorded_at, transition.transition_id
|
|
2766
|
-
LIMIT $2`,
|
|
2767
|
-
[input.account_id ?? null, limit]
|
|
2768
|
-
);
|
|
2769
|
-
return result.rows.map((row) => clone(row.data));
|
|
2770
|
-
}
|
|
2771
|
-
async createSnapshot(query) {
|
|
2772
|
-
return this.transaction(
|
|
2773
|
-
async (client) => {
|
|
2774
|
-
const clock = await client.query(
|
|
2775
|
-
"SELECT statement_timestamp() AS ledger_as_of"
|
|
2776
|
-
);
|
|
2777
|
-
const ledgerAsOf = clock.rows[0].ledger_as_of.toISOString();
|
|
2778
|
-
await client.query(
|
|
2779
|
-
"DELETE FROM adcp_reporting_snapshots WHERE account_id = $1 AND expires_at <= clock_timestamp()",
|
|
2780
|
-
[query.account_id]
|
|
2781
|
-
);
|
|
2782
|
-
if (query.view === "periods") {
|
|
2783
|
-
await client.query(
|
|
2784
|
-
"DELETE FROM adcp_reporting_checkpoints WHERE account_id = $1 AND expires_at <= clock_timestamp()",
|
|
2785
|
-
[query.account_id]
|
|
2786
|
-
);
|
|
2787
|
-
}
|
|
2788
|
-
const active = await client.query(
|
|
2789
|
-
`SELECT COUNT(*)::text AS count, COALESCE(SUM(byte_count), 0)::text AS bytes
|
|
2790
|
-
FROM adcp_reporting_snapshots WHERE account_id = $1`,
|
|
2791
|
-
[query.account_id]
|
|
2792
|
-
);
|
|
2793
|
-
if (Number(active.rows[0]?.count ?? 0) >= MAX_ACTIVE_SNAPSHOTS_PER_ACCOUNT) {
|
|
2794
|
-
throw new Error("Reporting ledger snapshot capacity is exhausted");
|
|
2795
|
-
}
|
|
2796
|
-
const activeSnapshotBytes = Number(active.rows[0]?.bytes ?? 0);
|
|
2797
|
-
const changesAfter = query.changes_after ? await this.resolveChangesCheckpoint(client, query, query.changes_after) : void 0;
|
|
2798
|
-
const managedInstalled = this.managedDelivery && await this.managedTablesInstalled(client);
|
|
2799
|
-
const configurations = await this.listSnapshotConfigurations(client, query, ledgerAsOf);
|
|
2800
|
-
if (configurations.length > MAX_SNAPSHOT_ITEMS) {
|
|
2801
|
-
throw new Error("Reporting ledger snapshot exceeds the configuration limit");
|
|
2802
|
-
}
|
|
2803
|
-
let obligations = await this.listSnapshotObligations(client, query, ledgerAsOf, changesAfter);
|
|
2804
|
-
if (managedInstalled && changesAfter) {
|
|
2805
|
-
const managedChangedIds = await this.listManagedChangedObligationIds(client, query, ledgerAsOf, changesAfter);
|
|
2806
|
-
if (managedChangedIds.size) {
|
|
2807
|
-
const fullScope = await this.listSnapshotObligations(
|
|
2808
|
-
client,
|
|
2809
|
-
{ ...query, changes_after: void 0, health: void 0 },
|
|
2810
|
-
ledgerAsOf
|
|
2811
|
-
);
|
|
2812
|
-
const byId = new Map(obligations.map((value) => [value.reporting_obligation_id, value]));
|
|
2813
|
-
for (const obligation of fullScope) {
|
|
2814
|
-
if (managedChangedIds.has(obligation.reporting_obligation_id)) {
|
|
2815
|
-
byId.set(obligation.reporting_obligation_id, obligation);
|
|
2816
|
-
}
|
|
2817
|
-
}
|
|
2818
|
-
obligations = [...byId.values()].sort(
|
|
2819
|
-
(left, right) => left.period.start.localeCompare(right.period.start) || left.reporting_obligation_id.localeCompare(right.reporting_obligation_id)
|
|
2820
|
-
);
|
|
2821
|
-
}
|
|
2822
|
-
}
|
|
2823
|
-
if (obligations.length > MAX_SNAPSHOT_ITEMS) {
|
|
2824
|
-
throw new Error("Reporting ledger snapshot exceeds the item limit");
|
|
2825
|
-
}
|
|
2826
|
-
const frozenCalendarProofRequired = configurations.some((configuration) => {
|
|
2827
|
-
try {
|
|
2828
|
-
(0, import_schedule.reportingPeriodSchedule)(configuration);
|
|
2829
|
-
return false;
|
|
2830
|
-
} catch (error) {
|
|
2831
|
-
if ((0, import_schedule.isFrozenCalendarRulesMismatch)(error)) return true;
|
|
2832
|
-
throw error;
|
|
2833
|
-
}
|
|
2834
|
-
});
|
|
2835
|
-
const coverageObligations = changesAfter || frozenCalendarProofRequired ? await this.listSnapshotObligations(
|
|
2836
|
-
client,
|
|
2837
|
-
{
|
|
2838
|
-
...query,
|
|
2839
|
-
changes_after: void 0,
|
|
2840
|
-
health: void 0,
|
|
2841
|
-
finality: void 0,
|
|
2842
|
-
...frozenCalendarProofRequired ? {
|
|
2843
|
-
// The current host cannot derive the predecessor's edge
|
|
2844
|
-
// ordinals. Fetch its full closed range so coverage can
|
|
2845
|
-
// prove the handoff exclusively from frozen obligations.
|
|
2846
|
-
period: {
|
|
2847
|
-
start: configurations.map((configuration) => configuration.installedAt).sort(import_instant.compareReportingInstants)[0],
|
|
2848
|
-
end: ledgerAsOf
|
|
2849
|
-
}
|
|
2850
|
-
} : {}
|
|
2851
|
-
},
|
|
2852
|
-
ledgerAsOf
|
|
2853
|
-
) : obligations;
|
|
2854
|
-
if (coverageObligations.length > MAX_SNAPSHOT_ITEMS) {
|
|
2855
|
-
throw new Error("Reporting ledger snapshot exceeds the coverage item limit");
|
|
2856
|
-
}
|
|
2857
|
-
const ledgerCoverage = (0, import_coverage.evaluateReportingLedgerCoverageV1)(
|
|
2858
|
-
query,
|
|
2859
|
-
configurations,
|
|
2860
|
-
coverageObligations.map((value) => ({
|
|
2861
|
-
configurationId: value.configurationId,
|
|
2862
|
-
periodOrdinal: value.periodOrdinal,
|
|
2863
|
-
period: { start: value.period.start, end: value.period.end }
|
|
2864
|
-
})),
|
|
2865
|
-
ledgerAsOf
|
|
2866
|
-
);
|
|
2867
|
-
let consumerStatuses = query.consumer_id ? await this.listSnapshotConsumerStatuses(client, query, ledgerAsOf, changesAfter, MAX_SNAPSHOT_ITEMS + 1) : [];
|
|
2868
|
-
if (consumerStatuses.length > MAX_SNAPSHOT_ITEMS) {
|
|
2869
|
-
throw new Error("Reporting ledger snapshot exceeds the consumer status limit");
|
|
2870
|
-
}
|
|
2871
|
-
const consumerStatusProjection = query.consumer_id ? await this.listSnapshotConsumerStatuses(client, query, ledgerAsOf, void 0, MAX_SNAPSHOT_ITEMS + 1) : [];
|
|
2872
|
-
if (consumerStatusProjection.length > MAX_SNAPSHOT_ITEMS) {
|
|
2873
|
-
throw new Error("Reporting ledger snapshot exceeds the consumer status projection limit");
|
|
2874
|
-
}
|
|
2875
|
-
const supersededConsumerStatusIds = new Set(
|
|
2876
|
-
consumerStatusProjection.map((value) => value.supersedes_reporting_status_id).filter((value) => Boolean(value))
|
|
2877
|
-
);
|
|
2878
|
-
const exposesMissingObligationStatus = query.view === "periods" && consumerStatusProjection.some(
|
|
2879
|
-
(value) => value.consumer_status === "obligation_missing" && !supersededConsumerStatusIds.has(value.reporting_status_id)
|
|
2880
|
-
);
|
|
2881
|
-
if (query.view !== "revision" && !ledgerCoverage.complete && !exposesMissingObligationStatus) {
|
|
2882
|
-
throw new ReportingLedgerContinuityError("Reporting ledger is missing an elapsed obligation");
|
|
2883
|
-
}
|
|
2884
|
-
const obligationIds = obligations.map((value) => value.reporting_obligation_id);
|
|
2885
|
-
const remaining = MAX_SNAPSHOT_ITEMS - obligations.length;
|
|
2886
|
-
let revisions = await this.listSnapshotRevisions(client, obligationIds, query, ledgerAsOf, remaining + 1);
|
|
2887
|
-
if (revisions.length > remaining) throw new Error("Reporting ledger snapshot exceeds the item limit");
|
|
2888
|
-
const adjustmentCapacity = remaining - revisions.length;
|
|
2889
|
-
let adjustments = await this.listSnapshotAdjustments(client, obligationIds, ledgerAsOf, adjustmentCapacity + 1);
|
|
2890
|
-
if (adjustments.length > adjustmentCapacity) {
|
|
2891
|
-
throw new Error("Reporting ledger snapshot exceeds the item limit");
|
|
2892
|
-
}
|
|
2893
|
-
const consumerStatusCapacity = adjustmentCapacity - adjustments.length;
|
|
2894
|
-
if (consumerStatuses.length > consumerStatusCapacity) {
|
|
2895
|
-
throw new Error("Reporting ledger snapshot exceeds the item limit");
|
|
2896
|
-
}
|
|
2897
|
-
let issues = await this.listSnapshotIssues(client, obligationIds, MAX_SNAPSHOT_ITEMS + 1);
|
|
2898
|
-
const managedBindings = managedInstalled ? await this.listSnapshotManagedBindings(
|
|
2899
|
-
client,
|
|
2900
|
-
configurations.map((value) => value.configurationId)
|
|
2901
|
-
) : [];
|
|
2902
|
-
let materializations = managedInstalled ? await this.listSnapshotMaterializations(client, obligationIds, ledgerAsOf, changesAfter) : [];
|
|
2903
|
-
let receipts = managedInstalled && query.consumer_id ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, "revision") : [];
|
|
2904
|
-
let adjustmentReceipts = managedInstalled && query.consumer_id ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, "adjustment") : [];
|
|
2905
|
-
const tombstonedAcceptedSubjects = managedInstalled ? (await this.listTombstonedAcceptedSubjects(client, query, obligationIds, false, ledgerAsOf)).subjects : [];
|
|
2906
|
-
const tombstonedDeliveredRevisionIds = managedInstalled ? (await client.query(
|
|
2907
|
-
`SELECT revision_id FROM adcp_reporting_materialization_tombstones
|
|
2908
|
-
WHERE obligation_id = ANY($1::text[]) AND reached_success
|
|
2909
|
-
AND COALESCE(reached_success_at, pruned_at) <= $3::timestamptz LIMIT $2`,
|
|
2910
|
-
[obligationIds, MAX_SNAPSHOT_ITEMS + 1, ledgerAsOf]
|
|
2911
|
-
)).rows.map((row) => row.revision_id) : [];
|
|
2912
|
-
const materializationProjection = managedInstalled ? await this.listSnapshotMaterializationProjection(client, obligationIds, ledgerAsOf) : [];
|
|
2913
|
-
const materializationHistoryProjection = managedInstalled && changesAfter ? await this.listSnapshotMaterializations(client, obligationIds, ledgerAsOf) : materializations;
|
|
2914
|
-
const receiptProjection = managedInstalled && query.consumer_id && changesAfter ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, void 0, "revision") : receipts;
|
|
2915
|
-
const adjustmentReceiptProjection = managedInstalled && query.consumer_id && changesAfter ? await this.listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, void 0, "adjustment") : adjustmentReceipts;
|
|
2916
|
-
if (materializationProjection.length > MAX_SNAPSHOT_ITEMS || materializationHistoryProjection.length > MAX_SNAPSHOT_ITEMS || receiptProjection.length > MAX_SNAPSHOT_ITEMS || adjustmentReceiptProjection.length > MAX_SNAPSHOT_ITEMS) {
|
|
2917
|
-
throw new Error("Reporting ledger snapshot exceeds the managed projection limit");
|
|
2918
|
-
}
|
|
2919
|
-
if (obligations.length + revisions.length + adjustments.length + consumerStatuses.length + materializations.length + receipts.length + adjustmentReceipts.length > MAX_SNAPSHOT_ITEMS) {
|
|
2920
|
-
throw new Error("Reporting ledger snapshot exceeds the item limit");
|
|
2921
|
-
}
|
|
2922
|
-
if (issues.length > MAX_SNAPSHOT_ITEMS) throw new Error("Reporting ledger snapshot exceeds the issue limit");
|
|
2923
|
-
if (query.view === "periods" && query.health) {
|
|
2924
|
-
const scopedObligations = obligations;
|
|
2925
|
-
const accepted = new Set(
|
|
2926
|
-
scopedObligations.filter((value) => {
|
|
2927
|
-
const obligationRevisions = revisions.filter(
|
|
2928
|
-
(item) => item.reporting_obligation_id === value.reporting_obligation_id
|
|
2929
|
-
);
|
|
2930
|
-
const coreProjection = (0, import_health.projectReportingObligationHealthV1)(
|
|
2931
|
-
value,
|
|
2932
|
-
obligationRevisions,
|
|
2933
|
-
ledgerAsOf,
|
|
2934
|
-
(0, import_coverage.reportingLedgerScopeClosed)(query, ledgerAsOf, ledgerCoverage.complete)
|
|
2935
|
-
);
|
|
2936
|
-
const sellerHealth = (0, import_health.projectReportingUnreadableRevisionHealthV1)(
|
|
2937
|
-
value,
|
|
2938
|
-
obligationRevisions,
|
|
2939
|
-
issues,
|
|
2940
|
-
coreProjection.health
|
|
2941
|
-
);
|
|
2942
|
-
const statuses = consumerStatusProjection.filter((status) => statusMatchesObligation(status, value));
|
|
2943
|
-
const leaf = currentConsumerStatus(statuses);
|
|
2944
|
-
const mismatch = (0, import_health.projectReportingConsumerStatusMismatchV1)(
|
|
2945
|
-
value,
|
|
2946
|
-
leaf,
|
|
2947
|
-
obligationRevisions,
|
|
2948
|
-
sellerHealth,
|
|
2949
|
-
ledgerAsOf,
|
|
2950
|
-
this.consumerMismatchEscalation
|
|
2951
|
-
);
|
|
2952
|
-
const baseProjection = { ...coreProjection, health: sellerHealth };
|
|
2953
|
-
const managed = (0, import_handler.projectManagedDelivery)({
|
|
2954
|
-
obligation: value,
|
|
2955
|
-
binding: managedBindings.find((binding) => binding.configurationId === value.configurationId),
|
|
2956
|
-
revisions: obligationRevisions,
|
|
2957
|
-
adjustments: adjustments.filter(
|
|
2958
|
-
(item) => item.reporting_obligation_id === value.reporting_obligation_id
|
|
2959
|
-
),
|
|
2960
|
-
materializations: materializationProjection.filter(
|
|
2961
|
-
(item) => item.reporting_obligation_id === value.reporting_obligation_id
|
|
2962
|
-
),
|
|
2963
|
-
materializationHistory: materializationHistoryProjection.filter(
|
|
2964
|
-
(item) => item.reporting_obligation_id === value.reporting_obligation_id
|
|
2965
|
-
),
|
|
2966
|
-
receipts: receiptProjection,
|
|
2967
|
-
adjustmentReceipts: adjustmentReceiptProjection,
|
|
2968
|
-
base: baseProjection,
|
|
2969
|
-
ledgerAsOf,
|
|
2970
|
-
tombstonedAcceptedSubjects,
|
|
2971
|
-
tombstonedDeliveredRevisionIds
|
|
2972
|
-
});
|
|
2973
|
-
const health = mismatch ? (0, import_handler.moreSevereReportingHealthV1)(managed?.projection.health ?? sellerHealth, mismatch.health) : managed?.projection.health ?? sellerHealth;
|
|
2974
|
-
return query.health.includes(health);
|
|
2975
|
-
}).map((value) => value.reporting_obligation_id)
|
|
2976
|
-
);
|
|
2977
|
-
const obligationsByConsumerStatusChain = new Map(
|
|
2978
|
-
scopedObligations.map((value) => [consumerStatusChainKeyForObligation(value), value])
|
|
2979
|
-
);
|
|
2980
|
-
const projectedStatusesByChain = /* @__PURE__ */ new Map();
|
|
2981
|
-
for (const status of consumerStatusProjection) {
|
|
2982
|
-
const key = (0, import_consumer_status_identity.reportingConsumerStatusChainKeyV1)(status);
|
|
2983
|
-
const statuses = projectedStatusesByChain.get(key) ?? [];
|
|
2984
|
-
statuses.push(status);
|
|
2985
|
-
projectedStatusesByChain.set(key, statuses);
|
|
2986
|
-
}
|
|
2987
|
-
obligations = obligations.filter((value) => accepted.has(value.reporting_obligation_id));
|
|
2988
|
-
revisions = revisions.filter((value) => accepted.has(value.reporting_obligation_id));
|
|
2989
|
-
adjustments = adjustments.filter((value) => accepted.has(value.reporting_obligation_id));
|
|
2990
|
-
materializations = materializations.filter((value) => accepted.has(value.reporting_obligation_id));
|
|
2991
|
-
const acceptedRevisionIds = new Set(revisions.map((value) => value.reporting_revision_id));
|
|
2992
|
-
const acceptedAdjustmentIds = new Set(adjustments.map((value) => value.reporting_adjustment_id));
|
|
2993
|
-
receipts = receipts.filter((value) => acceptedRevisionIds.has(value.reporting_revision_id));
|
|
2994
|
-
adjustmentReceipts = adjustmentReceipts.filter(
|
|
2995
|
-
(value) => acceptedAdjustmentIds.has(value.reporting_adjustment_id)
|
|
2996
|
-
);
|
|
2997
|
-
consumerStatuses = consumerStatuses.filter((value) => {
|
|
2998
|
-
const key = (0, import_consumer_status_identity.reportingConsumerStatusChainKeyV1)(value);
|
|
2999
|
-
const matchingObligation = obligationsByConsumerStatusChain.get(key);
|
|
3000
|
-
if (matchingObligation) return accepted.has(matchingObligation.reporting_obligation_id);
|
|
3001
|
-
const leaf = currentConsumerStatus(projectedStatusesByChain.get(key) ?? []);
|
|
3002
|
-
return query.health.includes("action_required") && leaf?.consumer_status === "obligation_missing";
|
|
3003
|
-
});
|
|
3004
|
-
issues = issues.filter((value) => accepted.has(value.reporting_obligation_id));
|
|
3005
|
-
}
|
|
3006
|
-
const changesCheckpoint = (0, import_node_crypto.randomUUID)();
|
|
3007
|
-
const snapshot = {
|
|
3008
|
-
snapshotId: (0, import_node_crypto.randomUUID)(),
|
|
3009
|
-
ledgerAsOf,
|
|
3010
|
-
changesCheckpoint,
|
|
3011
|
-
queryFingerprint: digest(query),
|
|
3012
|
-
query: clone(query),
|
|
3013
|
-
configurations,
|
|
3014
|
-
coverageOrdinals: coverageObligations.map((value) => ({
|
|
3015
|
-
configurationId: value.configurationId,
|
|
3016
|
-
periodOrdinal: value.periodOrdinal,
|
|
3017
|
-
period: { start: value.period.start, end: value.period.end }
|
|
3018
|
-
})),
|
|
3019
|
-
obligations,
|
|
3020
|
-
revisions,
|
|
3021
|
-
adjustments,
|
|
3022
|
-
...managedInstalled ? {
|
|
3023
|
-
tombstonedAcceptedSubjects,
|
|
3024
|
-
tombstonedDeliveredRevisionIds,
|
|
3025
|
-
managedBindings,
|
|
3026
|
-
materializations,
|
|
3027
|
-
materializationHistoryProjection,
|
|
3028
|
-
materializationProjection,
|
|
3029
|
-
receipts,
|
|
3030
|
-
receiptProjection,
|
|
3031
|
-
adjustmentReceipts,
|
|
3032
|
-
adjustmentReceiptProjection
|
|
3033
|
-
} : {},
|
|
3034
|
-
consumerStatuses,
|
|
3035
|
-
consumerStatusProjection,
|
|
3036
|
-
issues
|
|
3037
|
-
};
|
|
3038
|
-
const snapshotJson = JSON.stringify(snapshot);
|
|
3039
|
-
const snapshotBytes = Buffer.byteLength(snapshotJson, "utf8");
|
|
3040
|
-
if (snapshotBytes > MAX_SNAPSHOT_BYTES) {
|
|
3041
|
-
throw new Error("Reporting ledger snapshot exceeds the byte limit");
|
|
3042
|
-
}
|
|
3043
|
-
if (activeSnapshotBytes + snapshotBytes > MAX_ACTIVE_SNAPSHOT_BYTES_PER_ACCOUNT) {
|
|
3044
|
-
throw new Error("Reporting ledger snapshot byte capacity is exhausted");
|
|
3045
|
-
}
|
|
3046
|
-
await client.query(
|
|
3047
|
-
`INSERT INTO adcp_reporting_snapshots
|
|
3048
|
-
(snapshot_id, account_id, query_fingerprint, data, byte_count, created_at, expires_at)
|
|
3049
|
-
VALUES ($1, $2, $3, $4::jsonb, $5, $6,
|
|
3050
|
-
$6::timestamptz + ($7::bigint * INTERVAL '1 millisecond'))`,
|
|
3051
|
-
[
|
|
3052
|
-
snapshot.snapshotId,
|
|
3053
|
-
query.account_id,
|
|
3054
|
-
snapshot.queryFingerprint,
|
|
3055
|
-
snapshotJson,
|
|
3056
|
-
snapshotBytes,
|
|
3057
|
-
ledgerAsOf,
|
|
3058
|
-
SNAPSHOT_RETENTION_MS
|
|
3059
|
-
]
|
|
3060
|
-
);
|
|
3061
|
-
if (query.view === "periods") {
|
|
3062
|
-
await client.query(
|
|
3063
|
-
`INSERT INTO adcp_reporting_checkpoints
|
|
3064
|
-
(checkpoint_id, account_id, scope_fingerprint, ledger_as_of, expires_at)
|
|
3065
|
-
VALUES ($1, $2, $3, $4,
|
|
3066
|
-
$4::timestamptz + ($5::bigint * INTERVAL '1 millisecond'))`,
|
|
3067
|
-
[
|
|
3068
|
-
changesCheckpoint,
|
|
3069
|
-
query.account_id,
|
|
3070
|
-
checkpointScopeFingerprint(query),
|
|
3071
|
-
ledgerAsOf,
|
|
3072
|
-
CHECKPOINT_RETENTION_MS
|
|
3073
|
-
]
|
|
3074
|
-
);
|
|
3075
|
-
}
|
|
3076
|
-
return clone(snapshot);
|
|
3077
|
-
},
|
|
3078
|
-
{
|
|
3079
|
-
isolation: "REPEATABLE READ",
|
|
3080
|
-
preBeginAdvisoryLock: `adcp-reporting-account:${query.account_id}`
|
|
3081
|
-
}
|
|
3082
|
-
);
|
|
3083
|
-
}
|
|
3084
|
-
async readSnapshotPage(snapshotId, account_id, cursor, limit) {
|
|
3085
|
-
positiveInteger(limit, "limit");
|
|
3086
|
-
if (limit > 500) throw new RangeError("limit must not exceed 500");
|
|
3087
|
-
if (!UUID_PATTERN.test(snapshotId)) throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
3088
|
-
const snapshot = await this.one(
|
|
3089
|
-
`SELECT data FROM adcp_reporting_snapshots
|
|
3090
|
-
WHERE snapshot_id = $1 AND account_id = $2 AND expires_at > clock_timestamp()`,
|
|
3091
|
-
[snapshotId, account_id]
|
|
3092
|
-
);
|
|
3093
|
-
if (!snapshot) throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
3094
|
-
const offset = cursor ? decodeCursor(cursor, snapshot) : 0;
|
|
3095
|
-
const items = snapshotItems(snapshot);
|
|
3096
|
-
const selected = items.slice(offset, offset + limit);
|
|
3097
|
-
const obligations = selected.filter((value) => value.kind === "obligation").map((value) => value.value);
|
|
3098
|
-
const revisions = selected.filter((value) => value.kind === "revision").map((value) => value.value);
|
|
3099
|
-
const adjustments = selected.filter((value) => value.kind === "adjustment").map((value) => value.value);
|
|
3100
|
-
const consumerStatuses = selected.filter(
|
|
3101
|
-
(value) => value.kind === "consumer_status"
|
|
3102
|
-
).map((value) => value.value);
|
|
3103
|
-
const materializations = selected.filter(
|
|
3104
|
-
(value) => value.kind === "materialization"
|
|
3105
|
-
).map((value) => value.value);
|
|
3106
|
-
const receipts = selected.filter((value) => value.kind === "receipt").map((value) => value.value);
|
|
3107
|
-
const adjustmentReceipts = selected.filter(
|
|
3108
|
-
(value) => value.kind === "adjustment_receipt"
|
|
3109
|
-
).map((value) => value.value);
|
|
3110
|
-
const nextOffset = offset + selected.length;
|
|
3111
|
-
const hasMore = nextOffset < items.length;
|
|
3112
|
-
return {
|
|
3113
|
-
snapshot,
|
|
3114
|
-
obligations,
|
|
3115
|
-
revisions,
|
|
3116
|
-
adjustments,
|
|
3117
|
-
materializations,
|
|
3118
|
-
receipts,
|
|
3119
|
-
adjustmentReceipts,
|
|
3120
|
-
consumerStatuses,
|
|
3121
|
-
totalCount: items.length,
|
|
3122
|
-
offset,
|
|
3123
|
-
limit,
|
|
3124
|
-
hasMore,
|
|
3125
|
-
...hasMore ? { nextCursor: encodeCursor(snapshot, nextOffset) } : {}
|
|
3126
|
-
};
|
|
3127
|
-
}
|
|
3128
|
-
async listSnapshotConsumerStatuses(client, query, ledgerAsOf, changesAfter, limit, currentOnly = false) {
|
|
3129
|
-
const period = (0, import_coverage.reportingLedgerEffectivePeriod)(query, ledgerAsOf);
|
|
3130
|
-
const result = await client.query(
|
|
3131
|
-
`SELECT status.data FROM adcp_reporting_consumer_statuses status
|
|
3132
|
-
JOIN adcp_reporting_configurations configuration
|
|
3133
|
-
ON configuration.account_id = status.account_id
|
|
3134
|
-
AND configuration.delivery_config_id = status.data->>'delivery_config_id'
|
|
3135
|
-
AND configuration.delivery_config_version = (status.data->>'delivery_config_version')::integer
|
|
3136
|
-
WHERE status.account_id = $1 AND status.consumer_id = $2
|
|
3137
|
-
AND status.recorded_at <= $3
|
|
3138
|
-
AND ($4::timestamptz IS NULL OR status.recorded_at > $4)
|
|
3139
|
-
AND ($5::text[] IS NULL OR status.data->>'delivery_config_id' = ANY($5))
|
|
3140
|
-
AND ($10::text IS NOT NULL OR (status.data->'period'->>'end')::timestamptz > $6)
|
|
3141
|
-
AND ($10::text IS NOT NULL OR (status.data->'period'->>'start')::timestamptz < $7)
|
|
3142
|
-
AND ($8::text[] IS NULL OR configuration.data->>'feedPurpose' = ANY($8))
|
|
3143
|
-
AND ($9::text[] IS NULL OR configuration.data->'mediaBuyIds' ?| $9::text[])
|
|
3144
|
-
AND ($10::text IS NULL OR status.data->>'reporting_revision_id' = $10)
|
|
3145
|
-
AND (NOT $11::boolean OR status.is_current)
|
|
3146
|
-
ORDER BY status.recorded_at, status.consumer_status_id
|
|
3147
|
-
LIMIT $12`,
|
|
3148
|
-
[
|
|
3149
|
-
query.account_id,
|
|
3150
|
-
query.consumer_id,
|
|
3151
|
-
ledgerAsOf,
|
|
3152
|
-
changesAfter ?? null,
|
|
3153
|
-
query.delivery_config_ids ?? null,
|
|
3154
|
-
period.start,
|
|
3155
|
-
period.end,
|
|
3156
|
-
query.feed_purposes ?? null,
|
|
3157
|
-
query.media_buy_ids ?? null,
|
|
3158
|
-
query.reporting_revision_id ?? null,
|
|
3159
|
-
currentOnly,
|
|
3160
|
-
limit
|
|
3161
|
-
]
|
|
3162
|
-
);
|
|
3163
|
-
return result.rows.map((row) => clone(row.data));
|
|
3164
|
-
}
|
|
3165
|
-
async listSnapshotObligations(client, query, ledgerAsOf, changesAfter) {
|
|
3166
|
-
const defaultPeriod = query.view === "revision" ? { start: null, end: null } : {
|
|
3167
|
-
start: new Date(Date.parse(ledgerAsOf) - 24 * 60 * 60 * 1e3).toISOString(),
|
|
3168
|
-
end: ledgerAsOf
|
|
3169
|
-
};
|
|
3170
|
-
const result = await client.query(
|
|
3171
|
-
`SELECT data FROM adcp_reporting_obligations
|
|
3172
|
-
WHERE account_id = $1 AND created_at <= $2
|
|
3173
|
-
AND ($3::timestamptz IS NULL OR changed_at > $3 OR EXISTS (
|
|
3174
|
-
SELECT 1 FROM adcp_reporting_revisions revision
|
|
3175
|
-
WHERE revision.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3176
|
-
AND revision.recorded_at > $3 AND revision.recorded_at <= $2
|
|
3177
|
-
) OR EXISTS (
|
|
3178
|
-
SELECT 1 FROM adcp_reporting_adjustments adjustment
|
|
3179
|
-
WHERE adjustment.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3180
|
-
AND adjustment.recorded_at > $3 AND adjustment.recorded_at <= $2
|
|
3181
|
-
) OR EXISTS (
|
|
3182
|
-
SELECT 1 FROM adcp_reporting_consumer_statuses status
|
|
3183
|
-
WHERE status.account_id = $1
|
|
3184
|
-
AND status.consumer_id = $10
|
|
3185
|
-
AND (
|
|
3186
|
-
status.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3187
|
-
OR (
|
|
3188
|
-
status.data->>'delivery_config_id' = adcp_reporting_obligations.data->>'delivery_config_id'
|
|
3189
|
-
AND (status.data->>'delivery_config_version')::bigint =
|
|
3190
|
-
(adcp_reporting_obligations.data->>'delivery_config_version')::bigint
|
|
3191
|
-
AND status.data->>'report_definition_id' = adcp_reporting_obligations.data->>'report_definition_id'
|
|
3192
|
-
AND (status.data->'period'->>'start')::timestamptz = period_start
|
|
3193
|
-
AND (status.data->'period'->>'end')::timestamptz = period_end
|
|
3194
|
-
AND status.data->'period'->>'source_timezone' =
|
|
3195
|
-
adcp_reporting_obligations.data->'period'->>'sourceTimezone'
|
|
3196
|
-
)
|
|
3197
|
-
)
|
|
3198
|
-
AND status.recorded_at > $3 AND status.recorded_at <= $2
|
|
3199
|
-
) OR EXISTS (
|
|
3200
|
-
SELECT 1 FROM adcp_reporting_issues issue
|
|
3201
|
-
WHERE issue.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3202
|
-
AND issue.changed_at > $3 AND issue.changed_at <= $2
|
|
3203
|
-
) OR EXISTS (
|
|
3204
|
-
SELECT 1 FROM adcp_reporting_transitions transition
|
|
3205
|
-
WHERE transition.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3206
|
-
AND transition.recorded_at > $3 AND transition.recorded_at <= $2
|
|
3207
|
-
) OR ((data->>'expectedAt')::timestamptz > $3 AND (data->>'expectedAt')::timestamptz <= $2)
|
|
3208
|
-
OR ((data->>'recoveryDeadlineAt')::timestamptz > $3
|
|
3209
|
-
AND (data->>'recoveryDeadlineAt')::timestamptz <= $2)
|
|
3210
|
-
)
|
|
3211
|
-
AND ($4::text[] IS NULL OR data->>'delivery_config_id' = ANY($4))
|
|
3212
|
-
AND ($5::text[] IS NULL OR data->>'feedPurpose' = ANY($5))
|
|
3213
|
-
AND ($6::text[] IS NULL OR EXISTS (
|
|
3214
|
-
SELECT 1 FROM jsonb_array_elements_text(data->'mediaBuyIds') value WHERE value = ANY($6)
|
|
3215
|
-
))
|
|
3216
|
-
AND ($7::timestamptz IS NULL OR period_end > $7)
|
|
3217
|
-
AND ($8::timestamptz IS NULL OR period_start < $8)
|
|
3218
|
-
AND ($9::text IS NOT NULL OR period_end <= $2)
|
|
3219
|
-
AND ($9::text IS NULL OR EXISTS (
|
|
3220
|
-
SELECT 1 FROM adcp_reporting_revisions exact_revision
|
|
3221
|
-
WHERE exact_revision.obligation_id = adcp_reporting_obligations.obligation_id
|
|
3222
|
-
AND exact_revision.revision_id = $9 AND exact_revision.recorded_at <= $2
|
|
3223
|
-
))
|
|
3224
|
-
ORDER BY period_start, obligation_id
|
|
3225
|
-
LIMIT $11`,
|
|
3226
|
-
[
|
|
3227
|
-
query.account_id,
|
|
3228
|
-
ledgerAsOf,
|
|
3229
|
-
changesAfter ?? null,
|
|
3230
|
-
query.delivery_config_ids ?? null,
|
|
3231
|
-
query.feed_purposes ?? null,
|
|
3232
|
-
query.media_buy_ids ?? null,
|
|
3233
|
-
query.period?.start ?? defaultPeriod.start,
|
|
3234
|
-
query.period?.end ?? defaultPeriod.end,
|
|
3235
|
-
query.reporting_revision_id ?? null,
|
|
3236
|
-
query.consumer_id ?? null,
|
|
3237
|
-
MAX_SNAPSHOT_ITEMS + 1
|
|
3238
|
-
]
|
|
3239
|
-
);
|
|
3240
|
-
return result.rows.map((row) => row.data);
|
|
3241
|
-
}
|
|
3242
|
-
async listSnapshotConfigurations(client, query, ledgerAsOf) {
|
|
3243
|
-
const result = await client.query(
|
|
3244
|
-
`SELECT data FROM adcp_reporting_configurations
|
|
3245
|
-
WHERE account_id = $1 AND created_at <= $2
|
|
3246
|
-
AND ($3::text[] IS NULL OR delivery_config_id = ANY($3))
|
|
3247
|
-
ORDER BY delivery_config_id, delivery_config_version
|
|
3248
|
-
LIMIT $4`,
|
|
3249
|
-
[query.account_id, ledgerAsOf, query.delivery_config_ids ?? null, MAX_SNAPSHOT_ITEMS + 1]
|
|
3250
|
-
);
|
|
3251
|
-
return result.rows.map((row) => row.data);
|
|
3252
|
-
}
|
|
3253
|
-
async resolveChangesCheckpoint(client, query, checkpointId) {
|
|
3254
|
-
if (!UUID_PATTERN.test(checkpointId)) throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
3255
|
-
const result = await client.query(
|
|
3256
|
-
`SELECT ledger_as_of FROM adcp_reporting_checkpoints
|
|
3257
|
-
WHERE checkpoint_id = $1 AND account_id = $2 AND scope_fingerprint = $3
|
|
3258
|
-
AND expires_at > clock_timestamp()`,
|
|
3259
|
-
[checkpointId, query.account_id, checkpointScopeFingerprint(query)]
|
|
3260
|
-
);
|
|
3261
|
-
const value = result.rows[0]?.ledger_as_of;
|
|
3262
|
-
if (!value) throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
3263
|
-
return value.toISOString();
|
|
3264
|
-
}
|
|
3265
|
-
async listSnapshotRevisions(client, obligationIds, query, ledgerAsOf, limit) {
|
|
3266
|
-
if (!obligationIds.length) return [];
|
|
3267
|
-
const result = await client.query(
|
|
3268
|
-
`SELECT data - 'rows' AS data FROM adcp_reporting_revisions
|
|
3269
|
-
WHERE obligation_id = ANY($1::text[]) AND recorded_at <= $2
|
|
3270
|
-
AND ($3::text IS NULL OR revision_id = $3)
|
|
3271
|
-
ORDER BY obligation_id, revision_number
|
|
3272
|
-
LIMIT $4`,
|
|
3273
|
-
[obligationIds, ledgerAsOf, query.reporting_revision_id ?? null, limit]
|
|
3274
|
-
);
|
|
3275
|
-
return result.rows.map((row) => row.data);
|
|
3276
|
-
}
|
|
3277
|
-
async listSnapshotIssues(client, obligationIds, limit) {
|
|
3278
|
-
if (!obligationIds.length) return [];
|
|
3279
|
-
const result = await client.query(
|
|
3280
|
-
`SELECT data FROM adcp_reporting_issues
|
|
3281
|
-
WHERE obligation_id = ANY($1::text[])
|
|
3282
|
-
ORDER BY obligation_id, observed_at, issue_id
|
|
3283
|
-
LIMIT $2`,
|
|
3284
|
-
[obligationIds, limit]
|
|
3285
|
-
);
|
|
3286
|
-
return result.rows.map((row) => row.data);
|
|
3287
|
-
}
|
|
3288
|
-
async listSnapshotAdjustments(client, obligationIds, ledgerAsOf, limit) {
|
|
3289
|
-
if (!obligationIds.length) return [];
|
|
3290
|
-
const result = await client.query(
|
|
3291
|
-
`SELECT data - 'rows' AS data FROM adcp_reporting_adjustments
|
|
3292
|
-
WHERE obligation_id = ANY($1::text[]) AND recorded_at <= $2
|
|
3293
|
-
ORDER BY obligation_id, adjustment_number
|
|
3294
|
-
LIMIT $3`,
|
|
3295
|
-
[obligationIds, ledgerAsOf, limit]
|
|
3296
|
-
);
|
|
3297
|
-
return result.rows.map((row) => row.data);
|
|
3298
|
-
}
|
|
3299
|
-
/**
|
|
3300
|
-
* Managed Delivery projection inputs for one obligation, grouped per consumer.
|
|
3301
|
-
*
|
|
3302
|
-
* The lifecycle reconciler folds these through the same
|
|
3303
|
-
* `projectManagedDelivery` the read path uses, so a persisted transition and
|
|
3304
|
-
* its webhook report the health a read of the same obligation would return.
|
|
3305
|
-
* Returns `null` when Managed Delivery is not installed or the obligation's
|
|
3306
|
-
* configuration has no binding, which keeps a Core-only ledger unchanged.
|
|
3307
|
-
*/
|
|
3308
|
-
async getManagedLifecycleProjection(input) {
|
|
3309
|
-
if (!this.managedDelivery) return null;
|
|
3310
|
-
const base = await this.transaction(
|
|
3311
|
-
async (client) => {
|
|
3312
|
-
if (!await this.managedTablesInstalled(client)) return null;
|
|
3313
|
-
const obligation = await client.query(
|
|
3314
|
-
"SELECT configuration_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
|
|
3315
|
-
[input.reporting_obligation_id]
|
|
3316
|
-
);
|
|
3317
|
-
const configurationId = obligation.rows[0]?.configuration_id;
|
|
3318
|
-
if (!configurationId) return null;
|
|
3319
|
-
const [binding] = await this.listSnapshotManagedBindings(client, [configurationId]);
|
|
3320
|
-
if (!binding) return null;
|
|
3321
|
-
const resolvedLedgerAsOf = (await client.query(
|
|
3322
|
-
// RFC 3339 with microseconds. PostgreSQL's own ::text rendering
|
|
3323
|
-
// uses a space separator and a two-digit offset, which the SDK's
|
|
3324
|
-
// instant parser rejects, and round-tripping through a JS Date
|
|
3325
|
-
// would truncate the microseconds this exists to preserve.
|
|
3326
|
-
`SELECT ${rfc3339Microseconds("COALESCE($1::timestamptz, clock_timestamp())")} AS instant`,
|
|
3327
|
-
[input.ledgerAsOf ?? null]
|
|
3328
|
-
)).rows[0].instant;
|
|
3329
|
-
const rows = await client.query(
|
|
3330
|
-
`SELECT materialization.data,
|
|
3331
|
-
materialization.changed_at > $2::timestamptz AS settled_after_cutoff,
|
|
3332
|
-
CASE WHEN authz.revoked_at <= $2::timestamptz THEN authz.revoked_at END AS revoked_at
|
|
3333
|
-
FROM adcp_reporting_materializations materialization
|
|
3334
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
3335
|
-
ON authz.account_id = materialization.account_id
|
|
3336
|
-
AND authz.destination_ref = materialization.destination_ref
|
|
3337
|
-
AND authz.generation = materialization.authorization_generation
|
|
3338
|
-
WHERE materialization.obligation_id = $1
|
|
3339
|
-
AND materialization.recorded_at <= $2::timestamptz
|
|
3340
|
-
ORDER BY materialization.attempt, materialization.materialization_id
|
|
3341
|
-
LIMIT $3`,
|
|
3342
|
-
[input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
|
|
3343
|
-
);
|
|
3344
|
-
if (rows.rows.length > MAX_SNAPSHOT_ITEMS) {
|
|
3345
|
-
throw new Error("Reporting lifecycle projection exceeds the managed materialization limit");
|
|
3346
|
-
}
|
|
3347
|
-
const placed = rows.rows.map((row) => ({
|
|
3348
|
-
atAsOf: row.settled_after_cutoff ? pendingAtCutoff(row.data) : clone(row.data),
|
|
3349
|
-
revokedAt: row.revoked_at
|
|
3350
|
-
}));
|
|
3351
|
-
const materializationHistory = placed.map((value) => value.atAsOf);
|
|
3352
|
-
const materializations = placed.map(
|
|
3353
|
-
({ atAsOf, revokedAt }) => revokedAt && (atAsOf.status === "available" || atAsOf.status === "delivered") ? {
|
|
3354
|
-
...atAsOf,
|
|
3355
|
-
status: "failed",
|
|
3356
|
-
failed_at: revokedAt.toISOString(),
|
|
3357
|
-
failure_code: "AUTHORIZATION_REVOKED"
|
|
3358
|
-
} : atAsOf
|
|
3359
|
-
);
|
|
3360
|
-
const receipts = await client.query(
|
|
3361
|
-
`WITH subject AS (
|
|
3362
|
-
-- Drive from this obligation's own subjects. Asking the global
|
|
3363
|
-
-- receipt table "which of your rows belong to this obligation"
|
|
3364
|
-
-- matched no index \u2014 they lead with account and consumer, and
|
|
3365
|
-
-- this question supplies neither \u2014 so every due obligation
|
|
3366
|
-
-- re-scanned the whole receipt history.
|
|
3367
|
-
SELECT revision_id AS subject_id, 'revision' AS receipt_kind
|
|
3368
|
-
FROM adcp_reporting_revisions WHERE obligation_id = $1
|
|
3369
|
-
UNION ALL
|
|
3370
|
-
SELECT adjustment_id, 'adjustment'
|
|
3371
|
-
FROM adcp_reporting_adjustments WHERE obligation_id = $1
|
|
3372
|
-
)
|
|
3373
|
-
SELECT receipt.consumer_id, receipt.receipt_kind, receipt.data
|
|
3374
|
-
FROM subject
|
|
3375
|
-
JOIN adcp_reporting_receipts receipt
|
|
3376
|
-
ON receipt.subject_id = subject.subject_id
|
|
3377
|
-
AND receipt.receipt_kind = subject.receipt_kind
|
|
3378
|
-
-- Ordered and cut off by the database clock, exactly as every other
|
|
3379
|
-
-- receipt read path is, so a skewed host cannot make the lifecycle
|
|
3380
|
-
-- projection see a different receipt set than get_reporting_status.
|
|
3381
|
-
--
|
|
3382
|
-
-- One leaf per chain, resolved AS OF the cutoff. The fold reads
|
|
3383
|
-
-- exactly one thing from each chain, and walking the whole history
|
|
3384
|
-
-- to find it pushed this read past its bound for a subject repaired
|
|
3385
|
-
-- often enough. But is_current is today's answer: at a cutoff
|
|
3386
|
-
-- before an acceptance superseded a rejection, the rejection was no
|
|
3387
|
-
-- longer current and the acceptance was not yet in scope, so the
|
|
3388
|
-
-- projection saw neither and persisted RECEIPT_REQUIRED over a
|
|
3389
|
-
-- rejection the buyer had already filed. The leaf at the cutoff is
|
|
3390
|
-
-- the receipt that nothing recorded by then supersedes. Wire
|
|
3391
|
-
-- counters are computed on the read path, which still sees every
|
|
3392
|
-
-- row.
|
|
3393
|
-
WHERE receipt.recorded_at <= $2
|
|
3394
|
-
AND NOT EXISTS (
|
|
3395
|
-
SELECT 1 FROM adcp_reporting_receipts successor
|
|
3396
|
-
WHERE successor.account_id = receipt.account_id
|
|
3397
|
-
AND successor.consumer_id = receipt.consumer_id
|
|
3398
|
-
AND successor.supersedes_receipt_id = receipt.reporting_receipt_id
|
|
3399
|
-
AND successor.recorded_at <= $2
|
|
3400
|
-
)
|
|
3401
|
-
-- A successor whose body has been pruned still superseded this
|
|
3402
|
-
-- row. Counting only live successors let retention resurrect the
|
|
3403
|
-
-- rejected predecessor of a pruned acceptance as the live leaf,
|
|
3404
|
-
-- which overrode that acceptance's own tombstone and reopened a
|
|
3405
|
-
-- settled subject the write path then refused to repair.
|
|
3406
|
-
AND NOT EXISTS (
|
|
3407
|
-
SELECT 1 FROM adcp_reporting_receipt_tombstones gone
|
|
3408
|
-
WHERE gone.account_id = receipt.account_id
|
|
3409
|
-
AND gone.consumer_id = receipt.consumer_id
|
|
3410
|
-
AND gone.supersedes_receipt_id = receipt.reporting_receipt_id
|
|
3411
|
-
AND COALESCE(gone.subject_recorded_at, gone.pruned_at) <= $2
|
|
3412
|
-
)
|
|
3413
|
-
ORDER BY receipt.consumer_id, receipt.subject_id, receipt.recorded_at, receipt.reporting_receipt_id
|
|
3414
|
-
LIMIT $3`,
|
|
3415
|
-
[input.reporting_obligation_id, resolvedLedgerAsOf, MAX_LIFECYCLE_RECEIPT_LEAVES + 1]
|
|
3416
|
-
);
|
|
3417
|
-
let receiptEvidenceComplete = true;
|
|
3418
|
-
let leaves = receipts.rows;
|
|
3419
|
-
if (leaves.length > MAX_LIFECYCLE_RECEIPT_LEAVES) {
|
|
3420
|
-
receiptEvidenceComplete = false;
|
|
3421
|
-
const partial = leaves[MAX_LIFECYCLE_RECEIPT_LEAVES].consumer_id;
|
|
3422
|
-
leaves = leaves.slice(0, MAX_LIFECYCLE_RECEIPT_LEAVES).filter((row) => row.consumer_id !== partial);
|
|
3423
|
-
}
|
|
3424
|
-
const byConsumer = /* @__PURE__ */ new Map();
|
|
3425
|
-
for (const row of leaves) {
|
|
3426
|
-
const consumer = byConsumer.get(row.consumer_id) ?? {
|
|
3427
|
-
consumer_id: row.consumer_id,
|
|
3428
|
-
receipts: [],
|
|
3429
|
-
adjustmentReceipts: []
|
|
3430
|
-
};
|
|
3431
|
-
if (row.receipt_kind === "revision") consumer.receipts.push(clone(row.data));
|
|
3432
|
-
else consumer.adjustmentReceipts.push(clone(row.data));
|
|
3433
|
-
byConsumer.set(row.consumer_id, consumer);
|
|
3434
|
-
}
|
|
3435
|
-
const engaged = await client.query(
|
|
3436
|
-
`SELECT DISTINCT consumer_id FROM adcp_reporting_consumer_statuses
|
|
3437
|
-
WHERE obligation_id = $1 AND consumer_id <> '__legacy_unscoped_consumer__'
|
|
3438
|
-
ORDER BY consumer_id LIMIT $2`,
|
|
3439
|
-
[input.reporting_obligation_id, MAX_SNAPSHOT_ITEMS + 1]
|
|
3440
|
-
);
|
|
3441
|
-
if (engaged.rows.length > MAX_SNAPSHOT_ITEMS) {
|
|
3442
|
-
receiptEvidenceComplete = false;
|
|
3443
|
-
engaged.rows = engaged.rows.slice(0, MAX_SNAPSHOT_ITEMS);
|
|
3444
|
-
}
|
|
3445
|
-
const observed = [.../* @__PURE__ */ new Set([...byConsumer.keys(), ...engaged.rows.map((row) => row.consumer_id)])].sort();
|
|
3446
|
-
const tombstoned = await this.listTombstonedAcceptedSubjects(
|
|
3447
|
-
client,
|
|
3448
|
-
{ account_id: binding.account_id, consumer_id: void 0, view: "periods" },
|
|
3449
|
-
[input.reporting_obligation_id],
|
|
3450
|
-
true,
|
|
3451
|
-
resolvedLedgerAsOf
|
|
3452
|
-
);
|
|
3453
|
-
if (!tombstoned.complete) receiptEvidenceComplete = false;
|
|
3454
|
-
const visible = await client.query(
|
|
3455
|
-
`SELECT adjustment_id FROM adcp_reporting_adjustments
|
|
3456
|
-
WHERE obligation_id = $1 AND recorded_at <= $2::timestamptz
|
|
3457
|
-
ORDER BY adjustment_number, adjustment_id LIMIT $3`,
|
|
3458
|
-
[input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
|
|
3459
|
-
);
|
|
3460
|
-
if (visible.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
|
|
3461
|
-
const visibleAdjustmentIds = visible.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.adjustment_id);
|
|
3462
|
-
const visibleRevisions = await client.query(
|
|
3463
|
-
`SELECT revision_id FROM adcp_reporting_revisions
|
|
3464
|
-
WHERE obligation_id = $1 AND recorded_at <= $2::timestamptz
|
|
3465
|
-
ORDER BY revision_number, revision_id LIMIT $3`,
|
|
3466
|
-
[input.reporting_obligation_id, resolvedLedgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
|
|
3467
|
-
);
|
|
3468
|
-
if (visibleRevisions.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
|
|
3469
|
-
const visibleRevisionIds = visibleRevisions.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.revision_id);
|
|
3470
|
-
const tombstonedAcceptedSubjects = tombstoned.subjects;
|
|
3471
|
-
const delivered = await client.query(
|
|
3472
|
-
`SELECT revision_id FROM adcp_reporting_materialization_tombstones
|
|
3473
|
-
WHERE obligation_id = $1 AND reached_success
|
|
3474
|
-
AND COALESCE(reached_success_at, pruned_at) <= $3::timestamptz LIMIT $2`,
|
|
3475
|
-
[input.reporting_obligation_id, MAX_SNAPSHOT_ITEMS + 1, resolvedLedgerAsOf]
|
|
3476
|
-
);
|
|
3477
|
-
if (delivered.rows.length > MAX_SNAPSHOT_ITEMS) receiptEvidenceComplete = false;
|
|
3478
|
-
const tombstonedDeliveredRevisionIds = delivered.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => row.revision_id);
|
|
3479
|
-
const managedStateVersion = await this.readManagedStateVersion(client, input.reporting_obligation_id);
|
|
3480
|
-
return {
|
|
3481
|
-
binding,
|
|
3482
|
-
materializations,
|
|
3483
|
-
materializationHistory,
|
|
3484
|
-
consumers: [...byConsumer.values()],
|
|
3485
|
-
obligatedConsumerIds: observed,
|
|
3486
|
-
obligatedConsumerRosterComplete: false,
|
|
3487
|
-
receiptEvidenceComplete,
|
|
3488
|
-
visibleAdjustmentIds,
|
|
3489
|
-
visibleRevisionIds,
|
|
3490
|
-
managedStateVersion,
|
|
3491
|
-
resolvedLedgerAsOf,
|
|
3492
|
-
tombstonedAcceptedSubjects,
|
|
3493
|
-
tombstonedDeliveredRevisionIds
|
|
3494
|
-
};
|
|
3495
|
-
},
|
|
3496
|
-
{ isolation: "REPEATABLE READ" }
|
|
3497
|
-
);
|
|
3498
|
-
if (!base || !this.obligatedConsumers) return base;
|
|
3499
|
-
const supplied = await withReportingCallbackDeadline(
|
|
3500
|
-
this.obligatedConsumers({
|
|
3501
|
-
reporting_obligation_id: input.reporting_obligation_id,
|
|
3502
|
-
account_id: base.binding.account_id
|
|
3503
|
-
}),
|
|
3504
|
-
OBLIGATED_CONSUMERS_DEADLINE_MS,
|
|
3505
|
-
"Reporting obligated-consumer lookup deadline elapsed"
|
|
3506
|
-
);
|
|
3507
|
-
const all = obligatedConsumerIdsFor(supplied, base.obligatedConsumerIds ?? []);
|
|
3508
|
-
const identityIds = supplied.version === void 0 && supplied.complete !== true ? obligatedConsumerIdsFor(supplied, await this.observedConsumerIds(input.reporting_obligation_id)) : all;
|
|
3509
|
-
const ids = all.slice(0, MAX_SNAPSHOT_ITEMS);
|
|
3510
|
-
const rosterComplete = supplied.complete === true && all.length === ids.length;
|
|
3511
|
-
return {
|
|
3512
|
-
...base,
|
|
3513
|
-
obligatedConsumerIds: ids,
|
|
3514
|
-
obligatedConsumerRosterComplete: rosterComplete,
|
|
3515
|
-
...all.length === ids.length ? {} : { receiptEvidenceComplete: false },
|
|
3516
|
-
obligatedConsumerRosterVersion: obligatedConsumerRosterVersionFor(supplied, identityIds)
|
|
3517
|
-
};
|
|
3518
|
-
}
|
|
3519
|
-
/**
|
|
3520
|
-
* Digest over every managed row that can change this obligation's projected
|
|
3521
|
-
* health: materialization status, receipt existence and current-leaf flag,
|
|
3522
|
-
* adjustments, and the destination authorization's revocation/cleanup state.
|
|
3523
|
-
*
|
|
3524
|
-
* `is_current` is included because superseding a receipt flips it with no
|
|
3525
|
-
* timestamp of its own, and the authorization's `cleanup_completed_at` is
|
|
3526
|
-
* included because `completeRevocation` does not touch `changed_at`.
|
|
3527
|
-
*/
|
|
3528
|
-
async readManagedStateVersion(client, obligationId) {
|
|
3529
|
-
const result = await client.query(
|
|
3530
|
-
`SELECT md5(COALESCE(string_agg(marker, '|' ORDER BY marker), '')) AS version FROM (
|
|
3531
|
-
SELECT 'm:' || materialization_id || ':' || status || ':'
|
|
3532
|
-
|| EXTRACT(EPOCH FROM changed_at)::text AS marker
|
|
3533
|
-
FROM adcp_reporting_materializations WHERE obligation_id = $1
|
|
3534
|
-
UNION ALL
|
|
3535
|
-
-- Driven from this obligation's subjects into the receipt subject
|
|
3536
|
-
-- index. This digest is read while the apply holds the account
|
|
3537
|
-
-- lock, so asking the global receipt table which of its rows belong
|
|
3538
|
-
-- to an obligation -- a question supplying neither account nor
|
|
3539
|
-
-- consumer -- turned every apply into a full scan with that lock
|
|
3540
|
-
-- held, and concurrent Core writes failed with 55P03.
|
|
3541
|
-
SELECT 'r:' || receipt.reporting_receipt_id || ':' || receipt.is_current::text || ':'
|
|
3542
|
-
|| EXTRACT(EPOCH FROM receipt.recorded_at)::text
|
|
3543
|
-
FROM adcp_reporting_revisions revision
|
|
3544
|
-
JOIN adcp_reporting_receipts receipt
|
|
3545
|
-
ON receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
|
|
3546
|
-
WHERE revision.obligation_id = $1
|
|
3547
|
-
UNION ALL
|
|
3548
|
-
SELECT 'r:' || receipt.reporting_receipt_id || ':' || receipt.is_current::text || ':'
|
|
3549
|
-
|| EXTRACT(EPOCH FROM receipt.recorded_at)::text
|
|
3550
|
-
FROM adcp_reporting_adjustments adjustment
|
|
3551
|
-
JOIN adcp_reporting_receipts receipt
|
|
3552
|
-
ON receipt.receipt_kind = 'adjustment' AND receipt.subject_id = adjustment.adjustment_id
|
|
3553
|
-
WHERE adjustment.obligation_id = $1
|
|
3554
|
-
UNION ALL
|
|
3555
|
-
SELECT 'a:' || adjustment_id || ':' || EXTRACT(EPOCH FROM recorded_at)::text
|
|
3556
|
-
FROM adcp_reporting_adjustments WHERE obligation_id = $1
|
|
3557
|
-
UNION ALL
|
|
3558
|
-
-- Consumer statuses are a health input through the mismatch
|
|
3559
|
-
-- projection and they widen the observed roster, so a status landing
|
|
3560
|
-
-- between projection and apply has to move the token too.
|
|
3561
|
-
SELECT 'c:' || status.consumer_id || ':' || status.consumer_status_id || ':'
|
|
3562
|
-
|| status.is_current::text || ':' || EXTRACT(EPOCH FROM status.created_at)::text
|
|
3563
|
-
FROM adcp_reporting_consumer_statuses status
|
|
3564
|
-
WHERE status.obligation_id = $1
|
|
3565
|
-
UNION ALL
|
|
3566
|
-
SELECT 'z:' || authz.generation::text
|
|
3567
|
-
|| ':' || COALESCE(EXTRACT(EPOCH FROM authz.revoked_at)::text, '')
|
|
3568
|
-
|| ':' || COALESCE(EXTRACT(EPOCH FROM authz.cleanup_completed_at)::text, '')
|
|
3569
|
-
|| ':' || EXTRACT(EPOCH FROM authz.changed_at)::text
|
|
3570
|
-
FROM adcp_reporting_destination_authorizations authz
|
|
3571
|
-
JOIN adcp_reporting_managed_bindings binding
|
|
3572
|
-
ON binding.account_id = authz.account_id AND binding.destination_ref = authz.destination_ref
|
|
3573
|
-
JOIN adcp_reporting_obligations obligation
|
|
3574
|
-
ON obligation.configuration_id = binding.configuration_id
|
|
3575
|
-
WHERE obligation.obligation_id = $1
|
|
3576
|
-
) markers`,
|
|
3577
|
-
[obligationId]
|
|
3578
|
-
);
|
|
3579
|
-
return result.rows[0]?.version ?? "";
|
|
3580
|
-
}
|
|
3581
|
-
/**
|
|
3582
|
-
* Current external roster version, read outside any transaction so the
|
|
3583
|
-
* reconciler can re-check it immediately before an apply.
|
|
3584
|
-
*/
|
|
3585
|
-
/**
|
|
3586
|
-
* Publishes the roster version currently observed for an obligation.
|
|
3587
|
-
*
|
|
3588
|
-
* The roster is external, so nothing in this database changes when it does.
|
|
3589
|
-
* Recording what a reader saw is what lets the due query notice drift from
|
|
3590
|
-
* the version last reconciled. Never lowers a watermark; it only reports.
|
|
3591
|
-
*/
|
|
3592
|
-
async recordObligatedConsumerRosterVersion(input) {
|
|
3593
|
-
await this.query(
|
|
3594
|
-
`INSERT INTO adcp_reporting_lifecycle_state
|
|
3595
|
-
(obligation_id, current_roster_version, roster_refreshed_at, processed_at)
|
|
3596
|
-
VALUES ($1, $2, clock_timestamp(), clock_timestamp())
|
|
3597
|
-
ON CONFLICT (obligation_id) DO UPDATE SET
|
|
3598
|
-
current_roster_version = EXCLUDED.current_roster_version,
|
|
3599
|
-
-- Advances the refresh cursor, never the reconciliation watermark:
|
|
3600
|
-
-- observing a roster is not reconciling an obligation.
|
|
3601
|
-
roster_refreshed_at = clock_timestamp()`,
|
|
3602
|
-
[input.reporting_obligation_id, input.version]
|
|
3603
|
-
);
|
|
3604
|
-
}
|
|
3605
|
-
/**
|
|
3606
|
-
* Re-reads the external roster for managed obligations and publishes what
|
|
3607
|
-
* it saw, so a change made while nothing was scheduled can re-arm.
|
|
3608
|
-
*
|
|
3609
|
-
* Ordered by least-recently-refreshed and bounded, so a large account is
|
|
3610
|
-
* swept over successive passes rather than in one burst of callbacks.
|
|
3611
|
-
*/
|
|
3612
|
-
async refreshObligatedConsumerRosterVersions(input) {
|
|
3613
|
-
if (!this.managedDelivery || !this.obligatedConsumers) return 0;
|
|
3614
|
-
positiveInteger(input.limit, "limit");
|
|
3615
|
-
if (!await this.managedDueTablesReady()) return 0;
|
|
3616
|
-
const budget = Math.min(input.limit, MAX_ROSTER_REFRESH_PER_SWEEP);
|
|
3617
|
-
const candidates = await this.query(
|
|
3618
|
-
`SELECT obligation.obligation_id
|
|
3619
|
-
FROM adcp_reporting_obligations obligation
|
|
3620
|
-
JOIN adcp_reporting_managed_bindings binding
|
|
3621
|
-
ON binding.configuration_id = obligation.configuration_id
|
|
3622
|
-
LEFT JOIN adcp_reporting_lifecycle_state state
|
|
3623
|
-
ON state.obligation_id = obligation.obligation_id
|
|
3624
|
-
WHERE ($1::text IS NULL OR obligation.account_id = $1)
|
|
3625
|
-
-- Its own cursor, advanced by the refresh itself. Ordering by the
|
|
3626
|
-
-- reconciliation watermark, which a refresh never moves, meant every
|
|
3627
|
-
-- sweep re-read the same first page and nothing beyond one page of quiet
|
|
3628
|
-
-- obligations was ever refreshed, so their roster changes could never
|
|
3629
|
-
-- re-arm.
|
|
3630
|
-
ORDER BY state.roster_refreshed_at NULLS FIRST, obligation.obligation_id
|
|
3631
|
-
LIMIT $2`,
|
|
3632
|
-
[input.account_id ?? null, budget]
|
|
3633
|
-
);
|
|
3634
|
-
let refreshed = 0;
|
|
3635
|
-
for (const row of candidates.rows) {
|
|
3636
|
-
try {
|
|
3637
|
-
await this.readObligatedConsumerRosterVersion({ reporting_obligation_id: row.obligation_id });
|
|
3638
|
-
refreshed += 1;
|
|
3639
|
-
} catch {
|
|
3640
|
-
await this.query(
|
|
3641
|
-
`INSERT INTO adcp_reporting_lifecycle_state (obligation_id, roster_refreshed_at, processed_at)
|
|
3642
|
-
VALUES ($1, clock_timestamp(), clock_timestamp())
|
|
3643
|
-
ON CONFLICT (obligation_id) DO UPDATE SET roster_refreshed_at = clock_timestamp()`,
|
|
3644
|
-
[row.obligation_id]
|
|
3645
|
-
).catch(() => void 0);
|
|
3646
|
-
}
|
|
3647
|
-
}
|
|
3648
|
-
return refreshed;
|
|
3649
|
-
}
|
|
3650
|
-
/**
|
|
3651
|
-
* Records a failed reconcile so the obligation backs off instead of
|
|
3652
|
-
* re-occupying the head of every sweep, without ever hiding it: the
|
|
3653
|
-
* watermark is untouched, so it stays unresolved work.
|
|
3654
|
-
*/
|
|
3655
|
-
async recordLifecycleFailure(input) {
|
|
3656
|
-
await this.query(
|
|
3657
|
-
`INSERT INTO adcp_reporting_lifecycle_state (obligation_id, failure_count, next_attempt_at, processed_at)
|
|
3658
|
-
VALUES ($1, 1, clock_timestamp() + INTERVAL '30 seconds', clock_timestamp())
|
|
3659
|
-
ON CONFLICT (obligation_id) DO UPDATE SET
|
|
3660
|
-
failure_count = adcp_reporting_lifecycle_state.failure_count + 1,
|
|
3661
|
-
next_attempt_at = clock_timestamp() + (LEAST(
|
|
3662
|
-
${MAX_LIFECYCLE_BACKOFF_SECONDS}, 30 * POWER(2, LEAST(6, adcp_reporting_lifecycle_state.failure_count))
|
|
3663
|
-
) * INTERVAL '1 second')`,
|
|
3664
|
-
[input.reporting_obligation_id]
|
|
3665
|
-
);
|
|
3666
|
-
}
|
|
3667
|
-
/** Authoritative instant from the database clock, at microsecond precision. */
|
|
3668
|
-
async readLedgerInstant() {
|
|
3669
|
-
const result = await this.query(
|
|
3670
|
-
`SELECT ${rfc3339Microseconds("clock_timestamp()")} AS instant`
|
|
3671
|
-
);
|
|
3672
|
-
return result.rows[0].instant;
|
|
3673
|
-
}
|
|
3674
|
-
async readObligatedConsumerRosterVersion(input) {
|
|
3675
|
-
if (!this.managedDelivery || !this.obligatedConsumers) return void 0;
|
|
3676
|
-
const accountId = await this.query(
|
|
3677
|
-
"SELECT account_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
|
|
3678
|
-
[input.reporting_obligation_id]
|
|
3679
|
-
);
|
|
3680
|
-
const account_id = accountId.rows[0]?.account_id;
|
|
3681
|
-
if (!account_id) return void 0;
|
|
3682
|
-
const supplied = await withReportingCallbackDeadline(
|
|
3683
|
-
this.obligatedConsumers({ reporting_obligation_id: input.reporting_obligation_id, account_id }),
|
|
3684
|
-
OBLIGATED_CONSUMERS_DEADLINE_MS,
|
|
3685
|
-
"Reporting obligated-consumer lookup deadline elapsed"
|
|
3686
|
-
);
|
|
3687
|
-
const observed = await this.observedConsumerIds(input.reporting_obligation_id);
|
|
3688
|
-
const version = obligatedConsumerRosterVersionFor(supplied, obligatedConsumerIdsFor(supplied, observed));
|
|
3689
|
-
await this.recordObligatedConsumerRosterVersion({
|
|
3690
|
-
reporting_obligation_id: input.reporting_obligation_id,
|
|
3691
|
-
version
|
|
3692
|
-
});
|
|
3693
|
-
return version;
|
|
3694
|
-
}
|
|
3695
|
-
/** Principals that have engaged with this obligation, as the projection sees them. */
|
|
3696
|
-
async observedConsumerIds(obligationId) {
|
|
3697
|
-
const result = await this.query(
|
|
3698
|
-
`SELECT DISTINCT consumer_id FROM (
|
|
3699
|
-
-- Driven from this obligation's subjects into the receipt subject
|
|
3700
|
-
-- index, like every other obligation-scoped receipt read. Asking the
|
|
3701
|
-
-- global receipt table which of its rows belong to an obligation
|
|
3702
|
-
-- supplies neither account nor consumer, so the roster read scanned
|
|
3703
|
-
-- the whole receipt history once per reconcile and once per
|
|
3704
|
-
-- pre-apply re-check.
|
|
3705
|
-
SELECT receipt.consumer_id
|
|
3706
|
-
FROM adcp_reporting_revisions revision
|
|
3707
|
-
JOIN adcp_reporting_receipts receipt
|
|
3708
|
-
ON receipt.receipt_kind = 'revision' AND receipt.subject_id = revision.revision_id
|
|
3709
|
-
WHERE revision.obligation_id = $1
|
|
3710
|
-
UNION
|
|
3711
|
-
SELECT receipt.consumer_id
|
|
3712
|
-
FROM adcp_reporting_adjustments adjustment
|
|
3713
|
-
JOIN adcp_reporting_receipts receipt
|
|
3714
|
-
ON receipt.receipt_kind = 'adjustment' AND receipt.subject_id = adjustment.adjustment_id
|
|
3715
|
-
WHERE adjustment.obligation_id = $1
|
|
3716
|
-
UNION
|
|
3717
|
-
SELECT status.consumer_id FROM adcp_reporting_consumer_statuses status
|
|
3718
|
-
WHERE status.obligation_id = $1 AND status.consumer_id <> '__legacy_unscoped_consumer__'
|
|
3719
|
-
) engaged ORDER BY consumer_id`,
|
|
3720
|
-
[obligationId]
|
|
3721
|
-
);
|
|
3722
|
-
return result.rows.map((row) => row.consumer_id);
|
|
3723
|
-
}
|
|
3724
|
-
managedDueTablesReadyCache;
|
|
3725
|
-
/** Whether the managed schema exists, resolved once per store instance. */
|
|
3726
|
-
managedDueTablesReady() {
|
|
3727
|
-
if (!this.managedDelivery) return Promise.resolve(false);
|
|
3728
|
-
this.managedDueTablesReadyCache ??= this.query(
|
|
3729
|
-
`SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
|
|
3730
|
-
AND to_regclass('adcp_reporting_materializations') IS NOT NULL
|
|
3731
|
-
AND to_regclass('adcp_reporting_receipts') IS NOT NULL AS ready`
|
|
3732
|
-
).then((result) => result.rows[0]?.ready === true).catch((cause) => {
|
|
3733
|
-
this.managedDueTablesReadyCache = void 0;
|
|
3734
|
-
throw cause;
|
|
3735
|
-
});
|
|
3736
|
-
return this.managedDueTablesReadyCache;
|
|
3737
|
-
}
|
|
3738
|
-
/**
|
|
3739
|
-
* Terminal receipt subjects whose bodies have been pruned.
|
|
3740
|
-
*
|
|
3741
|
-
* The projection decides reconciliation from the receipt rows it can see,
|
|
3742
|
-
* so once an accepted leaf ages out the subject looks unreconciled again
|
|
3743
|
-
* and a settled billing period reopens. The tombstone is the only remaining
|
|
3744
|
-
* proof, so the projection has to consult it.
|
|
3745
|
-
*/
|
|
3746
|
-
async listTombstonedAcceptedSubjects(client, query, obligationIds, allConsumers = false, ledgerAsOf) {
|
|
3747
|
-
if (!obligationIds.length || !query.consumer_id && !allConsumers) return { subjects: [], complete: true };
|
|
3748
|
-
const result = await client.query(
|
|
3749
|
-
`SELECT tombstone.receipt_kind, tombstone.subject_id, tombstone.consumer_id
|
|
3750
|
-
FROM adcp_reporting_receipt_tombstones tombstone
|
|
3751
|
-
WHERE tombstone.account_id = $1 AND ($2::text IS NULL OR tombstone.consumer_id = $2)
|
|
3752
|
-
AND tombstone.status = 'accepted' AND tombstone.was_current
|
|
3753
|
-
-- A conclusion still has a place in time. Applying every accepted
|
|
3754
|
-
-- tombstone regardless of when its receipt was filed let a
|
|
3755
|
-
-- historical projection settle a subject on an acceptance that had
|
|
3756
|
-
-- not happened yet at its cutoff. Rows written before the column
|
|
3757
|
-
-- existed fall back to pruned_at, which is never earlier, so the
|
|
3758
|
-
-- fallback can only withhold a conclusion, never invent one.
|
|
3759
|
-
AND ($5::timestamptz IS NULL
|
|
3760
|
-
OR COALESCE(tombstone.subject_recorded_at, tombstone.pruned_at) <= $5::timestamptz)
|
|
3761
|
-
AND ((tombstone.receipt_kind = 'revision' AND EXISTS (
|
|
3762
|
-
SELECT 1 FROM adcp_reporting_revisions revision
|
|
3763
|
-
WHERE revision.revision_id = tombstone.subject_id
|
|
3764
|
-
AND revision.obligation_id = ANY($3::text[])))
|
|
3765
|
-
OR (tombstone.receipt_kind = 'adjustment' AND EXISTS (
|
|
3766
|
-
SELECT 1 FROM adcp_reporting_adjustments adjustment
|
|
3767
|
-
WHERE adjustment.adjustment_id = tombstone.subject_id
|
|
3768
|
-
AND adjustment.obligation_id = ANY($3::text[]))))
|
|
3769
|
-
LIMIT $4`,
|
|
3770
|
-
[
|
|
3771
|
-
query.account_id,
|
|
3772
|
-
allConsumers ? null : query.consumer_id,
|
|
3773
|
-
obligationIds,
|
|
3774
|
-
MAX_SNAPSHOT_ITEMS + 1,
|
|
3775
|
-
ledgerAsOf ?? null
|
|
3776
|
-
]
|
|
3777
|
-
);
|
|
3778
|
-
const complete = result.rows.length <= MAX_SNAPSHOT_ITEMS;
|
|
3779
|
-
return {
|
|
3780
|
-
complete,
|
|
3781
|
-
subjects: result.rows.slice(0, MAX_SNAPSHOT_ITEMS).map((row) => ({
|
|
3782
|
-
kind: row.receipt_kind === "adjustment" ? "adjustment" : "revision",
|
|
3783
|
-
subjectId: row.subject_id,
|
|
3784
|
-
// Carried through deliberately. An acceptance belongs to the consumer
|
|
3785
|
-
// that gave it, so a lifecycle fold that runs per consumer must not
|
|
3786
|
-
// let A's pruned acceptance settle the obligation on B's behalf.
|
|
3787
|
-
consumerId: row.consumer_id
|
|
3788
|
-
}))
|
|
3789
|
-
};
|
|
3790
|
-
}
|
|
3791
|
-
async managedTablesInstalled(client) {
|
|
3792
|
-
const result = await client.query(
|
|
3793
|
-
`SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
|
|
3794
|
-
AND to_regclass('adcp_reporting_materializations') IS NOT NULL
|
|
3795
|
-
AND to_regclass('adcp_reporting_receipts') IS NOT NULL AS ready`
|
|
3796
|
-
);
|
|
3797
|
-
return result.rows[0]?.ready === true;
|
|
3798
|
-
}
|
|
3799
|
-
async listManagedChangedObligationIds(client, query, ledgerAsOf, changesAfter) {
|
|
3800
|
-
const result = await client.query(
|
|
3801
|
-
`SELECT DISTINCT changed.obligation_id FROM (
|
|
3802
|
-
SELECT materialization.obligation_id
|
|
3803
|
-
FROM adcp_reporting_materializations materialization
|
|
3804
|
-
WHERE materialization.account_id = $1
|
|
3805
|
-
AND GREATEST(materialization.recorded_at, materialization.changed_at) > $3
|
|
3806
|
-
AND GREATEST(materialization.recorded_at, materialization.changed_at) <= $2
|
|
3807
|
-
UNION ALL
|
|
3808
|
-
SELECT COALESCE(revision.obligation_id, adjustment.obligation_id) AS obligation_id
|
|
3809
|
-
FROM adcp_reporting_receipts receipt
|
|
3810
|
-
LEFT JOIN adcp_reporting_revisions revision
|
|
3811
|
-
ON receipt.receipt_kind = 'revision' AND revision.revision_id = receipt.subject_id
|
|
3812
|
-
LEFT JOIN adcp_reporting_adjustments adjustment
|
|
3813
|
-
ON receipt.receipt_kind = 'adjustment' AND adjustment.adjustment_id = receipt.subject_id
|
|
3814
|
-
WHERE receipt.account_id = $1 AND receipt.consumer_id = $4
|
|
3815
|
-
AND receipt.recorded_at > $3 AND receipt.recorded_at <= $2
|
|
3816
|
-
UNION ALL
|
|
3817
|
-
SELECT obligation.obligation_id
|
|
3818
|
-
FROM adcp_reporting_destination_authorizations authz
|
|
3819
|
-
JOIN adcp_reporting_managed_bindings binding
|
|
3820
|
-
ON binding.account_id = authz.account_id
|
|
3821
|
-
AND binding.destination_ref = authz.destination_ref
|
|
3822
|
-
AND binding.authorization_generation = authz.generation
|
|
3823
|
-
JOIN adcp_reporting_obligations obligation ON obligation.configuration_id = binding.configuration_id
|
|
3824
|
-
WHERE authz.account_id = $1 AND authz.revoked_at IS NOT NULL
|
|
3825
|
-
AND authz.changed_at > $3 AND authz.changed_at <= $2
|
|
3826
|
-
) changed WHERE changed.obligation_id IS NOT NULL`,
|
|
3827
|
-
[query.account_id, ledgerAsOf, changesAfter, query.consumer_id ?? null]
|
|
3828
|
-
);
|
|
3829
|
-
return new Set(result.rows.map((row) => row.obligation_id));
|
|
3830
|
-
}
|
|
3831
|
-
async listSnapshotManagedBindings(client, configurationIds) {
|
|
3832
|
-
if (!configurationIds.length) return [];
|
|
3833
|
-
const result = await client.query(
|
|
3834
|
-
`SELECT data FROM adcp_reporting_managed_bindings
|
|
3835
|
-
WHERE configuration_id = ANY($1::text[]) ORDER BY configuration_id`,
|
|
3836
|
-
[configurationIds]
|
|
3837
|
-
);
|
|
3838
|
-
return result.rows.map((row) => row.data);
|
|
3839
|
-
}
|
|
3840
|
-
async listSnapshotMaterializations(client, obligationIds, ledgerAsOf, changesAfter) {
|
|
3841
|
-
if (!obligationIds.length) return [];
|
|
3842
|
-
const result = await client.query(
|
|
3843
|
-
`SELECT data FROM adcp_reporting_materializations
|
|
3844
|
-
WHERE obligation_id = ANY($1::text[])
|
|
3845
|
-
AND recorded_at <= $2
|
|
3846
|
-
AND ($3::timestamptz IS NULL OR GREATEST(recorded_at, changed_at) > $3)
|
|
3847
|
-
ORDER BY obligation_id, attempt, materialization_id
|
|
3848
|
-
LIMIT $4`,
|
|
3849
|
-
[obligationIds, ledgerAsOf, changesAfter ?? null, MAX_SNAPSHOT_ITEMS + 1]
|
|
3850
|
-
);
|
|
3851
|
-
return result.rows.map((row) => row.data);
|
|
3852
|
-
}
|
|
3853
|
-
async listSnapshotMaterializationProjection(client, obligationIds, ledgerAsOf) {
|
|
3854
|
-
if (!obligationIds.length) return [];
|
|
3855
|
-
const result = await client.query(
|
|
3856
|
-
`SELECT materialization.data, authz.revoked_at
|
|
3857
|
-
FROM adcp_reporting_materializations materialization
|
|
3858
|
-
JOIN adcp_reporting_destination_authorizations authz
|
|
3859
|
-
ON authz.account_id = materialization.account_id
|
|
3860
|
-
AND authz.destination_ref = materialization.destination_ref
|
|
3861
|
-
AND authz.generation = materialization.authorization_generation
|
|
3862
|
-
WHERE materialization.obligation_id = ANY($1::text[])
|
|
3863
|
-
AND materialization.recorded_at <= $2
|
|
3864
|
-
ORDER BY materialization.obligation_id, materialization.attempt, materialization.materialization_id
|
|
3865
|
-
LIMIT $3`,
|
|
3866
|
-
[obligationIds, ledgerAsOf, MAX_SNAPSHOT_ITEMS + 1]
|
|
3867
|
-
);
|
|
3868
|
-
return result.rows.map((row) => {
|
|
3869
|
-
if (!row.revoked_at || row.data.status !== "available" && row.data.status !== "delivered") return row.data;
|
|
3870
|
-
return {
|
|
3871
|
-
...row.data,
|
|
3872
|
-
status: "failed",
|
|
3873
|
-
failed_at: row.revoked_at.toISOString(),
|
|
3874
|
-
failure_code: "AUTHORIZATION_REVOKED"
|
|
3875
|
-
};
|
|
3876
|
-
});
|
|
3877
|
-
}
|
|
3878
|
-
async listSnapshotReceipts(client, query, obligationIds, ledgerAsOf, changesAfter, kind) {
|
|
3879
|
-
if (!obligationIds.length || !query.consumer_id) return [];
|
|
3880
|
-
const join = kind === "revision" ? "JOIN adcp_reporting_revisions subject ON subject.revision_id = receipt.subject_id" : "JOIN adcp_reporting_adjustments subject ON subject.adjustment_id = receipt.subject_id";
|
|
3881
|
-
const result = await client.query(
|
|
3882
|
-
`SELECT receipt.data FROM adcp_reporting_receipts receipt
|
|
3883
|
-
${join}
|
|
3884
|
-
WHERE receipt.account_id = $1 AND receipt.consumer_id = $2
|
|
3885
|
-
AND receipt.receipt_kind = $3 AND subject.obligation_id = ANY($4::text[])
|
|
3886
|
-
AND receipt.recorded_at <= $5
|
|
3887
|
-
AND ($6::timestamptz IS NULL OR receipt.recorded_at > $6)
|
|
3888
|
-
ORDER BY subject.obligation_id, receipt.recorded_at, receipt.reporting_receipt_id
|
|
3889
|
-
LIMIT $7`,
|
|
3890
|
-
[
|
|
3891
|
-
query.account_id,
|
|
3892
|
-
query.consumer_id,
|
|
3893
|
-
kind,
|
|
3894
|
-
obligationIds,
|
|
3895
|
-
ledgerAsOf,
|
|
3896
|
-
changesAfter ?? null,
|
|
3897
|
-
MAX_SNAPSHOT_ITEMS + 1
|
|
3898
|
-
]
|
|
3899
|
-
);
|
|
3900
|
-
return result.rows.map((row) => row.data);
|
|
3901
|
-
}
|
|
3902
|
-
async one(sql, params) {
|
|
3903
|
-
const result = await this.query(sql, params);
|
|
3904
|
-
return result.rows[0] ? clone(result.rows[0].data) : null;
|
|
3905
|
-
}
|
|
3906
|
-
async putImmutable(insertSql, insertParams, readSql, readParams, proposed, fingerprint, advisoryLock, leaseFence, legacyReplayEquivalent, onInserted) {
|
|
3907
|
-
return this.transaction(
|
|
3908
|
-
async (client) => {
|
|
3909
|
-
const inserted = await client.query(insertSql, insertParams);
|
|
3910
|
-
const value = inserted.rows[0]?.data ?? (await client.query(readSql, readParams)).rows[0]?.data;
|
|
3911
|
-
if (!value) {
|
|
3912
|
-
if (leaseFence) {
|
|
3913
|
-
const lease = await client.query(
|
|
3914
|
-
`SELECT 1 FROM adcp_reporting_obligations
|
|
3915
|
-
WHERE obligation_id = $1 AND lease_owner = $2 AND lease_generation = $3
|
|
3916
|
-
AND lease_expires_at > clock_timestamp()`,
|
|
3917
|
-
[leaseFence.obligationId, leaseFence.owner, leaseFence.generation]
|
|
3918
|
-
);
|
|
3919
|
-
if (lease.rowCount === 1) throw new ReportingLedgerContinuityError();
|
|
3920
|
-
throw new ReportingLedgerLeaseLostError();
|
|
3921
|
-
}
|
|
3922
|
-
throw new Error("Immutable reporting ledger replay could not be resolved");
|
|
3923
|
-
}
|
|
3924
|
-
if (fingerprint(value) !== fingerprint(proposed) && !legacyReplayEquivalent?.(value, proposed)) {
|
|
3925
|
-
throw new Error("Immutable reporting ledger identity names different content");
|
|
3926
|
-
}
|
|
3927
|
-
if (inserted.rowCount === 1) await onInserted?.(client, clone(value));
|
|
3928
|
-
return { inserted: inserted.rowCount === 1, value: clone(value) };
|
|
3929
|
-
},
|
|
3930
|
-
{ preBeginAdvisoryLock: advisoryLock }
|
|
3931
|
-
);
|
|
3932
|
-
}
|
|
3933
|
-
async accountLockForObligation(obligationId) {
|
|
3934
|
-
const result = await this.query(
|
|
3935
|
-
"SELECT account_id FROM adcp_reporting_obligations WHERE obligation_id = $1",
|
|
3936
|
-
[obligationId]
|
|
3937
|
-
);
|
|
3938
|
-
const accountId = result.rows[0]?.account_id;
|
|
3939
|
-
if (!accountId) throw new Error("Reporting obligation is unavailable");
|
|
3940
|
-
return accountLock(accountId);
|
|
3941
|
-
}
|
|
3942
|
-
async accountLockForIssue(issueId) {
|
|
3943
|
-
const result = await this.query(
|
|
3944
|
-
"SELECT obligation_id FROM adcp_reporting_issues WHERE issue_id = $1",
|
|
3945
|
-
[issueId]
|
|
3946
|
-
);
|
|
3947
|
-
return result.rows[0]?.obligation_id ? this.accountLockForObligation(result.rows[0].obligation_id) : null;
|
|
3948
|
-
}
|
|
3949
|
-
async accountLockForTransition(transitionId) {
|
|
3950
|
-
const result = await this.query(
|
|
3951
|
-
"SELECT obligation_id FROM adcp_reporting_transitions WHERE transition_id = $1",
|
|
3952
|
-
[transitionId]
|
|
3953
|
-
);
|
|
3954
|
-
return result.rows[0]?.obligation_id ? this.accountLockForObligation(result.rows[0].obligation_id) : null;
|
|
3955
|
-
}
|
|
3956
|
-
async transaction(work, options = {}) {
|
|
3957
|
-
let client;
|
|
3958
|
-
try {
|
|
3959
|
-
client = await this.pool.connect();
|
|
3960
|
-
} catch (cause) {
|
|
3961
|
-
throw new Error("PostgresReportingLedgerStore database connection failed", { cause });
|
|
3962
|
-
}
|
|
3963
|
-
let releaseError;
|
|
3964
|
-
let transactionStarted = false;
|
|
3965
|
-
let advisoryLockAcquired = false;
|
|
3966
|
-
let lockTimeoutSet = false;
|
|
3967
|
-
try {
|
|
3968
|
-
if (options.preBeginAdvisoryLock) {
|
|
3969
|
-
await client.query("SET lock_timeout = '5s'");
|
|
3970
|
-
lockTimeoutSet = true;
|
|
3971
|
-
await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [options.preBeginAdvisoryLock]);
|
|
3972
|
-
advisoryLockAcquired = true;
|
|
3973
|
-
await client.query("RESET lock_timeout");
|
|
3974
|
-
lockTimeoutSet = false;
|
|
3975
|
-
}
|
|
3976
|
-
await client.query(`BEGIN ISOLATION LEVEL ${options.isolation ?? "READ COMMITTED"}`);
|
|
3977
|
-
transactionStarted = true;
|
|
3978
|
-
const result = await work(client);
|
|
3979
|
-
await client.query("COMMIT");
|
|
3980
|
-
transactionStarted = false;
|
|
3981
|
-
return result;
|
|
3982
|
-
} catch (error) {
|
|
3983
|
-
if (transactionStarted) {
|
|
3984
|
-
try {
|
|
3985
|
-
await client.query("ROLLBACK");
|
|
3986
|
-
transactionStarted = false;
|
|
3987
|
-
} catch (rollbackCause) {
|
|
3988
|
-
releaseError = rollbackCause instanceof Error ? rollbackCause : new Error("Reporting ledger rollback failed");
|
|
3989
|
-
}
|
|
3990
|
-
}
|
|
3991
|
-
if (error instanceof ReportingLedgerLeaseLostError || error instanceof ReportingLedgerContinuityError || error instanceof import_health.ReportingLedgerIssueIntegrityError || error instanceof ReportingLedgerPeriodRetiredError || error instanceof import_types.ReportingLedgerSnapshotUnavailableError || error instanceof import_types2.ReportingConsumerStatusConflictError || (0, import_row_storage.isReportingRowStoreError)(error)) {
|
|
3992
|
-
throw error;
|
|
3993
|
-
}
|
|
3994
|
-
throw new Error("PostgresReportingLedgerStore transaction failed", { cause: error });
|
|
3995
|
-
} finally {
|
|
3996
|
-
if (lockTimeoutSet) {
|
|
3997
|
-
try {
|
|
3998
|
-
await client.query("RESET lock_timeout");
|
|
3999
|
-
} catch (resetCause) {
|
|
4000
|
-
releaseError = resetCause instanceof Error ? resetCause : new Error("Reporting ledger lock timeout reset failed");
|
|
4001
|
-
}
|
|
4002
|
-
}
|
|
4003
|
-
if (options.preBeginAdvisoryLock && advisoryLockAcquired) {
|
|
4004
|
-
try {
|
|
4005
|
-
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [options.preBeginAdvisoryLock]);
|
|
4006
|
-
} catch (unlockCause) {
|
|
4007
|
-
releaseError = unlockCause instanceof Error ? unlockCause : new Error("Reporting ledger unlock failed");
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
client.release(releaseError);
|
|
4011
|
-
}
|
|
4012
|
-
}
|
|
4013
|
-
async query(sql, params) {
|
|
4014
|
-
try {
|
|
4015
|
-
return await this.pool.query(sql, params);
|
|
4016
|
-
} catch (cause) {
|
|
4017
|
-
throw new Error("PostgresReportingLedgerStore database operation failed", { cause });
|
|
4018
|
-
}
|
|
4019
|
-
}
|
|
4020
|
-
}
|
|
4021
|
-
function snapshotItems(snapshot) {
|
|
4022
|
-
const visibleRevisions = snapshot.query.finality ? snapshot.revisions.filter((value) => snapshot.query.finality.includes(value.finality)) : snapshot.revisions;
|
|
4023
|
-
const visibleRevisionIds = new Set(visibleRevisions.map((value) => value.reporting_revision_id));
|
|
4024
|
-
const revisions = /* @__PURE__ */ new Map();
|
|
4025
|
-
for (const revision of visibleRevisions) {
|
|
4026
|
-
const values = revisions.get(revision.reporting_obligation_id) ?? [];
|
|
4027
|
-
values.push(revision);
|
|
4028
|
-
revisions.set(revision.reporting_obligation_id, values);
|
|
4029
|
-
}
|
|
4030
|
-
const adjustments = /* @__PURE__ */ new Map();
|
|
4031
|
-
for (const adjustment of snapshot.adjustments) {
|
|
4032
|
-
if (snapshot.query.finality && !visibleRevisionIds.has(adjustment.adjusts_reporting_revision_id)) continue;
|
|
4033
|
-
const values = adjustments.get(adjustment.reporting_obligation_id) ?? [];
|
|
4034
|
-
values.push(adjustment);
|
|
4035
|
-
adjustments.set(adjustment.reporting_obligation_id, values);
|
|
4036
|
-
}
|
|
4037
|
-
const obligationByRevision = new Map(
|
|
4038
|
-
snapshot.revisions.map((value) => [value.reporting_revision_id, value.reporting_obligation_id])
|
|
4039
|
-
);
|
|
4040
|
-
const obligationByAdjustment = new Map(
|
|
4041
|
-
snapshot.adjustments.map((value) => [value.reporting_adjustment_id, value.reporting_obligation_id])
|
|
4042
|
-
);
|
|
4043
|
-
const materializations = /* @__PURE__ */ new Map();
|
|
4044
|
-
for (const materialization of snapshot.materializations ?? []) {
|
|
4045
|
-
if (snapshot.query.finality && !visibleRevisionIds.has(materialization.reporting_revision_id)) continue;
|
|
4046
|
-
const values = materializations.get(materialization.reporting_obligation_id) ?? [];
|
|
4047
|
-
values.push(materialization);
|
|
4048
|
-
materializations.set(materialization.reporting_obligation_id, values);
|
|
4049
|
-
}
|
|
4050
|
-
const receipts = /* @__PURE__ */ new Map();
|
|
4051
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
4052
|
-
if (snapshot.query.finality && !visibleRevisionIds.has(receipt.reporting_revision_id)) continue;
|
|
4053
|
-
const obligationId = obligationByRevision.get(receipt.reporting_revision_id);
|
|
4054
|
-
if (!obligationId) continue;
|
|
4055
|
-
const values = receipts.get(obligationId) ?? [];
|
|
4056
|
-
values.push(receipt);
|
|
4057
|
-
receipts.set(obligationId, values);
|
|
4058
|
-
}
|
|
4059
|
-
const adjustmentReceipts = /* @__PURE__ */ new Map();
|
|
4060
|
-
const visibleAdjustmentIds = new Set([...adjustments.values()].flat().map((value) => value.reporting_adjustment_id));
|
|
4061
|
-
for (const receipt of snapshot.adjustmentReceipts ?? []) {
|
|
4062
|
-
if (snapshot.query.finality && !visibleAdjustmentIds.has(receipt.reporting_adjustment_id)) continue;
|
|
4063
|
-
const obligationId = obligationByAdjustment.get(receipt.reporting_adjustment_id);
|
|
4064
|
-
if (!obligationId) continue;
|
|
4065
|
-
const values = adjustmentReceipts.get(obligationId) ?? [];
|
|
4066
|
-
values.push(receipt);
|
|
4067
|
-
adjustmentReceipts.set(obligationId, values);
|
|
4068
|
-
}
|
|
4069
|
-
return [
|
|
4070
|
-
...snapshot.obligations.flatMap((obligation) => [
|
|
4071
|
-
...snapshot.query.view === "revision" ? [] : [{ kind: "obligation", value: obligation }],
|
|
4072
|
-
...(revisions.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
|
|
4073
|
-
kind: "revision",
|
|
4074
|
-
value
|
|
4075
|
-
})),
|
|
4076
|
-
...(adjustments.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
|
|
4077
|
-
kind: "adjustment",
|
|
4078
|
-
value
|
|
4079
|
-
})),
|
|
4080
|
-
...(materializations.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
|
|
4081
|
-
kind: "materialization",
|
|
4082
|
-
value
|
|
4083
|
-
})),
|
|
4084
|
-
...(receipts.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
|
|
4085
|
-
kind: "receipt",
|
|
4086
|
-
value
|
|
4087
|
-
})),
|
|
4088
|
-
...(adjustmentReceipts.get(obligation.reporting_obligation_id) ?? []).map((value) => ({
|
|
4089
|
-
kind: "adjustment_receipt",
|
|
4090
|
-
value
|
|
4091
|
-
}))
|
|
4092
|
-
]),
|
|
4093
|
-
...snapshot.query.view === "periods" || snapshot.query.view === "revision" ? (snapshot.consumerStatuses ?? []).map((value) => ({ kind: "consumer_status", value })) : []
|
|
4094
|
-
];
|
|
4095
|
-
}
|
|
4096
|
-
function validateRevisionBinding(revision) {
|
|
4097
|
-
const bytes = Buffer.from(
|
|
4098
|
-
(0, import_jcs.canonicalize)({
|
|
4099
|
-
reporting_revision_id: revision.reporting_revision_id,
|
|
4100
|
-
row_count: revision.rows.length,
|
|
4101
|
-
control_totals: revision.wireRevision.control_totals,
|
|
4102
|
-
reporting_rows: revision.rows
|
|
4103
|
-
}),
|
|
4104
|
-
"utf8"
|
|
4105
|
-
);
|
|
4106
|
-
const sha256 = (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
|
|
4107
|
-
if (revision.binding.algorithm !== "rfc8785_jcs_v1" || revision.binding.sha256 !== sha256 || revision.binding.byteCount !== bytes.byteLength || revision.binding.rowCount !== revision.rows.length || revision.wireRevision.revision_content_sha256 !== sha256) {
|
|
4108
|
-
throw new Error("Reporting revision rows do not match their canonical binding");
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
function statusMatchesObligation(status, obligation) {
|
|
4112
|
-
return status.delivery_config_id === obligation.delivery_config_id && status.delivery_config_version === obligation.delivery_config_version && status.report_definition_id === obligation.report_definition_id && (0, import_instant.compareReportingInstants)(status.period.start, obligation.period.start) === 0 && (0, import_instant.compareReportingInstants)(status.period.end, obligation.period.end) === 0 && status.period.source_timezone === obligation.period.sourceTimezone;
|
|
4113
|
-
}
|
|
4114
|
-
function currentConsumerStatus(statuses) {
|
|
4115
|
-
const superseded = new Set(
|
|
4116
|
-
statuses.map((value) => value.supersedes_reporting_status_id).filter((value) => Boolean(value))
|
|
4117
|
-
);
|
|
4118
|
-
return statuses.find((value) => !superseded.has(value.reporting_status_id));
|
|
4119
|
-
}
|
|
4120
|
-
function validateBoundRows(value) {
|
|
4121
|
-
const bytes = Buffer.from((0, import_jcs.canonicalize)(value.rows), "utf8");
|
|
4122
|
-
const sha256 = (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
|
|
4123
|
-
if (value.binding.algorithm !== "rfc8785_jcs_v1" || value.binding.sha256 !== sha256 || value.binding.byteCount !== bytes.byteLength || value.binding.rowCount !== value.rows.length) {
|
|
4124
|
-
throw new Error("Reporting revision rows do not match their canonical binding");
|
|
4125
|
-
}
|
|
4126
|
-
}
|
|
4127
|
-
function assertLeaseTarget(reportingObligationId, lease) {
|
|
4128
|
-
if (lease.obligation.reporting_obligation_id !== reportingObligationId) {
|
|
4129
|
-
throw new ReportingLedgerLeaseLostError();
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
function encodeCursor(snapshot, offset) {
|
|
4133
|
-
const payload = { snapshotId: snapshot.snapshotId, offset, queryFingerprint: snapshot.queryFingerprint };
|
|
4134
|
-
return Buffer.from((0, import_source.canonicalJsonV1)({ ...payload, digest: digest(payload) }), "utf8").toString("base64url");
|
|
4135
|
-
}
|
|
4136
|
-
function decodeCursor(cursor, snapshot) {
|
|
4137
|
-
let parsed;
|
|
4138
|
-
try {
|
|
4139
|
-
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
4140
|
-
if (!isRecord(decoded)) throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
4141
|
-
parsed = decoded;
|
|
4142
|
-
} catch {
|
|
4143
|
-
throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
4144
|
-
}
|
|
4145
|
-
const payload = {
|
|
4146
|
-
snapshotId: parsed.snapshotId,
|
|
4147
|
-
offset: parsed.offset,
|
|
4148
|
-
queryFingerprint: parsed.queryFingerprint
|
|
4149
|
-
};
|
|
4150
|
-
if (parsed.snapshotId !== snapshot.snapshotId || parsed.queryFingerprint !== snapshot.queryFingerprint || !Number.isSafeInteger(parsed.offset) || parsed.offset < 0 || parsed.digest !== digest(payload)) {
|
|
4151
|
-
throw new import_types.ReportingLedgerSnapshotUnavailableError();
|
|
4152
|
-
}
|
|
4153
|
-
return parsed.offset;
|
|
4154
|
-
}
|
|
4155
|
-
function digest(value) {
|
|
4156
|
-
return (0, import_node_crypto.createHash)("sha256").update((0, import_source.canonicalJsonV1)(value)).digest("hex");
|
|
4157
|
-
}
|
|
4158
|
-
function isRecord(value) {
|
|
4159
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4160
|
-
}
|
|
4161
|
-
function revisionIdentityFingerprint(value) {
|
|
4162
|
-
const { rows: _rows, createdAt: _createdAt, wireRevision, ...identity } = value;
|
|
4163
|
-
const { created_at: _wireCreatedAt, ...stableWireRevision } = wireRevision;
|
|
4164
|
-
return digest(JSON.parse(JSON.stringify({ ...identity, wireRevision: stableWireRevision })));
|
|
4165
|
-
}
|
|
4166
|
-
function adjustmentIdentityFingerprint(value) {
|
|
4167
|
-
const { rows: _rows, createdAt: _createdAt, wireAdjustment, ...identity } = value;
|
|
4168
|
-
const { created_at: _wireCreatedAt, ...stableWireAdjustment } = wireAdjustment;
|
|
4169
|
-
return digest(JSON.parse(JSON.stringify({ ...identity, wireAdjustment: stableWireAdjustment })));
|
|
4170
|
-
}
|
|
4171
|
-
function revisionLegacyCanonicalDigestReplay(stored, proposed) {
|
|
4172
|
-
if (stored.wireRevision.canonical_content_digest !== void 0) return false;
|
|
4173
|
-
if (proposed.wireRevision.canonical_content_digest === void 0) return false;
|
|
4174
|
-
const { canonical_content_digest: _digest, ...withoutDigest } = proposed.wireRevision;
|
|
4175
|
-
return revisionIdentityFingerprint({
|
|
4176
|
-
...proposed,
|
|
4177
|
-
wireRevision: withoutDigest
|
|
4178
|
-
}) === revisionIdentityFingerprint(stored);
|
|
4179
|
-
}
|
|
4180
|
-
function adjustmentLegacyCanonicalDigestReplay(stored, proposed) {
|
|
4181
|
-
if (stored.wireAdjustment.canonical_adjustment_sha256 !== void 0) return false;
|
|
4182
|
-
const proposedDigest = proposed.wireAdjustment.canonical_adjustment_sha256;
|
|
4183
|
-
if (proposedDigest === void 0) return false;
|
|
4184
|
-
const { canonical_adjustment_sha256: _digest, ...withoutDigest } = proposed.wireAdjustment;
|
|
4185
|
-
if ((0, import_producer.reportingCanonicalAdjustmentSha256V1)(withoutDigest) !== proposedDigest) return false;
|
|
4186
|
-
return adjustmentIdentityFingerprint({
|
|
4187
|
-
...proposed,
|
|
4188
|
-
wireAdjustment: withoutDigest
|
|
4189
|
-
}) === adjustmentIdentityFingerprint(stored);
|
|
4190
|
-
}
|
|
4191
|
-
const OBLIGATED_CONSUMERS_DEADLINE_MS = 1e4;
|
|
4192
|
-
async function withReportingCallbackDeadline(value, milliseconds, message) {
|
|
4193
|
-
let timer;
|
|
4194
|
-
try {
|
|
4195
|
-
return await Promise.race([
|
|
4196
|
-
value,
|
|
4197
|
-
new Promise((_resolve, reject) => {
|
|
4198
|
-
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
4199
|
-
})
|
|
4200
|
-
]);
|
|
4201
|
-
} finally {
|
|
4202
|
-
if (timer) clearTimeout(timer);
|
|
4203
|
-
}
|
|
4204
|
-
}
|
|
4205
|
-
function pendingAtCutoff(value) {
|
|
4206
|
-
const {
|
|
4207
|
-
ready_at: _readyAt,
|
|
4208
|
-
resource: _resource,
|
|
4209
|
-
verification: _verification,
|
|
4210
|
-
failed_at: _failedAt,
|
|
4211
|
-
failure_code: _failureCode,
|
|
4212
|
-
...pending
|
|
4213
|
-
} = clone(value);
|
|
4214
|
-
return { ...pending, status: "pending" };
|
|
4215
|
-
}
|
|
4216
|
-
const MANAGED_DUE_ARM = ` OR (EXISTS (
|
|
4217
|
-
SELECT 1 FROM adcp_reporting_managed_bindings binding
|
|
4218
|
-
WHERE binding.configuration_id = obligation.configuration_id
|
|
4219
|
-
AND (
|
|
4220
|
-
-- A managed change after the last transition. Health is
|
|
4221
|
-
-- composed from managed state, but only Core evidence
|
|
4222
|
-
-- and the clock used to schedule a reconcile \u2014 so a
|
|
4223
|
-
-- settlement, revocation, receipt, consumer status or
|
|
4224
|
-
-- roster-visible change arriving after a complete left
|
|
4225
|
-
-- that complete persisted and webhooked forever while
|
|
4226
|
-
-- a live read of the same obligation degraded.
|
|
4227
|
-
EXISTS (
|
|
4228
|
-
SELECT 1 FROM adcp_reporting_materializations m
|
|
4229
|
-
WHERE m.obligation_id = obligation.obligation_id
|
|
4230
|
-
AND m.changed_at > watermark.since
|
|
4231
|
-
AND m.changed_at <= $2)
|
|
4232
|
-
OR EXISTS (
|
|
4233
|
-
-- Adjustments are managed health inputs too: after an
|
|
4234
|
-
-- accepted billing receipt, a correction makes the live
|
|
4235
|
-
-- read action_required while the persisted health and
|
|
4236
|
-
-- its webhook stayed complete.
|
|
4237
|
-
SELECT 1 FROM adcp_reporting_adjustments adj
|
|
4238
|
-
WHERE adj.obligation_id = obligation.obligation_id
|
|
4239
|
-
AND adj.recorded_at > watermark.since
|
|
4240
|
-
AND adj.recorded_at <= $2)
|
|
4241
|
-
OR EXISTS (
|
|
4242
|
-
SELECT 1 FROM adcp_reporting_destination_authorizations authz
|
|
4243
|
-
WHERE authz.account_id = binding.account_id
|
|
4244
|
-
AND authz.destination_ref = binding.destination_ref
|
|
4245
|
-
AND GREATEST(authz.changed_at, COALESCE(authz.cleanup_completed_at, authz.changed_at))
|
|
4246
|
-
> watermark.since
|
|
4247
|
-
AND authz.changed_at <= $2)
|
|
4248
|
-
-- Driven from this obligation's subjects into the
|
|
4249
|
-
-- receipt table's subject index. Asking the global
|
|
4250
|
-
-- receipt table which of its rows belong to this
|
|
4251
|
-
-- obligation supplied neither account nor consumer, so
|
|
4252
|
-
-- every candidate obligation in every sweep re-scanned
|
|
4253
|
-
-- the whole receipt history.
|
|
4254
|
-
OR EXISTS (
|
|
4255
|
-
SELECT 1 FROM adcp_reporting_revisions rv
|
|
4256
|
-
JOIN adcp_reporting_receipts receipt
|
|
4257
|
-
ON receipt.receipt_kind = 'revision'
|
|
4258
|
-
AND receipt.subject_id = rv.revision_id
|
|
4259
|
-
WHERE rv.obligation_id = obligation.obligation_id
|
|
4260
|
-
AND receipt.recorded_at > watermark.since
|
|
4261
|
-
AND receipt.recorded_at <= $2)
|
|
4262
|
-
OR EXISTS (
|
|
4263
|
-
SELECT 1 FROM adcp_reporting_adjustments aj
|
|
4264
|
-
JOIN adcp_reporting_receipts receipt
|
|
4265
|
-
ON receipt.receipt_kind = 'adjustment'
|
|
4266
|
-
AND receipt.subject_id = aj.adjustment_id
|
|
4267
|
-
WHERE aj.obligation_id = obligation.obligation_id
|
|
4268
|
-
AND receipt.recorded_at > watermark.since
|
|
4269
|
-
AND receipt.recorded_at <= $2)
|
|
4270
|
-
OR EXISTS (
|
|
4271
|
-
SELECT 1 FROM adcp_reporting_consumer_statuses status
|
|
4272
|
-
WHERE status.obligation_id = obligation.obligation_id
|
|
4273
|
-
AND status.created_at > watermark.since
|
|
4274
|
-
AND status.created_at <= $2)
|
|
4275
|
-
-- An external roster change writes nothing here at all,
|
|
4276
|
-
-- so the only durable trace is the version recorded at
|
|
4277
|
-
-- the last reconcile. A null one means never reconciled.
|
|
4278
|
-
OR state.processed_roster_version IS NULL
|
|
4279
|
-
-- A roster lives outside this database, so the only way
|
|
4280
|
-
-- a change can re-arm anything is if someone records
|
|
4281
|
-
-- it. Every read of the roster publishes the version it
|
|
4282
|
-
-- saw; a difference from what was last reconciled is a
|
|
4283
|
-
-- due condition. Without this the first reconcile made
|
|
4284
|
-
-- processed_roster_version non-null and no later roster
|
|
4285
|
-
-- change could ever schedule work again.
|
|
4286
|
-
OR state.current_roster_version IS DISTINCT FROM state.processed_roster_version
|
|
4287
|
-
-- A retained resource expiring is a health change with
|
|
4288
|
-
-- no row written anywhere, so nothing else can notice it.
|
|
4289
|
-
OR EXISTS (
|
|
4290
|
-
SELECT 1 FROM adcp_reporting_materializations m
|
|
4291
|
-
WHERE m.obligation_id = obligation.obligation_id
|
|
4292
|
-
AND m.status IN ('available', 'delivered')
|
|
4293
|
-
AND (m.data -> 'resource' ->> 'expires_at')::timestamptz <= $2
|
|
4294
|
-
AND (m.data -> 'resource' ->> 'expires_at')::timestamptz
|
|
4295
|
-
> watermark.since)
|
|
4296
|
-
)
|
|
4297
|
-
))
|
|
4298
|
-
`;
|
|
4299
|
-
function obligatedConsumerIdsFor(supplied, observed) {
|
|
4300
|
-
return supplied.complete === true ? [...new Set(supplied.ids)].sort() : [.../* @__PURE__ */ new Set([...supplied.ids, ...observed])].sort();
|
|
4301
|
-
}
|
|
4302
|
-
function obligatedConsumerRosterVersionFor(supplied, ids) {
|
|
4303
|
-
return supplied.version ?? digest({ ids, complete: supplied.complete === true });
|
|
4304
|
-
}
|
|
4305
|
-
const MAX_ROSTER_REFRESH_PER_SWEEP = 25;
|
|
4306
|
-
const MAX_LIFECYCLE_BACKOFF_SECONDS = 900;
|
|
4307
|
-
function rfc3339Microseconds(expression) {
|
|
4308
|
-
return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
|
|
4309
|
-
}
|
|
4310
|
-
function accountLock(accountId) {
|
|
4311
|
-
return `adcp-reporting-account:${accountId}`;
|
|
4312
|
-
}
|
|
4313
|
-
function objectLock(bindingId, objectRef) {
|
|
4314
|
-
return `adcp-reporting-object:${bindingId}:${objectRef}`;
|
|
4315
|
-
}
|
|
4316
|
-
async function resolveStoredFinalityBaseline(transaction, obligationId) {
|
|
4317
|
-
const latest = await transaction.query(
|
|
4318
|
-
`SELECT transition_id, data->>'finality' AS finality
|
|
4319
|
-
FROM adcp_reporting_transitions
|
|
4320
|
-
WHERE obligation_id = $1 ORDER BY transition_sequence DESC LIMIT 1
|
|
4321
|
-
FOR UPDATE`,
|
|
4322
|
-
[obligationId]
|
|
4323
|
-
);
|
|
4324
|
-
const previous = latest.rows[0];
|
|
4325
|
-
if (!previous) return "none";
|
|
4326
|
-
if (previous.finality) return previous.finality;
|
|
4327
|
-
await transaction.query(
|
|
4328
|
-
`UPDATE adcp_reporting_transitions
|
|
4329
|
-
SET data = data || jsonb_build_object('finality', 'none')
|
|
4330
|
-
WHERE transition_id = $1 AND NOT (data ? 'finality')`,
|
|
4331
|
-
[previous.transition_id]
|
|
4332
|
-
);
|
|
4333
|
-
return "none";
|
|
4334
|
-
}
|
|
4335
|
-
async function assertNoLegacyPendingTransitions(transaction, obligationId) {
|
|
4336
|
-
const pending = await transaction.query(
|
|
4337
|
-
`SELECT transition_id FROM adcp_reporting_transitions
|
|
4338
|
-
WHERE obligation_id = $1 AND data->>'notifiedAt' IS NULL
|
|
4339
|
-
LIMIT 1`,
|
|
4340
|
-
[obligationId]
|
|
4341
|
-
);
|
|
4342
|
-
if (pending.rowCount !== 0) {
|
|
4343
|
-
throw new Error(
|
|
4344
|
-
"Drain or explicitly resolve legacy pending reporting transitions before enabling transactional notification activity"
|
|
4345
|
-
);
|
|
4346
|
-
}
|
|
4347
|
-
}
|
|
4348
|
-
async function assertFinalityWriterFence(work) {
|
|
4349
|
-
try {
|
|
4350
|
-
return await work();
|
|
4351
|
-
} catch (error) {
|
|
4352
|
-
if (isFinalityWriterFenceViolation(error)) {
|
|
4353
|
-
throw new Error(
|
|
4354
|
-
"Reporting transition rejected by the finality writer fence: every transition written after REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION must record an observed finality. Drain pre-SDK-14 writers before installing the fence.",
|
|
4355
|
-
{ cause: error }
|
|
4356
|
-
);
|
|
4357
|
-
}
|
|
4358
|
-
throw error;
|
|
4359
|
-
}
|
|
4360
|
-
}
|
|
4361
|
-
function isFinalityWriterFenceViolation(error) {
|
|
4362
|
-
for (let current = error; current instanceof Error; current = current.cause) {
|
|
4363
|
-
const candidate = current;
|
|
4364
|
-
if (candidate.code === "23514" && candidate.constraint === REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT) return true;
|
|
4365
|
-
}
|
|
4366
|
-
return false;
|
|
4367
|
-
}
|
|
4368
|
-
function consumerStatusChainKeyForObligation(obligation) {
|
|
4369
|
-
return (0, import_consumer_status_identity.reportingConsumerStatusChainKeyFromIdentityV1)({
|
|
4370
|
-
delivery_config_id: obligation.delivery_config_id,
|
|
4371
|
-
delivery_config_version: obligation.delivery_config_version,
|
|
4372
|
-
report_definition_id: obligation.report_definition_id,
|
|
4373
|
-
periodStart: obligation.period.start,
|
|
4374
|
-
periodEnd: obligation.period.end,
|
|
4375
|
-
sourceTimezone: obligation.period.sourceTimezone
|
|
4376
|
-
});
|
|
4377
|
-
}
|
|
4378
|
-
function boundedConsumerStatusErrorField(errorField) {
|
|
4379
|
-
return errorField && !errorField.includes("\0") && (0, import_well_formed_unicode.isWellFormedUnicodeString)(errorField) && Buffer.byteLength(errorField, "utf8") <= import_types.REPORTING_CONSUMER_STATUS_ERROR_FIELD_MAX_BYTES ? { errorField } : {};
|
|
4380
|
-
}
|
|
4381
|
-
function boundedConsumerStatusErrorKeyword(errorKeyword) {
|
|
4382
|
-
return errorKeyword && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(errorKeyword) ? { errorKeyword } : {};
|
|
4383
|
-
}
|
|
4384
|
-
function boundedConsumerStatusSafeMessage(safeMessage) {
|
|
4385
|
-
const wellFormed = Buffer.from(safeMessage, "utf8").toString("utf8").replace(/\u0000/g, "\uFFFD");
|
|
4386
|
-
if (Buffer.byteLength(wellFormed, "utf8") <= import_types.REPORTING_CONSUMER_STATUS_ERROR_MESSAGE_MAX_BYTES) {
|
|
4387
|
-
return wellFormed || "Reporting consumer status was rejected";
|
|
4388
|
-
}
|
|
4389
|
-
let bounded = "";
|
|
4390
|
-
let bytes = 0;
|
|
4391
|
-
for (const character of wellFormed) {
|
|
4392
|
-
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
4393
|
-
if (bytes + characterBytes > import_types.REPORTING_CONSUMER_STATUS_ERROR_MESSAGE_MAX_BYTES) break;
|
|
4394
|
-
bounded += character;
|
|
4395
|
-
bytes += characterBytes;
|
|
4396
|
-
}
|
|
4397
|
-
return bounded || "Reporting consumer status was rejected";
|
|
4398
|
-
}
|
|
4399
|
-
function storedResultsJsonBytes(results) {
|
|
4400
|
-
return Buffer.byteLength(JSON.stringify(results), "utf8");
|
|
4401
|
-
}
|
|
4402
|
-
function boundStoredConsumerStatusResults(storedResults, results) {
|
|
4403
|
-
if (storedResultsJsonBytes(storedResults) <= import_types.REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
|
|
4404
|
-
for (let index = storedResults.length - 1; index >= 0; index -= 1) {
|
|
4405
|
-
const stored = storedResults[index];
|
|
4406
|
-
const result = results[index];
|
|
4407
|
-
if (stored?.errorField !== void 0) {
|
|
4408
|
-
delete stored.errorField;
|
|
4409
|
-
if (result && !("value" in result)) delete result.errorField;
|
|
4410
|
-
if (storedResultsJsonBytes(storedResults) <= import_types.REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
|
|
4411
|
-
}
|
|
4412
|
-
}
|
|
4413
|
-
const fallback = "Reporting consumer status was rejected";
|
|
4414
|
-
for (let index = storedResults.length - 1; index >= 0; index -= 1) {
|
|
4415
|
-
const stored = storedResults[index];
|
|
4416
|
-
const result = results[index];
|
|
4417
|
-
if (stored?.kind === "failed" && stored.safeMessage !== fallback) {
|
|
4418
|
-
stored.safeMessage = fallback;
|
|
4419
|
-
if (result && !("value" in result)) result.safeMessage = fallback;
|
|
4420
|
-
if (storedResultsJsonBytes(storedResults) <= import_types.REPORTING_CONSUMER_STATUS_BATCH_RESULT_MAX_BYTES) return;
|
|
4421
|
-
}
|
|
4422
|
-
}
|
|
4423
|
-
throw new RangeError("Reporting consumer status replay metadata exceeds 64 KiB");
|
|
4424
|
-
}
|
|
4425
|
-
function checkpointScopeFingerprint(query) {
|
|
4426
|
-
const sorted = (value) => [...value].sort();
|
|
4427
|
-
return digest({
|
|
4428
|
-
account_id: query.account_id,
|
|
4429
|
-
...query.consumer_id ? { consumer_id: query.consumer_id } : {},
|
|
4430
|
-
...query.media_buy_ids ? { media_buy_ids: sorted(query.media_buy_ids) } : {},
|
|
4431
|
-
...query.delivery_config_ids ? { delivery_config_ids: sorted(query.delivery_config_ids) } : {},
|
|
4432
|
-
...query.feed_purposes ? { feed_purposes: sorted(query.feed_purposes) } : {},
|
|
4433
|
-
...query.health ? { health: sorted(query.health) } : {},
|
|
4434
|
-
...query.finality ? { finality: sorted(query.finality) } : {},
|
|
4435
|
-
...query.reporting_revision_id ? { reporting_revision_id: query.reporting_revision_id } : {},
|
|
4436
|
-
...query.period ? { period: query.period } : {}
|
|
4437
|
-
});
|
|
4438
|
-
}
|
|
4439
|
-
function clone(value) {
|
|
4440
|
-
return structuredClone(value);
|
|
4441
|
-
}
|
|
4442
|
-
function positiveInteger(value, name) {
|
|
4443
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
4444
|
-
}
|
|
4445
|
-
function withoutRows(value) {
|
|
4446
|
-
const { rows: _rows, ...rest } = value;
|
|
4447
|
-
return rest;
|
|
4448
|
-
}
|
|
4449
|
-
function withRows(value, rows) {
|
|
4450
|
-
return Array.isArray(value.rows) ? value : { ...value, rows: clone(rows) };
|
|
4451
|
-
}
|
|
4452
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
4453
|
-
0 && (module.exports = {
|
|
4454
|
-
PostgresReportingLedgerStore,
|
|
4455
|
-
REPORTING_LEDGER_FINALITY_FENCE_CONSTRAINT,
|
|
4456
|
-
REPORTING_LEDGER_FINALITY_WRITER_FENCE_MIGRATION,
|
|
4457
|
-
REPORTING_LEDGER_MIGRATION,
|
|
4458
|
-
ReportingLedgerContinuityError,
|
|
4459
|
-
ReportingLedgerLeaseLostError,
|
|
4460
|
-
ReportingLedgerPeriodRetiredError,
|
|
4461
|
-
sweepExpiredReportingLedgerState
|
|
4462
|
-
});
|