@sixb/pg 0.1.16 → 0.1.18
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/README.md +125 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3201 -1473
- package/dist/index.js.map +32 -25
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/aggregate-admission.d.ts +10 -0
- package/dist/objects/aggregate-admission.d.ts.map +1 -0
- package/dist/objects/preparation-lock.d.ts +6 -0
- package/dist/objects/preparation-lock.d.ts.map +1 -0
- package/dist/objects/prepare-queries.d.ts +5 -0
- package/dist/objects/prepare-queries.d.ts.map +1 -0
- package/dist/objects/query-compiler.d.ts +19 -0
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/query-indexes.d.ts +43 -0
- package/dist/objects/query-indexes.d.ts.map +1 -0
- package/dist/objects/reader.d.ts +7 -1
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +1 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/objects/text-count-index.d.ts +24 -0
- package/dist/objects/text-count-index.d.ts.map +1 -0
- package/dist/ontology-storage/index.d.ts +2 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-session.d.ts +18 -38
- package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-state.d.ts +3 -26
- package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts +51 -9
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts +5 -7
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/outbox.d.ts.map +1 -1
- package/dist/ontology-storage/replacement-plans.d.ts +36 -0
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
- package/dist/ontology-storage/shared.d.ts +11 -5
- package/dist/ontology-storage/shared.d.ts.map +1 -1
- package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
- package/dist/ontology-storage/source-roots.d.ts +14 -1
- package/dist/ontology-storage/source-roots.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +1 -2
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-limit-storage.d.ts.map +1 -1
- package/dist/pg-ai-usage-storage.d.ts +2 -0
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/dist/pg-execution-storage.d.ts +28 -0
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/pg-file-download-grant-storage.d.ts +11 -0
- package/dist/pg-file-download-grant-storage.d.ts.map +1 -0
- package/dist/pg-projection-run-storage.d.ts.map +1 -1
- package/dist/pg-rules-storage.d.ts.map +1 -1
- package/dist/transactions.d.ts +6 -2
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +53 -3
- package/src/migrations/053-replacement-plans.sql +72 -0
- package/src/migrations/054-slim-ontology-outbox.sql +108 -0
- package/src/migrations/055-object-query-preparation.sql +11 -0
- package/src/migrations/056-commit-touches.sql +41 -0
- package/src/migrations/057-audio-transcription.sql +10 -0
- package/src/migrations/058-reranking-model-kind.sql +11 -0
- package/src/migrations/059-file-download-grants.sql +16 -0
- package/src/migrations/060-drained-source-versions.sql +11 -0
- package/src/migrations.ts +20 -0
- package/src/objects/aggregate-admission.ts +21 -0
- package/src/objects/preparation-lock.ts +15 -0
- package/src/objects/prepare-queries.ts +181 -0
- package/src/objects/query-compiler.ts +404 -114
- package/src/objects/query-indexes.ts +348 -0
- package/src/objects/reader.ts +140 -51
- package/src/objects/storage.ts +15 -3
- package/src/objects/text-count-index.ts +455 -0
- package/src/ontology-storage/index.ts +3 -0
- package/src/ontology-storage/materialization-session.ts +116 -275
- package/src/ontology-storage/materialization-state.ts +8 -259
- package/src/ontology-storage/materialization-writer.ts +618 -508
- package/src/ontology-storage/materializations.ts +106 -289
- package/src/ontology-storage/outbox.ts +46 -15
- package/src/ontology-storage/replacement-plans.ts +1110 -0
- package/src/ontology-storage/shared.ts +27 -15
- package/src/ontology-storage/source-cleanup.ts +22 -10
- package/src/ontology-storage/source-roots.ts +35 -8
- package/src/ontology-storage/sources.ts +0 -30
- package/src/pg-ai-cost-storage.ts +12 -0
- package/src/pg-ai-limit-storage.ts +13 -2
- package/src/pg-ai-usage-storage.ts +8 -0
- package/src/pg-execution-storage.ts +8 -3
- package/src/pg-file-download-grant-storage.ts +76 -0
- package/src/pg-projection-run-storage.ts +6 -1
- package/src/pg-rules-storage.ts +7 -3
- package/src/transactions.ts +10 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
-- An outbox event stores what changed and nothing it can be rebuilt from: delivery rebuilds the
|
|
2
|
+
-- event from its commit and the execution that commit ran under. The commit also stops copying
|
|
3
|
+
-- `requested_by` and `executor` from that execution (047): the execution is canonical, immutable,
|
|
4
|
+
-- and already referenced by the commit.
|
|
5
|
+
|
|
6
|
+
-- Every stored event must rebuild exactly as it was stored: consumers would otherwise receive a
|
|
7
|
+
-- different event than the one committed. Fail rather than convert one that would not. Who asked
|
|
8
|
+
-- and what wrote are derived from the execution exactly as 047 derived the commit's copies.
|
|
9
|
+
DO $$
|
|
10
|
+
DECLARE
|
|
11
|
+
mismatched BIGINT;
|
|
12
|
+
BEGIN
|
|
13
|
+
SELECT COUNT(*) INTO mismatched
|
|
14
|
+
FROM ontology_outbox AS outbox
|
|
15
|
+
LEFT JOIN ontology_commits AS commits
|
|
16
|
+
ON commits.project_id = outbox.project_id AND commits.id = outbox.commit_id
|
|
17
|
+
LEFT JOIN executions
|
|
18
|
+
ON executions.project_id = commits.project_id AND executions.id = commits.execution_id
|
|
19
|
+
CROSS JOIN LATERAL (
|
|
20
|
+
SELECT outbox.envelope->>'type' AS type, outbox.envelope->'payload' AS payload
|
|
21
|
+
) AS event
|
|
22
|
+
CROSS JOIN LATERAL (
|
|
23
|
+
SELECT
|
|
24
|
+
CASE
|
|
25
|
+
WHEN executions.requested_by_user_id IS NOT NULL
|
|
26
|
+
THEN jsonb_build_object('type', 'user', 'id', executions.requested_by_user_id)
|
|
27
|
+
WHEN executions.requested_by_service_account_id IS NOT NULL
|
|
28
|
+
THEN jsonb_build_object(
|
|
29
|
+
'type', 'serviceAccount', 'id', executions.requested_by_service_account_id
|
|
30
|
+
)
|
|
31
|
+
END AS requested_by,
|
|
32
|
+
CASE executions.executor_kind
|
|
33
|
+
WHEN 'request'
|
|
34
|
+
THEN jsonb_build_object('type', 'request', 'requestId', executions.executor_id)
|
|
35
|
+
WHEN 'agent' THEN jsonb_build_object('type', 'agent', 'runId', executions.executor_id)
|
|
36
|
+
WHEN 'kernel' THEN jsonb_build_object(
|
|
37
|
+
'type', 'kernel',
|
|
38
|
+
'operation',
|
|
39
|
+
CASE executions.authority_kernel_operation
|
|
40
|
+
WHEN 'ontology.recover'
|
|
41
|
+
THEN jsonb_build_object('type', 'ontology.recover', 'recoveryId', executions.executor_id)
|
|
42
|
+
ELSE jsonb_build_object(
|
|
43
|
+
'type', 'ontology.indexVectors', 'indexingId', executions.executor_id
|
|
44
|
+
)
|
|
45
|
+
END
|
|
46
|
+
)
|
|
47
|
+
ELSE jsonb_build_object(
|
|
48
|
+
'type', 'primitive',
|
|
49
|
+
'kind', executions.executor_kind,
|
|
50
|
+
'id', executions.authority_primitive_id,
|
|
51
|
+
'runId', executions.executor_id
|
|
52
|
+
)
|
|
53
|
+
END AS executor
|
|
54
|
+
) AS attribution
|
|
55
|
+
WHERE executions.id IS NULL
|
|
56
|
+
OR outbox.envelope->>'id' IS DISTINCT FROM outbox.id
|
|
57
|
+
OR (outbox.envelope->>'commitOrdinal')::bigint IS DISTINCT FROM outbox.commit_ordinal
|
|
58
|
+
OR outbox.envelope->>'commitId' IS DISTINCT FROM commits.id
|
|
59
|
+
OR outbox.envelope->>'projectId' IS DISTINCT FROM commits.project_id
|
|
60
|
+
OR outbox.envelope->'schemaVersion' IS DISTINCT FROM '1'::jsonb
|
|
61
|
+
OR (outbox.envelope->>'occurredAt')::timestamptz IS DISTINCT FROM commits.committed_at
|
|
62
|
+
OR outbox.envelope->'origin' IS DISTINCT FROM commits.origin
|
|
63
|
+
OR outbox.envelope->>'correlationId' IS DISTINCT FROM executions.correlation_id
|
|
64
|
+
OR outbox.envelope->'requestedBy' IS DISTINCT FROM attribution.requested_by
|
|
65
|
+
OR outbox.envelope->'executor' IS DISTINCT FROM attribution.executor
|
|
66
|
+
OR outbox.envelope->>'topic' IS DISTINCT FROM CASE split_part(event.type, '.', 1)
|
|
67
|
+
WHEN 'object' THEN 'objects'
|
|
68
|
+
WHEN 'link' THEN 'links'
|
|
69
|
+
WHEN 'telemetry' THEN 'telemetry'
|
|
70
|
+
END
|
|
71
|
+
OR outbox.envelope->>'partitionKey' IS DISTINCT FROM CASE split_part(event.type, '.', 1)
|
|
72
|
+
WHEN 'object' THEN concat_ws(':', event.payload->>'objectTypeId', event.payload->>'primaryId')
|
|
73
|
+
WHEN 'link' THEN concat_ws(
|
|
74
|
+
':', event.payload->>'sourceTypeId', event.payload->>'sourceId', event.payload->>'linkId'
|
|
75
|
+
)
|
|
76
|
+
WHEN 'telemetry' THEN concat_ws(
|
|
77
|
+
':', event.payload->>'objectTypeId', event.payload->>'objectId', event.payload->>'propertyId'
|
|
78
|
+
)
|
|
79
|
+
END
|
|
80
|
+
OR (
|
|
81
|
+
event.type IN ('object.created', 'link.created')
|
|
82
|
+
AND event.payload->'propertyChanges' IS DISTINCT FROM COALESCE(
|
|
83
|
+
(
|
|
84
|
+
SELECT jsonb_object_agg(
|
|
85
|
+
property.key, jsonb_build_object('operation', 'created', 'after', property.value)
|
|
86
|
+
)
|
|
87
|
+
FROM jsonb_each(COALESCE(event.payload->'properties', '{}'::jsonb)) AS property
|
|
88
|
+
),
|
|
89
|
+
'{}'::jsonb
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
IF mismatched > 0 THEN
|
|
93
|
+
RAISE EXCEPTION '[SixbPg] % outbox events would not rebuild from their commit.', mismatched;
|
|
94
|
+
END IF;
|
|
95
|
+
END $$;
|
|
96
|
+
|
|
97
|
+
ALTER TABLE ontology_outbox ADD COLUMN event JSONB;
|
|
98
|
+
UPDATE ontology_outbox SET event = jsonb_build_object(
|
|
99
|
+
'type', envelope->'type',
|
|
100
|
+
'payload', CASE
|
|
101
|
+
WHEN envelope->>'type' IN ('object.created', 'link.created')
|
|
102
|
+
THEN (envelope->'payload') - 'propertyChanges'
|
|
103
|
+
ELSE envelope->'payload'
|
|
104
|
+
END
|
|
105
|
+
);
|
|
106
|
+
ALTER TABLE ontology_outbox ALTER COLUMN event SET NOT NULL, DROP COLUMN envelope;
|
|
107
|
+
|
|
108
|
+
ALTER TABLE ontology_commits DROP COLUMN requested_by, DROP COLUMN executor;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Object predicates use typed property expressions, not containment on the whole JSON document.
|
|
2
|
+
-- The whole-document index accelerates none of the compiler paths and amplifies every write.
|
|
3
|
+
DROP INDEX IF EXISTS idx_objects_properties;
|
|
4
|
+
|
|
5
|
+
-- Completion is recorded only after all non-transactional index builds and generated-column backfills finish.
|
|
6
|
+
CREATE TABLE object_query_preparation (
|
|
7
|
+
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
8
|
+
fingerprint TEXT NOT NULL,
|
|
9
|
+
result JSONB NOT NULL,
|
|
10
|
+
prepared_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
11
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- Every commit records the entities whose plan inputs it changed, so a replacement plan's refresh
|
|
2
|
+
-- rechecks only what the commits since it was last fresh touched, instead of the whole plan.
|
|
3
|
+
-- UNLOGGED like the plans they serve: a crash truncates both together.
|
|
4
|
+
CREATE UNLOGGED TABLE ontology_commit_touches (
|
|
5
|
+
project_id TEXT NOT NULL,
|
|
6
|
+
-- The transaction of the commit, compared with a plan's watermark snapshot like `xact_id` on
|
|
7
|
+
-- `ontology_commits`.
|
|
8
|
+
xact_id xid8 NOT NULL DEFAULT pg_current_xact_id(),
|
|
9
|
+
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('object', 'link', 'scope')),
|
|
10
|
+
-- `objectRefKey`, `linkRefKey` or `linkScopeKey`.
|
|
11
|
+
identity_key TEXT COLLATE "C" NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX idx_ontology_commit_touches_xact ON ontology_commit_touches (project_id, xact_id);
|
|
14
|
+
|
|
15
|
+
-- Touches of the project's commits before this transaction may have been purged: a plan whose
|
|
16
|
+
-- watermark does not see them all checks every identity.
|
|
17
|
+
CREATE UNLOGGED TABLE ontology_commit_touch_horizons (
|
|
18
|
+
project_id TEXT PRIMARY KEY,
|
|
19
|
+
xact_id xid8 NOT NULL
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- Existing plans predate the touches of the commits since their watermark: they plan again.
|
|
23
|
+
TRUNCATE ontology_replacement_plan_work, ontology_replacement_plan_identities,
|
|
24
|
+
ontology_replacement_plans;
|
|
25
|
+
|
|
26
|
+
-- Link expansion starts only from what changed since it last ran: `expansions` counts the runs,
|
|
27
|
+
-- and an identity's `expand_at` is the run that has yet to start from it (an object once planned,
|
|
28
|
+
-- a link once it needs a diff). `source_key` and `target_key` find a link from either endpoint.
|
|
29
|
+
ALTER TABLE ontology_replacement_plans ADD COLUMN expansions INTEGER NOT NULL DEFAULT 0;
|
|
30
|
+
ALTER TABLE ontology_replacement_plan_identities
|
|
31
|
+
ADD COLUMN expand_at INTEGER,
|
|
32
|
+
ADD COLUMN source_key TEXT COLLATE "C",
|
|
33
|
+
ADD COLUMN target_key TEXT COLLATE "C";
|
|
34
|
+
CREATE INDEX idx_ontology_replacement_plan_identities_expand
|
|
35
|
+
ON ontology_replacement_plan_identities (version_id, entity_kind, expand_at);
|
|
36
|
+
CREATE INDEX idx_ontology_replacement_plan_identities_source
|
|
37
|
+
ON ontology_replacement_plan_identities (version_id, source_key)
|
|
38
|
+
WHERE source_key IS NOT NULL;
|
|
39
|
+
CREATE INDEX idx_ontology_replacement_plan_identities_target
|
|
40
|
+
ON ontology_replacement_plan_identities (version_id, target_key)
|
|
41
|
+
WHERE target_key IS NOT NULL;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ALTER TABLE ai_model_call_usage
|
|
2
|
+
ADD COLUMN audio_duration_ms BIGINT
|
|
3
|
+
CHECK (audio_duration_ms IS NULL OR audio_duration_ms >= 0);
|
|
4
|
+
|
|
5
|
+
ALTER TABLE ai_model_call_usage
|
|
6
|
+
ADD COLUMN model_kind TEXT
|
|
7
|
+
CHECK (
|
|
8
|
+
model_kind IS NULL
|
|
9
|
+
OR model_kind IN ('language', 'image', 'video', 'embedding', 'decision', 'transcription')
|
|
10
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ALTER TABLE ai_model_call_usage
|
|
2
|
+
DROP CONSTRAINT ai_model_call_usage_model_kind_check;
|
|
3
|
+
|
|
4
|
+
ALTER TABLE ai_model_call_usage
|
|
5
|
+
ADD CONSTRAINT ai_model_call_usage_model_kind_check
|
|
6
|
+
CHECK (
|
|
7
|
+
model_kind IS NULL
|
|
8
|
+
OR model_kind IN (
|
|
9
|
+
'language', 'image', 'video', 'embedding', 'decision', 'transcription', 'reranking'
|
|
10
|
+
)
|
|
11
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- URLs issued by blobs.createDownloadUrl(). Only the SHA-256 of each URL's token is stored.
|
|
2
|
+
CREATE TABLE file_download_grants (
|
|
3
|
+
project_id TEXT NOT NULL CHECK (length(trim(project_id)) > 0),
|
|
4
|
+
id TEXT NOT NULL CHECK (length(trim(id)) > 0),
|
|
5
|
+
token_hash TEXT NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
|
6
|
+
file JSONB NOT NULL CHECK (jsonb_typeof(file) = 'object'),
|
|
7
|
+
execution_id TEXT NOT NULL CHECK (length(trim(execution_id)) > 0),
|
|
8
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
9
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
10
|
+
revoked_at TIMESTAMPTZ,
|
|
11
|
+
PRIMARY KEY (project_id, id),
|
|
12
|
+
UNIQUE (project_id, token_hash),
|
|
13
|
+
CHECK (expires_at > created_at)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE INDEX idx_file_download_grants_expiry ON file_download_grants (project_id, expires_at);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- A superseded version's `root_count` counts the roots it still holds: cleanup lowers it as it
|
|
2
|
+
-- deletes them. A version that reaches zero is found through this index, instead of every
|
|
3
|
+
-- superseded version being walked and probed for roots on every maintenance pass.
|
|
4
|
+
UPDATE ontology_sources AS versions
|
|
5
|
+
SET root_count = (SELECT count(*) FROM ontology_source_roots AS roots
|
|
6
|
+
WHERE roots.version_id = versions.version_id)
|
|
7
|
+
WHERE status = 'superseded' AND root_count <> (SELECT count(*) FROM ontology_source_roots AS roots
|
|
8
|
+
WHERE roots.version_id = versions.version_id);
|
|
9
|
+
CREATE INDEX idx_ontology_sources_drained
|
|
10
|
+
ON ontology_sources (project_id, terminal_at, source_id, materialization_id)
|
|
11
|
+
WHERE status = 'superseded' AND root_count = 0;
|
package/src/migrations.ts
CHANGED
|
@@ -115,6 +115,14 @@ import projectionRunSupersessionSql from "./migrations/051-projection-run-supers
|
|
|
115
115
|
import compactSourceStorageSql from "./migrations/052-compact-source-storage.sql" with {
|
|
116
116
|
type: "text",
|
|
117
117
|
}
|
|
118
|
+
import replacementPlansSql from "./migrations/053-replacement-plans.sql" with { type: "text" }
|
|
119
|
+
import slimOntologyOutboxSql from "./migrations/054-slim-ontology-outbox.sql" with { type: "text" }
|
|
120
|
+
import objectQueryPreparationSql from "./migrations/055-object-query-preparation.sql" with {
|
|
121
|
+
type: "text",
|
|
122
|
+
}
|
|
123
|
+
import commitTouchesSql from "./migrations/056-commit-touches.sql" with { type: "text" }
|
|
124
|
+
import audioTranscriptionSql from "./migrations/057-audio-transcription.sql" with { type: "text" }
|
|
125
|
+
import rerankingModelKindSql from "./migrations/058-reranking-model-kind.sql" with { type: "text" }
|
|
118
126
|
import type { ReservedSQL, SQL, SQLClient } from "./pg-client"
|
|
119
127
|
import { runPgTransactionOn, undoOnFailure, withReservedPgConnection } from "./transactions"
|
|
120
128
|
|
|
@@ -415,6 +423,14 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
|
|
|
415
423
|
pgSql("050-directory-group-memberships", directoryGroupMembershipsSql),
|
|
416
424
|
pgSql("051-projection-run-supersession", projectionRunSupersessionSql),
|
|
417
425
|
pgSql("052-compact-source-storage", compactSourceStorageSql),
|
|
426
|
+
pgSql("053-replacement-plans", replacementPlansSql),
|
|
427
|
+
pgSql("054-slim-ontology-outbox", slimOntologyOutboxSql),
|
|
428
|
+
pgSql("055-object-query-preparation", objectQueryPreparationSql),
|
|
429
|
+
pgSql("056-commit-touches", commitTouchesSql),
|
|
430
|
+
pgSql("057-audio-transcription", audioTranscriptionSql),
|
|
431
|
+
pgSql("058-reranking-model-kind", rerankingModelKindSql),
|
|
432
|
+
pgSql("059-file-download-grants", fileDownloadGrantsSql),
|
|
433
|
+
pgSql("060-drained-source-versions", drainedSourceVersionsSql),
|
|
418
434
|
],
|
|
419
435
|
})
|
|
420
436
|
|
|
@@ -439,3 +455,7 @@ import connectorOptionalPkceSql from "./migrations/040-connector-optional-pkce.s
|
|
|
439
455
|
type: "text",
|
|
440
456
|
}
|
|
441
457
|
import fileUploadSessionsSql from "./migrations/048-file-upload-sessions.sql" with { type: "text" }
|
|
458
|
+
import fileDownloadGrantsSql from "./migrations/059-file-download-grants.sql" with { type: "text" }
|
|
459
|
+
import drainedSourceVersionsSql from "./migrations/060-drained-source-versions.sql" with {
|
|
460
|
+
type: "text",
|
|
461
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Limit expensive root aggregates before they reserve a database connection. Pages can
|
|
2
|
+
* use the remaining pool capacity. No results or object revisions are retained here. */
|
|
3
|
+
export class PgAggregateAdmission {
|
|
4
|
+
private active = 0
|
|
5
|
+
private readonly waiting: (() => void)[] = []
|
|
6
|
+
|
|
7
|
+
constructor(private readonly concurrency = 1) {}
|
|
8
|
+
|
|
9
|
+
async run<T>(compute: () => Promise<T>): Promise<T> {
|
|
10
|
+
if (this.active >= this.concurrency)
|
|
11
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve))
|
|
12
|
+
else this.active++
|
|
13
|
+
try {
|
|
14
|
+
return await compute()
|
|
15
|
+
} finally {
|
|
16
|
+
const next = this.waiting.shift()
|
|
17
|
+
if (next) next()
|
|
18
|
+
else this.active--
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReservedSQL } from "../pg-client"
|
|
2
|
+
|
|
3
|
+
/** CREATE INDEX CONCURRENTLY on expressions/partial indexes waits for older snapshots.
|
|
4
|
+
* A blocking pg_advisory_lock SELECT can hold one while waiting for the index builder,
|
|
5
|
+
* producing a cycle. Try-lock in short statements and wait outside PostgreSQL instead. */
|
|
6
|
+
export async function lockPgPreparation(sql: ReservedSQL, key: string): Promise<void> {
|
|
7
|
+
let delay = 25
|
|
8
|
+
while (true) {
|
|
9
|
+
const [row] = await sql<{ acquired: boolean }[]>`
|
|
10
|
+
SELECT pg_try_advisory_lock(hashtextextended(${key}, 0)) AS acquired`
|
|
11
|
+
if (row?.acquired) return
|
|
12
|
+
await Bun.sleep(delay)
|
|
13
|
+
delay = Math.min(delay * 2, 500)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import type { ObjectQueryIndexDefinition } from "@sixb/core"
|
|
3
|
+
import type { ObjectQueryPreparationPlan, ObjectQueryPreparationResult } from "@sixb/core/storage"
|
|
4
|
+
import type { ReservedSQL, SQL, SQLClient } from "../pg-client"
|
|
5
|
+
import { undoOnFailure, withReservedPgConnection } from "../transactions"
|
|
6
|
+
import { lockPgPreparation } from "./preparation-lock"
|
|
7
|
+
import { ensurePgObjectQueryIndexes, type PgObjectQueryIndex } from "./query-indexes"
|
|
8
|
+
import { type PgObjectTextCountIndex, preparePgObjectTextCounts } from "./text-count-index"
|
|
9
|
+
|
|
10
|
+
/** Reconcile ontology indexes and text columns once per plan, coordinating concurrent startups. */
|
|
11
|
+
export async function preparePgObjectQueries(
|
|
12
|
+
sql: SQL,
|
|
13
|
+
schema: string,
|
|
14
|
+
plan: ObjectQueryPreparationPlan
|
|
15
|
+
): Promise<ObjectQueryPreparationResult> {
|
|
16
|
+
const fingerprint = preparationFingerprint(plan)
|
|
17
|
+
const current = await readCompletedPreparation(sql, fingerprint)
|
|
18
|
+
if (current) return current
|
|
19
|
+
|
|
20
|
+
// Hold one session through the entire preparation, including CONCURRENTLY builds (which
|
|
21
|
+
// cannot run in a transaction). Helpers reuse it instead of taking another pool connection.
|
|
22
|
+
return withReservedPgConnection(sql, async (connection) => {
|
|
23
|
+
const key = `${schema}.objects.query-indexes`
|
|
24
|
+
await lockPgPreparation(connection, key)
|
|
25
|
+
const unlock = () => connection`SELECT pg_advisory_unlock(hashtextextended(${key}, 0))`
|
|
26
|
+
const result = await undoOnFailure(async () => {
|
|
27
|
+
const existing = await readCompletedPreparation(connection, fingerprint)
|
|
28
|
+
if (existing) return existing
|
|
29
|
+
|
|
30
|
+
console.info(
|
|
31
|
+
`[SixbPg] Preparing query indexes for ${plan.projectId}; initial backfills may block writes.`
|
|
32
|
+
)
|
|
33
|
+
const result = await prepareStructures(connection, schema, plan)
|
|
34
|
+
|
|
35
|
+
// Record completion only after every index and generated-column backfill succeeds.
|
|
36
|
+
await connection`
|
|
37
|
+
INSERT INTO object_query_preparation (singleton, fingerprint, result)
|
|
38
|
+
VALUES (true, ${fingerprint}, ${connection.json({ ...result, warnings: [...result.warnings] })})
|
|
39
|
+
ON CONFLICT (singleton) DO UPDATE
|
|
40
|
+
SET fingerprint = excluded.fingerprint, result = excluded.result, prepared_at = now()
|
|
41
|
+
`
|
|
42
|
+
return result
|
|
43
|
+
}, unlock)
|
|
44
|
+
await unlock()
|
|
45
|
+
|
|
46
|
+
return result
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function preparationFingerprint(plan: ObjectQueryPreparationPlan): string {
|
|
51
|
+
const objectTypes = plan.objectTypes
|
|
52
|
+
.map((type) => ({
|
|
53
|
+
objectTypeId: type.objectTypeId,
|
|
54
|
+
properties: [...type.properties].sort((a, b) => a.propertyId.localeCompare(b.propertyId)),
|
|
55
|
+
indexes: type.indexes
|
|
56
|
+
.map(normalizeIndex)
|
|
57
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
58
|
+
}))
|
|
59
|
+
.sort((a, b) => a.objectTypeId.localeCompare(b.objectTypeId))
|
|
60
|
+
|
|
61
|
+
// Bump the version when physical generation changes, even if the ontology stays the same.
|
|
62
|
+
const definition = { version: 1, projectId: plan.projectId, objectTypes }
|
|
63
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeIndex(index: ObjectQueryIndexDefinition) {
|
|
67
|
+
if (index.kind === "sort") {
|
|
68
|
+
return {
|
|
69
|
+
kind: index.kind,
|
|
70
|
+
fields: index.fields.map((field) => ({
|
|
71
|
+
propertyId: field.propertyId,
|
|
72
|
+
direction: field.direction ?? "asc",
|
|
73
|
+
})),
|
|
74
|
+
filters: index.filters ?? [],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
kind: index.kind,
|
|
80
|
+
propertyId: index.propertyId,
|
|
81
|
+
filters: [...(index.filters ?? [])].sort(),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readCompletedPreparation(
|
|
86
|
+
sql: SQLClient,
|
|
87
|
+
fingerprint: string
|
|
88
|
+
): Promise<ObjectQueryPreparationResult | undefined> {
|
|
89
|
+
const [row] = await sql<{ result: ObjectQueryPreparationResult }[]>`
|
|
90
|
+
SELECT result FROM object_query_preparation
|
|
91
|
+
WHERE singleton AND fingerprint = ${fingerprint}
|
|
92
|
+
`
|
|
93
|
+
if (!row) return undefined
|
|
94
|
+
|
|
95
|
+
return { ...row.result, status: "current" }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function prepareStructures(
|
|
99
|
+
sql: ReservedSQL,
|
|
100
|
+
schema: string,
|
|
101
|
+
plan: ObjectQueryPreparationPlan
|
|
102
|
+
): Promise<ObjectQueryPreparationResult> {
|
|
103
|
+
const { indexes, textCounts, warnings } = compilePreparationPlan(plan)
|
|
104
|
+
|
|
105
|
+
if (indexes.some((index) => index.kind === "text") || textCounts.length > 0) {
|
|
106
|
+
await ensureTrigrams(sql)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const names = await ensurePgObjectQueryIndexes(sql, schema, indexes)
|
|
110
|
+
const texts = await preparePgObjectTextCounts(sql, schema, textCounts)
|
|
111
|
+
return {
|
|
112
|
+
status: "prepared",
|
|
113
|
+
objectTypes: plan.objectTypes.length,
|
|
114
|
+
indexes: new Set([...names, ...texts]).size,
|
|
115
|
+
warnings,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compilePreparationPlan(plan: ObjectQueryPreparationPlan) {
|
|
120
|
+
const indexes: PgObjectQueryIndex[] = []
|
|
121
|
+
const textCounts: PgObjectTextCountIndex[] = []
|
|
122
|
+
const warnings: string[] = []
|
|
123
|
+
|
|
124
|
+
for (const type of plan.objectTypes) {
|
|
125
|
+
const scope = { projectId: plan.projectId, objectTypeId: type.objectTypeId }
|
|
126
|
+
indexes.push({ ...scope, kind: "incomingLinks" })
|
|
127
|
+
|
|
128
|
+
for (const property of type.properties) {
|
|
129
|
+
if (property.text) indexes.push({ ...scope, kind: "text", propertyId: property.propertyId })
|
|
130
|
+
if (property.sortable) {
|
|
131
|
+
indexes.push({
|
|
132
|
+
...scope,
|
|
133
|
+
kind: "sort",
|
|
134
|
+
fields: [{ propertyId: property.propertyId, scalarKind: property.scalarKind }],
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (property.filterable && property.scalarKind === "string") {
|
|
139
|
+
indexes.push({ ...scope, kind: "filter", properties: [property.propertyId] })
|
|
140
|
+
} else if (
|
|
141
|
+
property.filterable &&
|
|
142
|
+
property.scalarKind &&
|
|
143
|
+
["integer", "double", "boolean", "date", "timestamp", "uuid"].includes(property.scalarKind)
|
|
144
|
+
) {
|
|
145
|
+
indexes.push({ ...scope, kind: "value", propertyId: property.propertyId })
|
|
146
|
+
} else if (property.filterable) {
|
|
147
|
+
warnings.push(
|
|
148
|
+
`${type.objectTypeId}.${property.propertyId}: no dedicated equality index for ${property.scalarKind ?? "this schema"}; queries remain exact through the general path.`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const index of type.indexes) {
|
|
154
|
+
if (index.kind === "sort") {
|
|
155
|
+
indexes.push({
|
|
156
|
+
...scope,
|
|
157
|
+
kind: "sort",
|
|
158
|
+
equality: index.filters,
|
|
159
|
+
fields: index.fields.map((field) => ({
|
|
160
|
+
...field,
|
|
161
|
+
scalarKind: type.properties.find((property) => property.propertyId === field.propertyId)
|
|
162
|
+
?.scalarKind,
|
|
163
|
+
})),
|
|
164
|
+
})
|
|
165
|
+
} else {
|
|
166
|
+
textCounts.push({ ...scope, propertyId: index.propertyId, filters: index.filters })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { indexes, textCounts, warnings }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extension creation is database-wide, whereas index preparation is schema-scoped.
|
|
174
|
+
async function ensureTrigrams(sql: ReservedSQL): Promise<void> {
|
|
175
|
+
await lockPgPreparation(sql, "sixb.extension.pg_trgm")
|
|
176
|
+
const unlock = () => sql`SELECT pg_advisory_unlock(hashtextextended('sixb.extension.pg_trgm', 0))`
|
|
177
|
+
await undoOnFailure(async () => {
|
|
178
|
+
await sql.unsafe("CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public")
|
|
179
|
+
}, unlock)
|
|
180
|
+
await unlock()
|
|
181
|
+
}
|