@tungthedev/streams-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +76 -0
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/SECURITY.md +42 -0
- package/bin/prisma-streams-server +2 -0
- package/package.json +46 -0
- package/src/app.ts +583 -0
- package/src/app_core.ts +3144 -0
- package/src/app_local.ts +206 -0
- package/src/auth.ts +124 -0
- package/src/auto_tune.ts +69 -0
- package/src/backpressure.ts +66 -0
- package/src/bootstrap.ts +613 -0
- package/src/compute/demo_entry.ts +415 -0
- package/src/compute/demo_site.ts +1242 -0
- package/src/compute/entry.ts +19 -0
- package/src/compute/package_entry.ts +4 -0
- package/src/compute/virtual-modules.d.ts +15 -0
- package/src/compute/worker_module_url.ts +9 -0
- package/src/concurrency_gate.ts +108 -0
- package/src/config.ts +402 -0
- package/src/db/bootstrap_store.ts +9 -0
- package/src/db/db.ts +2424 -0
- package/src/db/schema.ts +925 -0
- package/src/db/sqlite_manifest_snapshot.ts +81 -0
- package/src/db/sqlite_touch_store.ts +491 -0
- package/src/db/sqlite_wal_store.ts +472 -0
- package/src/details/full_mode_details.ts +568 -0
- package/src/expiry_sweeper.ts +47 -0
- package/src/foreground_activity.ts +55 -0
- package/src/hist.ts +169 -0
- package/src/index/binary_fuse.ts +379 -0
- package/src/index/indexer.ts +947 -0
- package/src/index/lexicon_file_cache.ts +261 -0
- package/src/index/lexicon_format.ts +93 -0
- package/src/index/lexicon_indexer.ts +863 -0
- package/src/index/run_cache.ts +84 -0
- package/src/index/run_format.ts +213 -0
- package/src/index/schedule.ts +28 -0
- package/src/index/secondary_indexer.ts +901 -0
- package/src/index/secondary_schema.ts +105 -0
- package/src/ingest.ts +309 -0
- package/src/lens/lens.ts +501 -0
- package/src/manifest.ts +249 -0
- package/src/memory.ts +334 -0
- package/src/metrics.ts +147 -0
- package/src/metrics_emitter.ts +83 -0
- package/src/notifier.ts +180 -0
- package/src/objectstore/accounting.ts +151 -0
- package/src/objectstore/interface.ts +13 -0
- package/src/objectstore/mock_r2.ts +269 -0
- package/src/objectstore/null.ts +32 -0
- package/src/objectstore/r2.ts +318 -0
- package/src/observe/pairing.ts +61 -0
- package/src/observe/request.ts +772 -0
- package/src/offset.ts +70 -0
- package/src/postgres/bootstrap.ts +269 -0
- package/src/postgres/companions.ts +197 -0
- package/src/postgres/control_restore.ts +109 -0
- package/src/postgres/details.ts +189 -0
- package/src/postgres/lexicon_index.ts +260 -0
- package/src/postgres/routing_index.ts +189 -0
- package/src/postgres/rows.ts +132 -0
- package/src/postgres/schema.ts +355 -0
- package/src/postgres/secondary_index.ts +238 -0
- package/src/postgres/segments.ts +900 -0
- package/src/postgres/stats.ts +103 -0
- package/src/postgres/store.ts +947 -0
- package/src/postgres/touch.ts +591 -0
- package/src/postgres/types.ts +32 -0
- package/src/profiles/evlog/schema.ts +234 -0
- package/src/profiles/evlog.ts +473 -0
- package/src/profiles/generic.ts +51 -0
- package/src/profiles/index.ts +237 -0
- package/src/profiles/metrics/block_format.ts +109 -0
- package/src/profiles/metrics/normalize.ts +366 -0
- package/src/profiles/metrics/schema.ts +319 -0
- package/src/profiles/metrics.ts +83 -0
- package/src/profiles/otelTraces/normalize.ts +955 -0
- package/src/profiles/otelTraces/otlp.ts +1002 -0
- package/src/profiles/otelTraces/schema.ts +408 -0
- package/src/profiles/otelTraces.ts +390 -0
- package/src/profiles/profile.ts +284 -0
- package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
- package/src/profiles/stateProtocol/changes.ts +24 -0
- package/src/profiles/stateProtocol/ingest.ts +115 -0
- package/src/profiles/stateProtocol/routes.ts +511 -0
- package/src/profiles/stateProtocol/types.ts +6 -0
- package/src/profiles/stateProtocol/validation.ts +51 -0
- package/src/profiles/stateProtocol.ts +107 -0
- package/src/read_filter.ts +468 -0
- package/src/reader.ts +2986 -0
- package/src/runtime/hash.ts +156 -0
- package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
- package/src/runtime/hash_vendor/NOTICE.md +8 -0
- package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
- package/src/runtime/host_runtime.ts +5 -0
- package/src/runtime_memory.ts +200 -0
- package/src/runtime_memory_sampler.ts +237 -0
- package/src/schema/lens_schema.ts +290 -0
- package/src/schema/proof.ts +547 -0
- package/src/schema/read_json.ts +51 -0
- package/src/schema/registry.ts +966 -0
- package/src/search/agg_format.ts +638 -0
- package/src/search/aggregate.ts +409 -0
- package/src/search/binary/codec.ts +162 -0
- package/src/search/binary/docset.ts +67 -0
- package/src/search/binary/restart_strings.ts +181 -0
- package/src/search/binary/varint.ts +34 -0
- package/src/search/bitset.ts +19 -0
- package/src/search/col_format.ts +382 -0
- package/src/search/col_runtime.ts +59 -0
- package/src/search/column_encoding.ts +43 -0
- package/src/search/companion_file_cache.ts +319 -0
- package/src/search/companion_format.ts +327 -0
- package/src/search/companion_manager.ts +1305 -0
- package/src/search/companion_plan.ts +229 -0
- package/src/search/exact_format.ts +281 -0
- package/src/search/exact_runtime.ts +55 -0
- package/src/search/fts_format.ts +423 -0
- package/src/search/fts_runtime.ts +333 -0
- package/src/search/query.ts +875 -0
- package/src/search/schema.ts +245 -0
- package/src/segment/cache.ts +270 -0
- package/src/segment/cached_segment.ts +89 -0
- package/src/segment/format.ts +403 -0
- package/src/segment/segmenter.ts +412 -0
- package/src/segment/segmenter_worker.ts +72 -0
- package/src/segment/segmenter_workers.ts +130 -0
- package/src/server.ts +264 -0
- package/src/server_auto_tune.ts +158 -0
- package/src/sqlite/adapter.ts +335 -0
- package/src/sqlite/runtime_stats.ts +163 -0
- package/src/stats.ts +205 -0
- package/src/store/append.ts +50 -0
- package/src/store/bootstrap_restore_store.ts +71 -0
- package/src/store/capabilities.ts +86 -0
- package/src/store/full_mode_details_store.ts +71 -0
- package/src/store/index_store.ts +104 -0
- package/src/store/profile_touch_store.ts +1 -0
- package/src/store/rows.ts +144 -0
- package/src/store/schema_profile_store.ts +73 -0
- package/src/store/schema_publication.ts +6 -0
- package/src/store/segment_manifest_store.ts +129 -0
- package/src/store/segment_read_store.ts +22 -0
- package/src/store/stats_accounting_store.ts +83 -0
- package/src/store/touch_store.ts +98 -0
- package/src/store/wal_store.ts +21 -0
- package/src/stream_size_reconciler.ts +100 -0
- package/src/touch/canonical_change.ts +7 -0
- package/src/touch/live_keys.ts +158 -0
- package/src/touch/live_metrics.ts +841 -0
- package/src/touch/live_templates.ts +449 -0
- package/src/touch/manager.ts +1292 -0
- package/src/touch/process_batch.ts +576 -0
- package/src/touch/processor_worker.ts +85 -0
- package/src/touch/spec.ts +459 -0
- package/src/touch/touch_journal.ts +771 -0
- package/src/touch/touch_key_id.ts +20 -0
- package/src/touch/worker_pool.ts +191 -0
- package/src/touch/worker_protocol.ts +57 -0
- package/src/types/proper-lockfile.d.ts +1 -0
- package/src/uploader.ts +358 -0
- package/src/util/base32_crockford.ts +81 -0
- package/src/util/bloom256.ts +67 -0
- package/src/util/byte_lru.ts +73 -0
- package/src/util/cleanup.ts +22 -0
- package/src/util/crc32c.ts +29 -0
- package/src/util/ds_error.ts +15 -0
- package/src/util/duration.ts +17 -0
- package/src/util/endian.ts +53 -0
- package/src/util/json_pointer.ts +148 -0
- package/src/util/log.ts +25 -0
- package/src/util/lru.ts +53 -0
- package/src/util/retry.ts +35 -0
- package/src/util/siphash.ts +71 -0
- package/src/util/stream_paths.ts +50 -0
- package/src/util/time.ts +14 -0
- package/src/util/yield.ts +3 -0
- package/src/util/zstd.ts +24 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { Pool, type PoolClient } from "pg";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import type {
|
|
4
|
+
EnsureStreamOptions,
|
|
5
|
+
DurableStoreCapabilities,
|
|
6
|
+
WalControlPlaneStore,
|
|
7
|
+
} from "../store/capabilities";
|
|
8
|
+
import type { StreamReadRow } from "../store/segment_read_store";
|
|
9
|
+
import type {
|
|
10
|
+
SchemaMetadataCommit,
|
|
11
|
+
SchemaMetadataMutationContext,
|
|
12
|
+
SchemaMetadataMutationPlan,
|
|
13
|
+
SchemaRegistryRow,
|
|
14
|
+
ProfileMetadataCommit,
|
|
15
|
+
ProfileMetadataMutationContext,
|
|
16
|
+
ProfileMetadataMutationPlan,
|
|
17
|
+
StoredProfileRow,
|
|
18
|
+
} from "../store/schema_profile_store";
|
|
19
|
+
import type {
|
|
20
|
+
StoreAppendBatch,
|
|
21
|
+
StoreAppendError,
|
|
22
|
+
StoreAppendResult,
|
|
23
|
+
StoreAppendTask,
|
|
24
|
+
} from "../store/append";
|
|
25
|
+
import type { WalReadRow } from "../store/wal_store";
|
|
26
|
+
import { dsError } from "../util/ds_error";
|
|
27
|
+
import { migratePostgresStore } from "./schema";
|
|
28
|
+
import { PostgresSegmentManifestStore } from "./segments";
|
|
29
|
+
import { PostgresRoutingIndexStore } from "./routing_index";
|
|
30
|
+
import { PostgresSecondaryIndexStore } from "./secondary_index";
|
|
31
|
+
import { PostgresLexiconIndexStore } from "./lexicon_index";
|
|
32
|
+
import { PostgresCompanionIndexStore } from "./companions";
|
|
33
|
+
import { PostgresFullModeDetailsStore } from "./details";
|
|
34
|
+
import { PostgresStatsAccountingStore } from "./stats";
|
|
35
|
+
import { PostgresTouchStore } from "./touch";
|
|
36
|
+
import type { PgExecutor, PgStreamRow } from "./types";
|
|
37
|
+
|
|
38
|
+
const STREAM_FLAG_DELETED = 1 << 0;
|
|
39
|
+
const STREAM_FLAG_TOUCH = 1 << 1;
|
|
40
|
+
const WAL_READ_CHUNK_ROWS = 1024;
|
|
41
|
+
|
|
42
|
+
type StreamState = {
|
|
43
|
+
nextOffset: bigint;
|
|
44
|
+
lastAppendMs: bigint;
|
|
45
|
+
expiresAtMs: bigint | null;
|
|
46
|
+
contentType: string;
|
|
47
|
+
streamSeq: string | null;
|
|
48
|
+
closed: boolean;
|
|
49
|
+
closedProducerId: string | null;
|
|
50
|
+
closedProducerEpoch: number | null;
|
|
51
|
+
closedProducerSeq: number | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ProducerState = { epoch: number; lastSeq: number };
|
|
55
|
+
type ProducerCheck = { duplicate: boolean; update: boolean; epoch: number; seq: number };
|
|
56
|
+
type PostgresStoreOptions = { fullMode?: boolean };
|
|
57
|
+
export type PostgresFullModeIndexStores = {
|
|
58
|
+
routing: PostgresRoutingIndexStore;
|
|
59
|
+
secondary: PostgresSecondaryIndexStore;
|
|
60
|
+
lexicon: PostgresLexiconIndexStore;
|
|
61
|
+
companions: PostgresCompanionIndexStore;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export class PostgresDurableStore implements WalControlPlaneStore {
|
|
65
|
+
readonly kind = "postgres" as const;
|
|
66
|
+
readonly capabilities: DurableStoreCapabilities;
|
|
67
|
+
private readonly segments?: PostgresSegmentManifestStore;
|
|
68
|
+
private readonly indexStores?: PostgresFullModeIndexStores;
|
|
69
|
+
private readonly details?: PostgresFullModeDetailsStore;
|
|
70
|
+
private readonly statsAccounting?: PostgresStatsAccountingStore;
|
|
71
|
+
private readonly touch?: PostgresTouchStore;
|
|
72
|
+
|
|
73
|
+
constructor(private readonly pool: Pool, private readonly opts: PostgresStoreOptions = {}) {
|
|
74
|
+
this.capabilities = {
|
|
75
|
+
wal: true,
|
|
76
|
+
schemas: true,
|
|
77
|
+
profiles: true,
|
|
78
|
+
streamLifecycle: true,
|
|
79
|
+
segmentReads: opts.fullMode === true,
|
|
80
|
+
indexes: opts.fullMode === true,
|
|
81
|
+
manifests: opts.fullMode === true,
|
|
82
|
+
objectStoreAccounting: opts.fullMode === true,
|
|
83
|
+
storageStats: opts.fullMode === true,
|
|
84
|
+
schemaPublication: opts.fullMode === true,
|
|
85
|
+
builtinProfiles: opts.fullMode === true,
|
|
86
|
+
internalMetrics: opts.fullMode === true,
|
|
87
|
+
touch: opts.fullMode === true,
|
|
88
|
+
};
|
|
89
|
+
if (opts.fullMode) {
|
|
90
|
+
this.segments = new PostgresSegmentManifestStore(this.pool, () => this.nowMs(), (stream, startOffset, endOffset, routingKey) =>
|
|
91
|
+
this.readWalRange(stream, startOffset, endOffset, routingKey)
|
|
92
|
+
);
|
|
93
|
+
this.indexStores = {
|
|
94
|
+
routing: new PostgresRoutingIndexStore(this.pool, () => this.nowMs()),
|
|
95
|
+
secondary: new PostgresSecondaryIndexStore(this.pool, () => this.nowMs()),
|
|
96
|
+
lexicon: new PostgresLexiconIndexStore(
|
|
97
|
+
this.pool,
|
|
98
|
+
() => this.nowMs(),
|
|
99
|
+
(stream, startOffset, endOffset, routingKey) => this.readWalRange(stream, startOffset, endOffset, routingKey)
|
|
100
|
+
),
|
|
101
|
+
companions: new PostgresCompanionIndexStore(this.pool, () => this.nowMs()),
|
|
102
|
+
};
|
|
103
|
+
this.details = new PostgresFullModeDetailsStore(this.pool);
|
|
104
|
+
this.statsAccounting = new PostgresStatsAccountingStore(this.pool, () => this.nowMs());
|
|
105
|
+
this.touch = new PostgresTouchStore(this.pool, {
|
|
106
|
+
nowMs: () => this.nowMs(),
|
|
107
|
+
getStream: (stream) => this.getStream(stream),
|
|
108
|
+
ensureStream: (stream, ensureOpts) => this.ensureStream(stream, ensureOpts),
|
|
109
|
+
isDeleted: (row) => this.isDeleted(row),
|
|
110
|
+
readWalRange: (stream, startOffset, endOffset, routingKey) => this.readWalRange(stream, startOffset, endOffset, routingKey),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static async connect(connectionString: string): Promise<PostgresDurableStore> {
|
|
116
|
+
const store = new PostgresDurableStore(new Pool({ connectionString }));
|
|
117
|
+
await store.migrate();
|
|
118
|
+
return store;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static async connectFull(connectionString: string): Promise<PostgresDurableStore> {
|
|
122
|
+
const store = new PostgresDurableStore(new Pool({ connectionString }), { fullMode: true });
|
|
123
|
+
await store.migrate();
|
|
124
|
+
return store;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async migrate(): Promise<void> {
|
|
128
|
+
await migratePostgresStore(this.pool, { fullMode: this.opts.fullMode });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async close(): Promise<void> {
|
|
132
|
+
await this.pool.end();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
nowMs(): bigint {
|
|
136
|
+
return BigInt(Date.now());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async nowMsForRead(): Promise<bigint> {
|
|
140
|
+
return this.nowMs();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
isDeleted(row: StreamReadRow): boolean {
|
|
144
|
+
return (row.stream_flags & STREAM_FLAG_DELETED) !== 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getStream(stream: string): Promise<StreamReadRow | null> {
|
|
148
|
+
return this.getStreamWithExecutor(this.pool, stream, false);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getStreamForRead(stream: string): Promise<StreamReadRow | null> {
|
|
152
|
+
return this.getStream(stream);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async ensureStream(stream: string, opts?: EnsureStreamOptions | null): Promise<StreamReadRow> {
|
|
156
|
+
if (!this.opts.fullMode && opts?.profile != null && opts.profile !== "generic") {
|
|
157
|
+
throw dsError("postgres storage supports generic profiles only", { code: "unsupported_capability" });
|
|
158
|
+
}
|
|
159
|
+
const now = this.nowMs();
|
|
160
|
+
const sql = this.opts.fullMode
|
|
161
|
+
? `INSERT INTO streams(
|
|
162
|
+
stream, created_at_ms, updated_at_ms,
|
|
163
|
+
content_type, profile, stream_seq, closed, closed_producer_id, closed_producer_epoch, closed_producer_seq, ttl_seconds,
|
|
164
|
+
epoch, next_offset, sealed_through, uploaded_through, uploaded_segment_count,
|
|
165
|
+
pending_rows, pending_bytes, logical_size_bytes, wal_rows, wal_bytes, last_append_ms, last_segment_cut_ms, segment_in_progress,
|
|
166
|
+
expires_at_ms, stream_flags
|
|
167
|
+
)
|
|
168
|
+
VALUES($1, $2, $2, $3, $4, $5, $6, NULL, NULL, NULL, $7, 0, 0, -1, -1, 0, 0, 0, 0, 0, 0, $2, $2, 0, $8, $9)
|
|
169
|
+
ON CONFLICT (stream) DO NOTHING
|
|
170
|
+
RETURNING *;`
|
|
171
|
+
: `INSERT INTO streams(
|
|
172
|
+
stream, created_at_ms, updated_at_ms,
|
|
173
|
+
content_type, profile, stream_seq, closed, closed_producer_id, closed_producer_epoch, closed_producer_seq, ttl_seconds,
|
|
174
|
+
epoch, next_offset, logical_size_bytes, wal_rows, wal_bytes, last_append_ms,
|
|
175
|
+
expires_at_ms, stream_flags
|
|
176
|
+
)
|
|
177
|
+
VALUES($1, $2, $2, $3, $4, $5, $6, NULL, NULL, NULL, $7, 0, 0, 0, 0, 0, $2, $8, $9)
|
|
178
|
+
ON CONFLICT (stream) DO NOTHING
|
|
179
|
+
RETURNING *;`;
|
|
180
|
+
const row = await this.pool.query<PgStreamRow>(
|
|
181
|
+
sql,
|
|
182
|
+
[
|
|
183
|
+
stream,
|
|
184
|
+
pgInt(now),
|
|
185
|
+
opts?.contentType ?? "application/octet-stream",
|
|
186
|
+
opts?.profile ?? "generic",
|
|
187
|
+
opts?.streamSeq ?? null,
|
|
188
|
+
opts?.closed ? 1 : 0,
|
|
189
|
+
opts?.ttlSeconds ?? null,
|
|
190
|
+
opts?.expiresAtMs == null ? null : pgInt(opts.expiresAtMs),
|
|
191
|
+
Math.max(0, Math.floor(opts?.streamFlags ?? 0)),
|
|
192
|
+
]
|
|
193
|
+
);
|
|
194
|
+
if (row.rows[0]) return coerceStreamRow(row.rows[0]);
|
|
195
|
+
const existing = await this.getStream(stream);
|
|
196
|
+
if (!existing) throw dsError("failed to ensure stream");
|
|
197
|
+
return existing;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async listStreams(limit: number, offset: number): Promise<StreamReadRow[]> {
|
|
201
|
+
const now = this.nowMs();
|
|
202
|
+
const res = await this.pool.query<PgStreamRow>(
|
|
203
|
+
`SELECT * FROM streams
|
|
204
|
+
WHERE (stream_flags & $1) = 0
|
|
205
|
+
AND (expires_at_ms IS NULL OR expires_at_ms > $2)
|
|
206
|
+
ORDER BY created_at_ms ASC, stream ASC
|
|
207
|
+
LIMIT $3 OFFSET $4;`,
|
|
208
|
+
[STREAM_FLAG_DELETED | STREAM_FLAG_TOUCH, pgInt(now), limit, offset]
|
|
209
|
+
);
|
|
210
|
+
return res.rows.map(coerceStreamRow);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async listExpiredStreams(limit: number): Promise<string[]> {
|
|
214
|
+
const now = this.nowMs();
|
|
215
|
+
const res = await this.pool.query<{ stream: string }>(
|
|
216
|
+
`SELECT stream FROM streams
|
|
217
|
+
WHERE (stream_flags & $1) = 0
|
|
218
|
+
AND expires_at_ms IS NOT NULL
|
|
219
|
+
AND expires_at_ms <= $2
|
|
220
|
+
ORDER BY expires_at_ms ASC
|
|
221
|
+
LIMIT $3;`,
|
|
222
|
+
[STREAM_FLAG_DELETED | STREAM_FLAG_TOUCH, pgInt(now), limit]
|
|
223
|
+
);
|
|
224
|
+
return res.rows.map((row) => row.stream);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async deleteStream(stream: string): Promise<boolean> {
|
|
228
|
+
const now = this.nowMs();
|
|
229
|
+
if (!this.opts.fullMode) {
|
|
230
|
+
const res = await this.pool.query(
|
|
231
|
+
`UPDATE streams
|
|
232
|
+
SET stream_flags = (stream_flags | $1), updated_at_ms = $2
|
|
233
|
+
WHERE stream = $3 AND (stream_flags & $1) = 0;`,
|
|
234
|
+
[STREAM_FLAG_DELETED, pgInt(now), stream]
|
|
235
|
+
);
|
|
236
|
+
return (res.rowCount ?? 0) > 0;
|
|
237
|
+
}
|
|
238
|
+
const client = await this.pool.connect();
|
|
239
|
+
try {
|
|
240
|
+
await client.query("BEGIN");
|
|
241
|
+
const res = await client.query(
|
|
242
|
+
`UPDATE streams
|
|
243
|
+
SET stream_flags = (stream_flags | $1), updated_at_ms = $2
|
|
244
|
+
WHERE stream = $3 AND (stream_flags & $1) = 0;`,
|
|
245
|
+
[STREAM_FLAG_DELETED, pgInt(now), stream]
|
|
246
|
+
);
|
|
247
|
+
if ((res.rowCount ?? 0) === 0) {
|
|
248
|
+
await client.query("ROLLBACK");
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
await this.deleteFullModeAccelerationState(client, stream);
|
|
252
|
+
await client.query("COMMIT");
|
|
253
|
+
return true;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
256
|
+
throw error;
|
|
257
|
+
} finally {
|
|
258
|
+
client.release();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async hardDeleteStream(stream: string): Promise<boolean> {
|
|
263
|
+
const res = await this.pool.query(`DELETE FROM streams WHERE stream = $1;`, [stream]);
|
|
264
|
+
return (res.rowCount ?? 0) > 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async appendBatch(batch: StoreAppendTask[]): Promise<StoreAppendBatch> {
|
|
268
|
+
if (batch.length === 0) return Result.ok({ results: [], walBytesCommitted: 0 });
|
|
269
|
+
const client = await this.pool.connect();
|
|
270
|
+
try {
|
|
271
|
+
await client.query("BEGIN");
|
|
272
|
+
const result = await this.appendBatchInTransaction(client, batch);
|
|
273
|
+
await client.query("COMMIT");
|
|
274
|
+
return result;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
277
|
+
if (isRetryablePostgresError(error)) return Result.err({ kind: "retryable" });
|
|
278
|
+
throw error;
|
|
279
|
+
} finally {
|
|
280
|
+
client.release();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async *readWalRange(stream: string, startOffset: bigint, endOffset: bigint, routingKey?: Uint8Array): AsyncIterable<WalReadRow> {
|
|
285
|
+
for await (const row of this.readWalRows(stream, startOffset, endOffset, "ASC", routingKey)) yield row;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async *readWalRangeDesc(stream: string, startOffset: bigint, endOffset: bigint, routingKey?: Uint8Array): AsyncIterable<WalReadRow> {
|
|
289
|
+
for await (const row of this.readWalRows(stream, startOffset, endOffset, "DESC", routingKey)) yield row;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async getWalOldestTimestampMsForRead(stream: string): Promise<bigint | null> {
|
|
293
|
+
const res = await this.pool.query<{ min_ts: string | number | bigint | null }>(
|
|
294
|
+
`SELECT MIN(ts_ms) AS min_ts FROM wal WHERE stream = $1;`,
|
|
295
|
+
[stream]
|
|
296
|
+
);
|
|
297
|
+
const value = res.rows[0]?.min_ts;
|
|
298
|
+
return value == null ? null : toBigInt(value);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fullModeSegments(): PostgresSegmentManifestStore {
|
|
302
|
+
return this.requireSegments();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fullModeIndexStores(): PostgresFullModeIndexStores {
|
|
306
|
+
if (!this.indexStores) throw dsError("postgres full-mode index stores are not enabled", { code: "unsupported_capability" });
|
|
307
|
+
return this.indexStores;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fullModeDetails(): PostgresFullModeDetailsStore {
|
|
311
|
+
if (!this.details) throw dsError("postgres full-mode details store is not enabled", { code: "unsupported_capability" });
|
|
312
|
+
return this.details;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
fullModeStatsAccounting(): PostgresStatsAccountingStore {
|
|
316
|
+
if (!this.statsAccounting) throw dsError("postgres full-mode stats/accounting store is not enabled", { code: "unsupported_capability" });
|
|
317
|
+
return this.statsAccounting;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
fullModeTouch(): PostgresTouchStore {
|
|
321
|
+
if (!this.touch) throw dsError("postgres full-mode touch store is not enabled", { code: "unsupported_capability" });
|
|
322
|
+
return this.touch;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getSchemaRegistryForRead(stream: string): Promise<SchemaRegistryRow | null> {
|
|
326
|
+
return this.getSchemaRegistryWithExecutor(this.pool, stream);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async commitSchemaMetadataMutation<T, E>(
|
|
330
|
+
stream: string,
|
|
331
|
+
mutation: (ctx: SchemaMetadataMutationContext) => Result<SchemaMetadataMutationPlan<T>, E>
|
|
332
|
+
): Promise<Result<SchemaMetadataCommit<T>, E>> {
|
|
333
|
+
const client = await this.pool.connect();
|
|
334
|
+
try {
|
|
335
|
+
await client.query("BEGIN");
|
|
336
|
+
const streamRow = await this.getStreamWithExecutor(client, stream, true);
|
|
337
|
+
const registryRow = await this.getSchemaRegistryWithExecutor(client, stream);
|
|
338
|
+
const mutationRes = mutation({ streamRow, registryRow });
|
|
339
|
+
if (Result.isError(mutationRes)) {
|
|
340
|
+
await client.query("ROLLBACK");
|
|
341
|
+
return mutationRes;
|
|
342
|
+
}
|
|
343
|
+
if (mutationRes.value.registry.search && !this.opts.fullMode) {
|
|
344
|
+
await client.query("ROLLBACK");
|
|
345
|
+
return Result.err(unsupportedSchemaSearchError() as E);
|
|
346
|
+
}
|
|
347
|
+
const updatedAtMs = this.nowMs();
|
|
348
|
+
await client.query(
|
|
349
|
+
`INSERT INTO schemas(stream, schema_json, updated_at_ms)
|
|
350
|
+
VALUES($1, $2, $3)
|
|
351
|
+
ON CONFLICT(stream) DO UPDATE SET
|
|
352
|
+
schema_json = excluded.schema_json,
|
|
353
|
+
updated_at_ms = excluded.updated_at_ms;`,
|
|
354
|
+
[stream, mutationRes.value.registryJson, pgInt(updatedAtMs)]
|
|
355
|
+
);
|
|
356
|
+
await client.query("COMMIT");
|
|
357
|
+
return Result.ok({
|
|
358
|
+
registry: mutationRes.value.registry,
|
|
359
|
+
updatedAtMs,
|
|
360
|
+
value: mutationRes.value.value,
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
364
|
+
throw error;
|
|
365
|
+
} finally {
|
|
366
|
+
client.release();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async getStreamProfileForRead(stream: string): Promise<StoredProfileRow | null> {
|
|
371
|
+
return this.getProfileWithExecutor(this.pool, stream);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async commitProfileMetadataMutation<T, E>(
|
|
375
|
+
stream: string,
|
|
376
|
+
mutation: (ctx: ProfileMetadataMutationContext) => Result<ProfileMetadataMutationPlan<T>, E>
|
|
377
|
+
): Promise<Result<ProfileMetadataCommit<T>, E>> {
|
|
378
|
+
const client = await this.pool.connect();
|
|
379
|
+
try {
|
|
380
|
+
await client.query("BEGIN");
|
|
381
|
+
const streamRow = await this.getStreamWithExecutor(client, stream, true);
|
|
382
|
+
const profileRow = await this.getProfileWithExecutor(client, stream);
|
|
383
|
+
const mutationRes = mutation({ streamRow, profileRow });
|
|
384
|
+
if (Result.isError(mutationRes)) {
|
|
385
|
+
await client.query("ROLLBACK");
|
|
386
|
+
return mutationRes;
|
|
387
|
+
}
|
|
388
|
+
const metadata = mutationRes.value.metadata;
|
|
389
|
+
if (!this.opts.fullMode && ((metadata.streamProfile != null && metadata.streamProfile !== "generic") || metadata.profileJson != null)) {
|
|
390
|
+
await client.query("ROLLBACK");
|
|
391
|
+
return Result.err(unsupportedProfileError() as E);
|
|
392
|
+
}
|
|
393
|
+
const updatedAtMs = this.nowMs();
|
|
394
|
+
await client.query(
|
|
395
|
+
`UPDATE streams SET profile = $1, updated_at_ms = $2 WHERE stream = $3;`,
|
|
396
|
+
[metadata.streamProfile, pgInt(updatedAtMs), stream]
|
|
397
|
+
);
|
|
398
|
+
if (metadata.profileJson == null) {
|
|
399
|
+
await client.query(`DELETE FROM stream_profiles WHERE stream = $1;`, [stream]);
|
|
400
|
+
} else {
|
|
401
|
+
await client.query(
|
|
402
|
+
`INSERT INTO stream_profiles(stream, profile_json, updated_at_ms)
|
|
403
|
+
VALUES($1, $2, $3)
|
|
404
|
+
ON CONFLICT(stream) DO UPDATE SET
|
|
405
|
+
profile_json = excluded.profile_json,
|
|
406
|
+
updated_at_ms = excluded.updated_at_ms;`,
|
|
407
|
+
[stream, metadata.profileJson, pgInt(updatedAtMs)]
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (metadata.schemaRegistry) {
|
|
411
|
+
await client.query(
|
|
412
|
+
`INSERT INTO schemas(stream, schema_json, updated_at_ms)
|
|
413
|
+
VALUES($1, $2, $3)
|
|
414
|
+
ON CONFLICT(stream) DO UPDATE SET
|
|
415
|
+
schema_json = excluded.schema_json,
|
|
416
|
+
updated_at_ms = excluded.updated_at_ms;`,
|
|
417
|
+
[stream, JSON.stringify(metadata.schemaRegistry), pgInt(updatedAtMs)]
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (metadata.touchState === "ensure" && streamRow) {
|
|
421
|
+
await client.query(
|
|
422
|
+
`INSERT INTO stream_touch_state(stream, processed_through, updated_at_ms)
|
|
423
|
+
VALUES($1, $2, $3)
|
|
424
|
+
ON CONFLICT(stream) DO NOTHING;`,
|
|
425
|
+
[stream, pgInt(streamRow.next_offset - 1n), pgInt(updatedAtMs)]
|
|
426
|
+
);
|
|
427
|
+
} else if (metadata.touchState === "delete") {
|
|
428
|
+
await client.query(`DELETE FROM stream_touch_state WHERE stream = $1;`, [stream]);
|
|
429
|
+
}
|
|
430
|
+
await client.query("COMMIT");
|
|
431
|
+
return Result.ok({
|
|
432
|
+
schemaRegistry: metadata.schemaRegistry,
|
|
433
|
+
profileUpdatedAtMs: updatedAtMs,
|
|
434
|
+
value: mutationRes.value.value,
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
438
|
+
throw error;
|
|
439
|
+
} finally {
|
|
440
|
+
client.release();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async appendBatchInTransaction(client: PoolClient, batch: StoreAppendTask[]): Promise<StoreAppendBatch> {
|
|
445
|
+
const nowMs = this.nowMs();
|
|
446
|
+
let walBytesCommitted = 0;
|
|
447
|
+
const results: StoreAppendResult[] = new Array(batch.length);
|
|
448
|
+
const perStream = new Map<string, StreamState>();
|
|
449
|
+
const perProducer = new Map<string, ProducerState | null>();
|
|
450
|
+
|
|
451
|
+
const loadStream = async (stream: string): Promise<StreamState | null> => {
|
|
452
|
+
const cached = perStream.get(stream);
|
|
453
|
+
if (cached) return cached;
|
|
454
|
+
const res = await client.query<PgStreamRow>(
|
|
455
|
+
`SELECT stream, epoch, next_offset, last_append_ms, expires_at_ms, stream_flags,
|
|
456
|
+
content_type, stream_seq, closed, closed_producer_id, closed_producer_epoch, closed_producer_seq
|
|
457
|
+
FROM streams
|
|
458
|
+
WHERE stream = $1
|
|
459
|
+
FOR UPDATE;`,
|
|
460
|
+
[stream]
|
|
461
|
+
);
|
|
462
|
+
const row = res.rows[0];
|
|
463
|
+
if (!row || (Number(row.stream_flags) & STREAM_FLAG_DELETED) !== 0) return null;
|
|
464
|
+
const st: StreamState = {
|
|
465
|
+
nextOffset: toBigInt(row.next_offset),
|
|
466
|
+
lastAppendMs: toBigInt(row.last_append_ms),
|
|
467
|
+
expiresAtMs: row.expires_at_ms == null ? null : toBigInt(row.expires_at_ms),
|
|
468
|
+
contentType: String(row.content_type),
|
|
469
|
+
streamSeq: row.stream_seq == null ? null : String(row.stream_seq),
|
|
470
|
+
closed: Number(row.closed) !== 0,
|
|
471
|
+
closedProducerId: row.closed_producer_id == null ? null : String(row.closed_producer_id),
|
|
472
|
+
closedProducerEpoch: row.closed_producer_epoch == null ? null : Number(row.closed_producer_epoch),
|
|
473
|
+
closedProducerSeq: row.closed_producer_seq == null ? null : Number(row.closed_producer_seq),
|
|
474
|
+
};
|
|
475
|
+
perStream.set(stream, st);
|
|
476
|
+
return st;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const loadProducerState = async (stream: string, producerId: string): Promise<ProducerState | null> => {
|
|
480
|
+
const key = `${stream}\u0000${producerId}`;
|
|
481
|
+
if (perProducer.has(key)) return perProducer.get(key)!;
|
|
482
|
+
const res = await client.query<{ epoch: number; last_seq: number }>(
|
|
483
|
+
`SELECT epoch, last_seq FROM producer_state WHERE stream = $1 AND producer_id = $2 FOR UPDATE;`,
|
|
484
|
+
[stream, producerId]
|
|
485
|
+
);
|
|
486
|
+
const row = res.rows[0];
|
|
487
|
+
const state = row ? { epoch: Number(row.epoch), lastSeq: Number(row.last_seq) } : null;
|
|
488
|
+
perProducer.set(key, state);
|
|
489
|
+
return state;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const checkProducer = async (task: StoreAppendTask): Promise<Result<ProducerCheck, StoreAppendError>> => {
|
|
493
|
+
const producer = task.producer!;
|
|
494
|
+
const state = await loadProducerState(task.stream, producer.id);
|
|
495
|
+
if (!state) {
|
|
496
|
+
if (producer.seq !== 0) return Result.err({ kind: "producer_epoch_seq" });
|
|
497
|
+
return Result.ok({ duplicate: false, update: true, epoch: producer.epoch, seq: producer.seq });
|
|
498
|
+
}
|
|
499
|
+
if (producer.epoch < state.epoch) return Result.err({ kind: "producer_stale_epoch", producerEpoch: state.epoch });
|
|
500
|
+
if (producer.epoch > state.epoch) {
|
|
501
|
+
if (producer.seq !== 0) return Result.err({ kind: "producer_epoch_seq" });
|
|
502
|
+
return Result.ok({ duplicate: false, update: true, epoch: producer.epoch, seq: producer.seq });
|
|
503
|
+
}
|
|
504
|
+
if (producer.seq <= state.lastSeq) return Result.ok({ duplicate: true, update: false, epoch: state.epoch, seq: state.lastSeq });
|
|
505
|
+
if (producer.seq === state.lastSeq + 1) {
|
|
506
|
+
return Result.ok({ duplicate: false, update: true, epoch: state.epoch, seq: producer.seq });
|
|
507
|
+
}
|
|
508
|
+
return Result.err({ kind: "producer_gap", expected: state.lastSeq + 1, received: producer.seq });
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const persistProducerUpdate = async (task: StoreAppendTask, check: ProducerCheck): Promise<void> => {
|
|
512
|
+
if (!task.producer || !check.update) return;
|
|
513
|
+
await upsertProducerState(client, task.stream, task.producer.id, check.epoch, check.seq, nowMs);
|
|
514
|
+
perProducer.set(`${task.stream}\u0000${task.producer.id}`, { epoch: check.epoch, lastSeq: check.seq });
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const checkStreamSeq = (task: StoreAppendTask, st: StreamState): Result<{ nextSeq: string | null }, StoreAppendError> => {
|
|
518
|
+
if (task.streamSeq == null) return Result.ok({ nextSeq: st.streamSeq });
|
|
519
|
+
if (st.streamSeq != null && task.streamSeq <= st.streamSeq) {
|
|
520
|
+
return Result.err({ kind: "stream_seq", expected: st.streamSeq, received: task.streamSeq });
|
|
521
|
+
}
|
|
522
|
+
return Result.ok({ nextSeq: task.streamSeq });
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
for (let idx = 0; idx < batch.length; idx++) {
|
|
526
|
+
const task = batch[idx]!;
|
|
527
|
+
const st = await loadStream(task.stream);
|
|
528
|
+
if (!st) {
|
|
529
|
+
results[idx] = Result.err({ kind: "not_found" });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (st.expiresAtMs != null && nowMs > st.expiresAtMs) {
|
|
533
|
+
results[idx] = Result.err({ kind: "gone" });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const tailOffset = st.nextOffset - 1n;
|
|
537
|
+
const isCloseOnly = task.close && task.rows.length === 0;
|
|
538
|
+
if (st.closed) {
|
|
539
|
+
if (isCloseOnly) {
|
|
540
|
+
results[idx] = Result.ok({ lastOffset: tailOffset, appendedRows: 0, closed: true, duplicate: true });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (
|
|
544
|
+
task.producer &&
|
|
545
|
+
task.close &&
|
|
546
|
+
st.closedProducerId === task.producer.id &&
|
|
547
|
+
st.closedProducerEpoch === task.producer.epoch &&
|
|
548
|
+
st.closedProducerSeq === task.producer.seq
|
|
549
|
+
) {
|
|
550
|
+
results[idx] = Result.ok({
|
|
551
|
+
lastOffset: tailOffset,
|
|
552
|
+
appendedRows: 0,
|
|
553
|
+
closed: true,
|
|
554
|
+
duplicate: true,
|
|
555
|
+
producer: { epoch: task.producer.epoch, seq: task.producer.seq },
|
|
556
|
+
});
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
results[idx] = Result.err({ kind: "closed", lastOffset: tailOffset });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (isCloseOnly) {
|
|
563
|
+
results[idx] = await this.applyCloseOnly(client, task, st, checkProducer, persistProducerUpdate, checkStreamSeq, nowMs, tailOffset);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (!task.contentType || task.contentType !== st.contentType) {
|
|
567
|
+
results[idx] = Result.err({ kind: "content_type_mismatch" });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const appendRes = await this.applyAppend(client, task, st, checkProducer, persistProducerUpdate, checkStreamSeq, nowMs, tailOffset);
|
|
571
|
+
results[idx] = appendRes.result;
|
|
572
|
+
walBytesCommitted += appendRes.walBytesCommitted;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return Result.ok({ results, walBytesCommitted });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async applyCloseOnly(
|
|
579
|
+
client: PoolClient,
|
|
580
|
+
task: StoreAppendTask,
|
|
581
|
+
st: StreamState,
|
|
582
|
+
checkProducer: (task: StoreAppendTask) => Promise<Result<ProducerCheck, StoreAppendError>>,
|
|
583
|
+
persistProducerUpdate: (task: StoreAppendTask, check: ProducerCheck) => Promise<void>,
|
|
584
|
+
checkStreamSeq: (task: StoreAppendTask, st: StreamState) => Result<{ nextSeq: string | null }, StoreAppendError>,
|
|
585
|
+
nowMs: bigint,
|
|
586
|
+
tailOffset: bigint
|
|
587
|
+
): Promise<StoreAppendResult> {
|
|
588
|
+
let producerInfo: { epoch: number; seq: number } | undefined;
|
|
589
|
+
let duplicate = false;
|
|
590
|
+
if (task.producer) {
|
|
591
|
+
const prodCheck = await checkProducer(task);
|
|
592
|
+
if (Result.isError(prodCheck)) return Result.err(prodCheck.error);
|
|
593
|
+
duplicate = prodCheck.value.duplicate;
|
|
594
|
+
producerInfo = { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq };
|
|
595
|
+
}
|
|
596
|
+
if (!duplicate) {
|
|
597
|
+
const seqCheck = checkStreamSeq(task, st);
|
|
598
|
+
if (Result.isError(seqCheck)) return Result.err(seqCheck.error);
|
|
599
|
+
if (task.producer) await persistProducerUpdate(task, { duplicate, update: true, epoch: producerInfo!.epoch, seq: producerInfo!.seq });
|
|
600
|
+
st.streamSeq = seqCheck.value.nextSeq;
|
|
601
|
+
const closedProducer = task.producer ?? null;
|
|
602
|
+
await client.query(
|
|
603
|
+
`UPDATE streams
|
|
604
|
+
SET closed = 1,
|
|
605
|
+
closed_producer_id = $1,
|
|
606
|
+
closed_producer_epoch = $2,
|
|
607
|
+
closed_producer_seq = $3,
|
|
608
|
+
updated_at_ms = $4,
|
|
609
|
+
stream_seq = $5
|
|
610
|
+
WHERE stream = $6 AND (stream_flags & $7) = 0;`,
|
|
611
|
+
[
|
|
612
|
+
closedProducer ? closedProducer.id : null,
|
|
613
|
+
closedProducer ? closedProducer.epoch : null,
|
|
614
|
+
closedProducer ? closedProducer.seq : null,
|
|
615
|
+
pgInt(nowMs),
|
|
616
|
+
st.streamSeq,
|
|
617
|
+
task.stream,
|
|
618
|
+
STREAM_FLAG_DELETED,
|
|
619
|
+
]
|
|
620
|
+
);
|
|
621
|
+
st.closed = true;
|
|
622
|
+
st.closedProducerId = closedProducer ? closedProducer.id : null;
|
|
623
|
+
st.closedProducerEpoch = closedProducer ? closedProducer.epoch : null;
|
|
624
|
+
st.closedProducerSeq = closedProducer ? closedProducer.seq : null;
|
|
625
|
+
}
|
|
626
|
+
return Result.ok({ lastOffset: tailOffset, appendedRows: 0, closed: st.closed, duplicate, producer: producerInfo });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async applyAppend(
|
|
630
|
+
client: PoolClient,
|
|
631
|
+
task: StoreAppendTask,
|
|
632
|
+
st: StreamState,
|
|
633
|
+
checkProducer: (task: StoreAppendTask) => Promise<Result<ProducerCheck, StoreAppendError>>,
|
|
634
|
+
persistProducerUpdate: (task: StoreAppendTask, check: ProducerCheck) => Promise<void>,
|
|
635
|
+
checkStreamSeq: (task: StoreAppendTask, st: StreamState) => Result<{ nextSeq: string | null }, StoreAppendError>,
|
|
636
|
+
nowMs: bigint,
|
|
637
|
+
tailOffset: bigint
|
|
638
|
+
): Promise<{ result: StoreAppendResult; walBytesCommitted: number }> {
|
|
639
|
+
let producerInfo: { epoch: number; seq: number } | undefined;
|
|
640
|
+
let producerCheck: ProducerCheck | null = null;
|
|
641
|
+
if (task.producer) {
|
|
642
|
+
const prodCheck = await checkProducer(task);
|
|
643
|
+
if (Result.isError(prodCheck)) return { result: Result.err(prodCheck.error), walBytesCommitted: 0 };
|
|
644
|
+
producerCheck = prodCheck.value;
|
|
645
|
+
if (prodCheck.value.duplicate) {
|
|
646
|
+
return {
|
|
647
|
+
result: Result.ok({
|
|
648
|
+
lastOffset: tailOffset,
|
|
649
|
+
appendedRows: 0,
|
|
650
|
+
closed: false,
|
|
651
|
+
duplicate: true,
|
|
652
|
+
producer: { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq },
|
|
653
|
+
}),
|
|
654
|
+
walBytesCommitted: 0,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
producerInfo = { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const seqCheck = checkStreamSeq(task, st);
|
|
661
|
+
if (Result.isError(seqCheck)) return { result: Result.err(seqCheck.error), walBytesCommitted: 0 };
|
|
662
|
+
if (producerCheck) await persistProducerUpdate(task, producerCheck);
|
|
663
|
+
st.streamSeq = seqCheck.value.nextSeq;
|
|
664
|
+
|
|
665
|
+
let appendMs = task.baseAppendMs;
|
|
666
|
+
if (appendMs <= st.lastAppendMs) appendMs = st.lastAppendMs + 1n;
|
|
667
|
+
let offset = st.nextOffset;
|
|
668
|
+
let totalBytes = 0n;
|
|
669
|
+
for (const row of task.rows) {
|
|
670
|
+
const payload = Buffer.from(row.payload.buffer, row.payload.byteOffset, row.payload.byteLength);
|
|
671
|
+
const payloadLen = row.payload.byteLength;
|
|
672
|
+
totalBytes += BigInt(payloadLen);
|
|
673
|
+
await client.query(
|
|
674
|
+
`INSERT INTO wal(stream, "offset", ts_ms, payload, payload_len, routing_key, content_type, flags)
|
|
675
|
+
VALUES($1, $2, $3, $4, $5, $6, $7, 0);`,
|
|
676
|
+
[
|
|
677
|
+
task.stream,
|
|
678
|
+
pgInt(offset),
|
|
679
|
+
pgInt(appendMs),
|
|
680
|
+
payload,
|
|
681
|
+
payloadLen,
|
|
682
|
+
row.routingKey == null ? null : Buffer.from(row.routingKey.buffer, row.routingKey.byteOffset, row.routingKey.byteLength),
|
|
683
|
+
row.contentType,
|
|
684
|
+
]
|
|
685
|
+
);
|
|
686
|
+
offset += 1n;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const lastOffset = offset - 1n;
|
|
690
|
+
st.nextOffset = offset;
|
|
691
|
+
st.lastAppendMs = appendMs;
|
|
692
|
+
if (task.close) {
|
|
693
|
+
st.closed = true;
|
|
694
|
+
st.closedProducerId = task.producer?.id ?? null;
|
|
695
|
+
st.closedProducerEpoch = task.producer?.epoch ?? null;
|
|
696
|
+
st.closedProducerSeq = task.producer?.seq ?? null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (this.opts.fullMode) {
|
|
700
|
+
await client.query(
|
|
701
|
+
`UPDATE streams
|
|
702
|
+
SET next_offset = $1,
|
|
703
|
+
updated_at_ms = $2,
|
|
704
|
+
last_append_ms = $3,
|
|
705
|
+
pending_rows = pending_rows + $4,
|
|
706
|
+
pending_bytes = pending_bytes + $5,
|
|
707
|
+
logical_size_bytes = logical_size_bytes + $5,
|
|
708
|
+
wal_rows = wal_rows + $4,
|
|
709
|
+
wal_bytes = wal_bytes + $5,
|
|
710
|
+
stream_seq = $6,
|
|
711
|
+
closed = CASE WHEN $7 = 1 THEN 1 ELSE closed END,
|
|
712
|
+
closed_producer_id = CASE WHEN $7 = 1 THEN $8 ELSE closed_producer_id END,
|
|
713
|
+
closed_producer_epoch = CASE WHEN $7 = 1 THEN $9 ELSE closed_producer_epoch END,
|
|
714
|
+
closed_producer_seq = CASE WHEN $7 = 1 THEN $10 ELSE closed_producer_seq END
|
|
715
|
+
WHERE stream = $11 AND (stream_flags & $12) = 0;`,
|
|
716
|
+
[
|
|
717
|
+
pgInt(st.nextOffset),
|
|
718
|
+
pgInt(nowMs),
|
|
719
|
+
pgInt(st.lastAppendMs),
|
|
720
|
+
pgInt(BigInt(task.rows.length)),
|
|
721
|
+
pgInt(totalBytes),
|
|
722
|
+
st.streamSeq,
|
|
723
|
+
task.close ? 1 : 0,
|
|
724
|
+
task.producer?.id ?? null,
|
|
725
|
+
task.producer?.epoch ?? null,
|
|
726
|
+
task.producer?.seq ?? null,
|
|
727
|
+
task.stream,
|
|
728
|
+
STREAM_FLAG_DELETED,
|
|
729
|
+
]
|
|
730
|
+
);
|
|
731
|
+
} else {
|
|
732
|
+
await client.query(
|
|
733
|
+
`UPDATE streams
|
|
734
|
+
SET next_offset = $1,
|
|
735
|
+
updated_at_ms = $2,
|
|
736
|
+
last_append_ms = $3,
|
|
737
|
+
logical_size_bytes = logical_size_bytes + $5,
|
|
738
|
+
wal_rows = wal_rows + $4,
|
|
739
|
+
wal_bytes = wal_bytes + $5,
|
|
740
|
+
stream_seq = $6,
|
|
741
|
+
closed = CASE WHEN $7 = 1 THEN 1 ELSE closed END,
|
|
742
|
+
closed_producer_id = CASE WHEN $7 = 1 THEN $8 ELSE closed_producer_id END,
|
|
743
|
+
closed_producer_epoch = CASE WHEN $7 = 1 THEN $9 ELSE closed_producer_epoch END,
|
|
744
|
+
closed_producer_seq = CASE WHEN $7 = 1 THEN $10 ELSE closed_producer_seq END
|
|
745
|
+
WHERE stream = $11 AND (stream_flags & $12) = 0;`,
|
|
746
|
+
[
|
|
747
|
+
pgInt(st.nextOffset),
|
|
748
|
+
pgInt(nowMs),
|
|
749
|
+
pgInt(st.lastAppendMs),
|
|
750
|
+
pgInt(BigInt(task.rows.length)),
|
|
751
|
+
pgInt(totalBytes),
|
|
752
|
+
st.streamSeq,
|
|
753
|
+
task.close ? 1 : 0,
|
|
754
|
+
task.producer?.id ?? null,
|
|
755
|
+
task.producer?.epoch ?? null,
|
|
756
|
+
task.producer?.seq ?? null,
|
|
757
|
+
task.stream,
|
|
758
|
+
STREAM_FLAG_DELETED,
|
|
759
|
+
]
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
result: Result.ok({ lastOffset, appendedRows: task.rows.length, closed: task.close, duplicate: false, producer: producerInfo }),
|
|
765
|
+
walBytesCommitted: Number(totalBytes),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private async *readWalRows(
|
|
770
|
+
stream: string,
|
|
771
|
+
startOffset: bigint,
|
|
772
|
+
endOffset: bigint,
|
|
773
|
+
order: "ASC" | "DESC",
|
|
774
|
+
routingKey?: Uint8Array
|
|
775
|
+
): AsyncIterable<WalReadRow> {
|
|
776
|
+
if (endOffset < startOffset) return;
|
|
777
|
+
const routingKeyBuffer = routingKey == null ? null : Buffer.from(routingKey.buffer, routingKey.byteOffset, routingKey.byteLength);
|
|
778
|
+
let cursor = order === "ASC" ? startOffset : endOffset;
|
|
779
|
+
while (order === "ASC" ? cursor <= endOffset : cursor >= startOffset) {
|
|
780
|
+
const params: unknown[] =
|
|
781
|
+
order === "ASC"
|
|
782
|
+
? [stream, pgInt(cursor), pgInt(endOffset), WAL_READ_CHUNK_ROWS]
|
|
783
|
+
: [stream, pgInt(startOffset), pgInt(cursor), WAL_READ_CHUNK_ROWS];
|
|
784
|
+
let routingSql = "";
|
|
785
|
+
if (routingKeyBuffer) {
|
|
786
|
+
params.push(routingKeyBuffer);
|
|
787
|
+
routingSql = ` AND routing_key = $5`;
|
|
788
|
+
}
|
|
789
|
+
const res = await this.pool.query(
|
|
790
|
+
`SELECT "offset", ts_ms, routing_key, content_type, payload
|
|
791
|
+
FROM wal
|
|
792
|
+
WHERE stream = $1 AND "offset" >= $2 AND "offset" <= $3${routingSql}
|
|
793
|
+
ORDER BY "offset" ${order}
|
|
794
|
+
LIMIT $4;`,
|
|
795
|
+
params
|
|
796
|
+
);
|
|
797
|
+
if (res.rows.length === 0) return;
|
|
798
|
+
for (const row of res.rows) yield coerceWalRow(row);
|
|
799
|
+
const lastOffset = toBigInt(res.rows[res.rows.length - 1]!["offset"]);
|
|
800
|
+
cursor = order === "ASC" ? lastOffset + 1n : lastOffset - 1n;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private async getStreamWithExecutor(executor: PgExecutor, stream: string, forUpdate: boolean): Promise<StreamReadRow | null> {
|
|
805
|
+
const res = await executor.query<PgStreamRow>(
|
|
806
|
+
`SELECT * FROM streams WHERE stream = $1${forUpdate ? " FOR UPDATE" : ""};`,
|
|
807
|
+
[stream]
|
|
808
|
+
);
|
|
809
|
+
return res.rows[0] ? coerceStreamRow(res.rows[0]) : null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private async getSchemaRegistryWithExecutor(executor: PgExecutor, stream: string): Promise<SchemaRegistryRow | null> {
|
|
813
|
+
const res = await executor.query<{ stream: string; schema_json: string; updated_at_ms: string | number | bigint; uploaded_size_bytes?: string | number | bigint }>(
|
|
814
|
+
this.opts.fullMode
|
|
815
|
+
? `SELECT stream, schema_json, updated_at_ms, uploaded_size_bytes FROM schemas WHERE stream = $1;`
|
|
816
|
+
: `SELECT stream, schema_json, updated_at_ms FROM schemas WHERE stream = $1;`,
|
|
817
|
+
[stream]
|
|
818
|
+
);
|
|
819
|
+
const row = res.rows[0];
|
|
820
|
+
return row
|
|
821
|
+
? {
|
|
822
|
+
stream: row.stream,
|
|
823
|
+
registry_json: row.schema_json,
|
|
824
|
+
updated_at_ms: toBigInt(row.updated_at_ms),
|
|
825
|
+
uploaded_size_bytes: row.uploaded_size_bytes == null ? 0n : toBigInt(row.uploaded_size_bytes),
|
|
826
|
+
}
|
|
827
|
+
: null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private async getProfileWithExecutor(executor: PgExecutor, stream: string): Promise<StoredProfileRow | null> {
|
|
831
|
+
const res = await executor.query<{ stream: string; profile_json: string; updated_at_ms: string | number | bigint }>(
|
|
832
|
+
`SELECT stream, profile_json, updated_at_ms FROM stream_profiles WHERE stream = $1;`,
|
|
833
|
+
[stream]
|
|
834
|
+
);
|
|
835
|
+
const row = res.rows[0];
|
|
836
|
+
return row ? { stream: row.stream, profile_json: row.profile_json, updated_at_ms: toBigInt(row.updated_at_ms) } : null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private requireSegments(): PostgresSegmentManifestStore {
|
|
840
|
+
if (!this.segments) throw dsError("postgres full-mode segment capability is not enabled", { code: "unsupported_capability" });
|
|
841
|
+
return this.segments;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private async deleteFullModeAccelerationState(executor: PgExecutor, stream: string): Promise<void> {
|
|
845
|
+
await executor.query(`DELETE FROM index_runs WHERE stream = $1;`, [stream]);
|
|
846
|
+
await executor.query(`DELETE FROM index_state WHERE stream = $1;`, [stream]);
|
|
847
|
+
await executor.query(`DELETE FROM secondary_index_runs WHERE stream = $1;`, [stream]);
|
|
848
|
+
await executor.query(`DELETE FROM secondary_index_state WHERE stream = $1;`, [stream]);
|
|
849
|
+
await executor.query(`DELETE FROM lexicon_index_runs WHERE stream = $1;`, [stream]);
|
|
850
|
+
await executor.query(`DELETE FROM lexicon_index_state WHERE stream = $1;`, [stream]);
|
|
851
|
+
await executor.query(`DELETE FROM search_segment_companions WHERE stream = $1;`, [stream]);
|
|
852
|
+
await executor.query(`DELETE FROM search_companion_plans WHERE stream = $1;`, [stream]);
|
|
853
|
+
await executor.query(`DELETE FROM live_templates WHERE stream = $1;`, [stream]);
|
|
854
|
+
await executor.query(`DELETE FROM stream_touch_state WHERE stream = $1;`, [stream]);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function upsertProducerState(client: PoolClient, stream: string, producerId: string, epoch: number, seq: number, nowMs: bigint): Promise<void> {
|
|
859
|
+
await client.query(
|
|
860
|
+
`INSERT INTO producer_state(stream, producer_id, epoch, last_seq, updated_at_ms)
|
|
861
|
+
VALUES($1, $2, $3, $4, $5)
|
|
862
|
+
ON CONFLICT(stream, producer_id) DO UPDATE SET
|
|
863
|
+
epoch = excluded.epoch,
|
|
864
|
+
last_seq = excluded.last_seq,
|
|
865
|
+
updated_at_ms = excluded.updated_at_ms;`,
|
|
866
|
+
[stream, producerId, epoch, seq, pgInt(nowMs)]
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function pgInt(value: bigint): string {
|
|
871
|
+
return value.toString();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function toBigInt(value: unknown): bigint {
|
|
875
|
+
return typeof value === "bigint" ? value : BigInt(value as any);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function toBytes(value: unknown): Uint8Array | null {
|
|
879
|
+
if (value == null) return null;
|
|
880
|
+
if (value instanceof Uint8Array) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
881
|
+
return new Uint8Array(value as ArrayBuffer);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function coerceStreamRow(row: PgStreamRow): StreamReadRow {
|
|
885
|
+
const walRows = toBigInt(row.wal_rows);
|
|
886
|
+
const walBytes = toBigInt(row.wal_bytes);
|
|
887
|
+
const lastAppendMs = toBigInt(row.last_append_ms);
|
|
888
|
+
return {
|
|
889
|
+
stream: String(row.stream),
|
|
890
|
+
created_at_ms: toBigInt(row.created_at_ms),
|
|
891
|
+
updated_at_ms: toBigInt(row.updated_at_ms),
|
|
892
|
+
content_type: String(row.content_type),
|
|
893
|
+
profile: row.profile == null ? null : String(row.profile),
|
|
894
|
+
stream_seq: row.stream_seq == null ? null : String(row.stream_seq),
|
|
895
|
+
closed: Number(row.closed),
|
|
896
|
+
closed_producer_id: row.closed_producer_id == null ? null : String(row.closed_producer_id),
|
|
897
|
+
closed_producer_epoch: row.closed_producer_epoch == null ? null : Number(row.closed_producer_epoch),
|
|
898
|
+
closed_producer_seq: row.closed_producer_seq == null ? null : Number(row.closed_producer_seq),
|
|
899
|
+
ttl_seconds: row.ttl_seconds == null ? null : Number(row.ttl_seconds),
|
|
900
|
+
epoch: Number(row.epoch),
|
|
901
|
+
next_offset: toBigInt(row.next_offset),
|
|
902
|
+
sealed_through: row.sealed_through == null ? -1n : toBigInt(row.sealed_through),
|
|
903
|
+
uploaded_through: row.uploaded_through == null ? -1n : toBigInt(row.uploaded_through),
|
|
904
|
+
uploaded_segment_count: row.uploaded_segment_count == null ? 0 : Number(row.uploaded_segment_count),
|
|
905
|
+
pending_rows: row.pending_rows == null ? walRows : toBigInt(row.pending_rows),
|
|
906
|
+
pending_bytes: row.pending_bytes == null ? walBytes : toBigInt(row.pending_bytes),
|
|
907
|
+
logical_size_bytes: toBigInt(row.logical_size_bytes),
|
|
908
|
+
wal_rows: walRows,
|
|
909
|
+
wal_bytes: walBytes,
|
|
910
|
+
last_append_ms: lastAppendMs,
|
|
911
|
+
last_segment_cut_ms: row.last_segment_cut_ms == null ? lastAppendMs : toBigInt(row.last_segment_cut_ms),
|
|
912
|
+
segment_in_progress: row.segment_in_progress == null ? 0 : Number(row.segment_in_progress),
|
|
913
|
+
expires_at_ms: row.expires_at_ms == null ? null : toBigInt(row.expires_at_ms),
|
|
914
|
+
stream_flags: Number(row.stream_flags),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function coerceWalRow(row: any): WalReadRow {
|
|
919
|
+
return {
|
|
920
|
+
offset: toBigInt(row.offset),
|
|
921
|
+
tsMs: toBigInt(row.ts_ms),
|
|
922
|
+
routingKey: toBytes(row.routing_key),
|
|
923
|
+
contentType: row.content_type == null ? null : String(row.content_type),
|
|
924
|
+
payload: toBytes(row.payload) ?? new Uint8Array(),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function unsupportedSchemaSearchError(): { kind: "bad_request"; message: string; code: string } {
|
|
929
|
+
return {
|
|
930
|
+
kind: "bad_request",
|
|
931
|
+
message: "postgres storage does not support schema search configuration yet",
|
|
932
|
+
code: "unsupported_capability",
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function unsupportedProfileError(): { kind: "bad_request"; message: string; code: string } {
|
|
937
|
+
return {
|
|
938
|
+
kind: "bad_request",
|
|
939
|
+
message: "postgres storage supports generic profiles only",
|
|
940
|
+
code: "unsupported_capability",
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function isRetryablePostgresError(error: unknown): boolean {
|
|
945
|
+
const code = String((error as { code?: unknown })?.code ?? "");
|
|
946
|
+
return code === "40001" || code === "40P01" || code === "55P03";
|
|
947
|
+
}
|