orez 0.1.13 → 0.1.15

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.
@@ -7,9 +7,10 @@
7
7
  */
8
8
 
9
9
  import { log } from '../log.js'
10
+
11
+ const textEncoder = new TextEncoder()
10
12
  import {
11
13
  getChangesSince,
12
- getCurrentWatermark,
13
14
  purgeConsumedChanges,
14
15
  installTriggersOnShardTables,
15
16
  type ChangeRecord,
@@ -22,11 +23,9 @@ import {
22
23
  encodeUpdate,
23
24
  encodeDelete,
24
25
  encodeKeepalive,
25
- wrapXLogData,
26
- wrapCopyData,
26
+ encodeWrappedChange,
27
27
  getTableOid,
28
28
  inferColumns,
29
- type ColumnInfo,
30
29
  } from './pgoutput-encoder.js'
31
30
 
32
31
  import type { Mutex } from '../mutex.js'
@@ -34,6 +33,7 @@ import type { PGlite } from '@electric-sql/pglite'
34
33
 
35
34
  export interface ReplicationWriter {
36
35
  write(data: Uint8Array): void
36
+ readonly closed?: boolean
37
37
  }
38
38
 
39
39
  // current lsn counter
@@ -56,7 +56,7 @@ function nowMicros(): bigint {
56
56
  // build a wire protocol row description + data row response
57
57
  function buildSimpleResponse(columns: string[], values: string[]): Uint8Array {
58
58
  const parts: Uint8Array[] = []
59
- const encoder = new TextEncoder()
59
+ const encoder = textEncoder
60
60
 
61
61
  // RowDescription (0x54)
62
62
  let rdSize = 6 // int32 len + int16 numFields
@@ -140,7 +140,7 @@ function buildSimpleResponse(columns: string[], values: string[]): Uint8Array {
140
140
  }
141
141
 
142
142
  function buildCommandComplete(tag: string): Uint8Array {
143
- const encoder = new TextEncoder()
143
+ const encoder = textEncoder
144
144
  const tagBytes = encoder.encode(tag + '\0')
145
145
  const cc = new Uint8Array(1 + 4 + tagBytes.length)
146
146
  cc[0] = 0x43
@@ -159,7 +159,7 @@ function buildCommandComplete(tag: string): Uint8Array {
159
159
  }
160
160
 
161
161
  function buildErrorResponse(message: string): Uint8Array {
162
- const encoder = new TextEncoder()
162
+ const encoder = textEncoder
163
163
  const msgBytes = encoder.encode(message)
164
164
  // S(severity) + M(message) + null terminator
165
165
  const fields = new Uint8Array(2 + 6 + 2 + msgBytes.length + 1 + 1) // S + ERROR\0 + M + msg\0 + terminator
@@ -334,6 +334,9 @@ export async function handleStartReplication(
334
334
  FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
335
335
  `)
336
336
  }
337
+ if (tables.length > 0) {
338
+ log.proxy(`installed notify triggers on ${tables.length} public table(s)`)
339
+ }
337
340
 
338
341
  // discover shard schemas (e.g. chat_0) and install NOTIFY triggers
339
342
  const shardSchemas = await db.query<{ nspname: string }>(
@@ -463,34 +466,76 @@ export async function handleStartReplication(
463
466
  const sentRelations = new Set<string>()
464
467
  let txCounter = 1
465
468
 
466
- // polling + notification loop
467
- // adaptive: poll fast when catching up, slow when idle
468
- const pollIntervalIdle = 500
469
- const pollIntervalCatchUp = 20
469
+ // event-driven replication with promise-based wakeup
470
+ // uses pg_notify to wake up immediately, polling only as fallback
471
+ const pollIntervalIdle = 200
470
472
  const batchSize = 2000
471
- const purgeEveryN = 10
473
+ const purgeEveryN = 5
472
474
  const shardRescanEveryN = 20
473
475
  let running = true
474
476
  let pollsSincePurge = 0
475
477
  let pollsSinceShardRescan = 0
476
478
 
479
+ // promise-based wakeup mechanism
480
+ let wakeupResolve: (() => void) | null = null
481
+ const wakeup = () => {
482
+ if (wakeupResolve) {
483
+ wakeupResolve()
484
+ wakeupResolve = null
485
+ }
486
+ }
487
+ const waitForWakeup = (timeoutMs: number): Promise<void> => {
488
+ return new Promise((resolve) => {
489
+ const timer = setTimeout(() => {
490
+ wakeupResolve = null
491
+ resolve()
492
+ }, timeoutMs)
493
+ wakeupResolve = () => {
494
+ clearTimeout(timer)
495
+ resolve()
496
+ }
497
+ })
498
+ }
499
+
500
+ // set up LISTEN to wake up immediately on changes
501
+ let unsubscribe: (() => Promise<void>) | null = null
502
+ try {
503
+ unsubscribe = await db.listen('_zero_changes', wakeup)
504
+ log.debug.proxy('replication: listening for _zero_changes notifications')
505
+ } catch {
506
+ log.debug.proxy('replication: LISTEN not available, using polling only')
507
+ }
508
+
477
509
  const poll = async () => {
478
510
  while (running) {
511
+ // check if the connection was closed
512
+ if (writer.closed) {
513
+ log.debug.proxy('replication: writer closed, exiting poll loop')
514
+ running = false
515
+ break
516
+ }
517
+
479
518
  try {
480
519
  // periodically re-scan for new shard schemas (e.g. chat_0 created by zero-cache)
481
520
  pollsSinceShardRescan++
482
521
  if (pollsSinceShardRescan >= shardRescanEveryN) {
483
- pollsSinceShardRescan = 0
484
- await mutex.acquire()
485
- try {
486
- await installTriggersOnShardTables(db)
487
- } finally {
488
- mutex.release()
522
+ if (mutex.tryAcquire()) {
523
+ pollsSinceShardRescan = 0
524
+ try {
525
+ await installTriggersOnShardTables(db)
526
+ } finally {
527
+ mutex.release()
528
+ }
489
529
  }
490
530
  }
491
531
 
492
- // acquire mutex to avoid conflicting with proxy connections
493
- await mutex.acquire()
532
+ // try to acquire mutex without blocking proxy connections.
533
+ // if the mutex is busy (proxy handling zero-cache queries), skip
534
+ // this iteration to avoid starving initial sync.
535
+ if (!mutex.tryAcquire()) {
536
+ await waitForWakeup(pollIntervalIdle)
537
+ continue
538
+ }
494
539
  let changes: Awaited<ReturnType<typeof getChangesSince>>
495
540
  try {
496
541
  changes = await getChangesSince(db, lastWatermark, batchSize)
@@ -531,9 +576,8 @@ export async function handleStartReplication(
531
576
 
532
577
  // purge consumed changes periodically to free wasm memory
533
578
  pollsSincePurge++
534
- if (pollsSincePurge >= purgeEveryN) {
579
+ if (pollsSincePurge >= purgeEveryN && mutex.tryAcquire()) {
535
580
  pollsSincePurge = 0
536
- await mutex.acquire()
537
581
  try {
538
582
  const purged = await purgeConsumedChanges(db, lastWatermark)
539
583
  if (purged > 0) {
@@ -543,15 +587,17 @@ export async function handleStartReplication(
543
587
  mutex.release()
544
588
  }
545
589
  }
590
+
591
+ // got changes - continue immediately to check for more
592
+ continue
546
593
  }
547
594
 
548
595
  // send keepalive
549
596
  const ts = nowMicros()
550
597
  writer.write(encodeKeepalive(currentLsn, ts, false))
551
598
 
552
- // if we got a full batch, there's likely more - poll fast
553
- const delay = changes.length >= batchSize ? pollIntervalCatchUp : pollIntervalIdle
554
- await new Promise((resolve) => setTimeout(resolve, delay))
599
+ // no changes: wait for notify signal or poll interval
600
+ await waitForWakeup(pollIntervalIdle)
555
601
  } catch (err: unknown) {
556
602
  const msg = err instanceof Error ? err.message : String(err)
557
603
  log.debug.proxy(`replication poll error: ${msg}`)
@@ -565,10 +611,19 @@ export async function handleStartReplication(
565
611
  }
566
612
 
567
613
  log.debug.proxy('replication: starting poll loop')
568
- await poll()
614
+ try {
615
+ await poll()
616
+ } finally {
617
+ if (unsubscribe) {
618
+ await unsubscribe().catch(() => {})
619
+ }
620
+ }
569
621
  log.debug.proxy('replication: poll loop exited')
570
622
  }
571
623
 
624
+ // cache column info per table to avoid per-change allocation
625
+ const cachedColumns = new Map<string, ReturnType<typeof inferColumns>>()
626
+
572
627
  async function streamChanges(
573
628
  changes: ChangeRecord[],
574
629
  writer: ReplicationWriter,
@@ -582,8 +637,7 @@ async function streamChanges(
582
637
  const lsn = nextLsn()
583
638
 
584
639
  // BEGIN
585
- const beginMsg = wrapXLogData(lsn, lsn, ts, encodeBegin(lsn, ts, txId))
586
- writer.write(wrapCopyData(beginMsg))
640
+ writer.write(encodeWrappedChange(lsn, lsn, ts, encodeBegin(lsn, ts, txId)))
587
641
 
588
642
  for (const change of changes) {
589
643
  // parse schema-qualified name (schema.table or bare table)
@@ -626,18 +680,23 @@ async function streamChanges(
626
680
  const row = rowData || oldData
627
681
  if (!row) continue
628
682
 
629
- const keySet = tableKeyColumns.get(qualifiedKey)
630
- const typeOids = columnTypeOids.get(qualifiedKey)
631
- const columns = inferColumns(row).map((col) => ({
632
- ...col,
633
- typeOid: typeOids?.get(col.name) ?? col.typeOid,
634
- isKey: keySet?.has(col.name) ?? false,
635
- }))
683
+ // use cached columns or build and cache them
684
+ let columns = cachedColumns.get(qualifiedKey)
685
+ if (!columns) {
686
+ const keySet = tableKeyColumns.get(qualifiedKey)
687
+ const typeOids = columnTypeOids.get(qualifiedKey)
688
+ columns = inferColumns(row).map((col) => ({
689
+ ...col,
690
+ typeOid: typeOids?.get(col.name) ?? col.typeOid,
691
+ isKey: keySet?.has(col.name) ?? false,
692
+ }))
693
+ cachedColumns.set(qualifiedKey, columns)
694
+ }
636
695
 
637
696
  // send RELATION if not yet sent
638
697
  if (!sentRelations.has(qualifiedKey)) {
639
698
  const relMsg = encodeRelation(tableOid, schema, tableName, 0x64, columns)
640
- writer.write(wrapCopyData(wrapXLogData(lsn, lsn, ts, relMsg)))
699
+ writer.write(encodeWrappedChange(lsn, lsn, ts, relMsg))
641
700
  sentRelations.add(qualifiedKey)
642
701
  }
643
702
 
@@ -660,13 +719,12 @@ async function streamChanges(
660
719
  continue
661
720
  }
662
721
 
663
- writer.write(wrapCopyData(wrapXLogData(lsn, lsn, ts, changeMsg)))
722
+ writer.write(encodeWrappedChange(lsn, lsn, ts, changeMsg))
664
723
  }
665
724
 
666
725
  // COMMIT
667
726
  const endLsn = nextLsn()
668
- const commitMsg = wrapXLogData(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts))
669
- writer.write(wrapCopyData(commitMsg))
727
+ writer.write(encodeWrappedChange(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts)))
670
728
  }
671
729
 
672
730
  function normalizeShardClientsRow(
@@ -10,6 +10,9 @@
10
10
  // postgres epoch: 2000-01-01 in microseconds from unix epoch
11
11
  const PG_EPOCH_MICROS = 946684800000000n
12
12
 
13
+ // shared encoder instance - avoids per-call allocation
14
+ const encoder = new TextEncoder()
15
+
13
16
  // table oid tracking
14
17
  const tableOids = new Map<string, number>()
15
18
  let nextOid = 16384
@@ -39,10 +42,27 @@ export function inferColumns(row: Record<string, unknown>): ColumnInfo[] {
39
42
  }))
40
43
  }
41
44
 
42
- function encodeString(str: string): Uint8Array {
43
- return new TextEncoder().encode(str)
45
+ // reusable scratch buffer for building messages (64KB, grows if needed)
46
+ let scratch = new Uint8Array(65536)
47
+ let scratchView = new DataView(scratch.buffer)
48
+
49
+ function ensureScratch(size: number): void {
50
+ if (scratch.length < size) {
51
+ const newSize = Math.max(size, scratch.length * 2)
52
+ scratch = new Uint8Array(newSize)
53
+ scratchView = new DataView(scratch.buffer)
54
+ }
55
+ }
56
+
57
+ function writeInt16At(offset: number, val: number): void {
58
+ scratchView.setInt16(offset, val)
59
+ }
60
+
61
+ function writeInt32At(offset: number, val: number): void {
62
+ scratchView.setInt32(offset, val)
44
63
  }
45
64
 
65
+ // legacy helpers for standalone buffers
46
66
  function writeInt16(buf: Uint8Array, offset: number, val: number): void {
47
67
  new DataView(buf.buffer, buf.byteOffset).setInt16(offset, val)
48
68
  }
@@ -89,14 +109,14 @@ export function encodeRelation(
89
109
  replicaIdentity: number,
90
110
  columns: ColumnInfo[]
91
111
  ): Uint8Array {
92
- const schemaBytes = encodeString(schema)
93
- const nameBytes = encodeString(tableName)
112
+ const schemaBytes = encoder.encode(schema)
113
+ const nameBytes = encoder.encode(tableName)
94
114
 
95
115
  // calculate column sizes
96
116
  let columnsSize = 0
97
117
  const colNameBytes: Uint8Array[] = []
98
118
  for (const col of columns) {
99
- const nb = encodeString(col.name)
119
+ const nb = encoder.encode(col.name)
100
120
  colNameBytes.push(nb)
101
121
  columnsSize += 1 + nb.length + 1 + 4 + 4 // flags + name + null + typeOid + typeMod
102
122
  }
@@ -133,19 +153,25 @@ export function encodeRelation(
133
153
  return buf
134
154
  }
135
155
 
136
- function encodeTupleData(
156
+ // encode tuple data directly into scratch buffer starting at given offset.
157
+ // returns the number of bytes written.
158
+ function encodeTupleDataInto(
137
159
  row: Record<string, unknown>,
138
- columns: ColumnInfo[]
139
- ): Uint8Array {
140
- const parts: Uint8Array[] = []
141
- let totalSize = 2 // ncolumns (int16)
160
+ columns: ColumnInfo[],
161
+ startOffset: number
162
+ ): number {
163
+ let pos = startOffset
164
+
165
+ // reserve space for ncolumns
166
+ ensureScratch(pos + 2 + columns.length * 32)
167
+ writeInt16At(pos, columns.length)
168
+ pos += 2
142
169
 
143
- const values: (Uint8Array | null)[] = []
144
170
  for (const col of columns) {
145
171
  const val = row[col.name]
146
172
  if (val === null || val === undefined) {
147
- values.push(null)
148
- totalSize += 1 // 'n' byte
173
+ ensureScratch(pos + 1)
174
+ scratch[pos++] = 0x6e // 'n' for null
149
175
  } else {
150
176
  // convert to postgresql text format
151
177
  let strVal: string
@@ -156,28 +182,48 @@ function encodeTupleData(
156
182
  } else {
157
183
  strVal = String(val)
158
184
  }
159
- const bytes = encodeString(strVal)
160
- values.push(bytes)
161
- totalSize += 1 + 4 + bytes.length // 't' + len + data
185
+ const bytes = encoder.encode(strVal)
186
+ ensureScratch(pos + 1 + 4 + bytes.length)
187
+ scratch[pos++] = 0x74 // 't' for text
188
+ writeInt32At(pos, bytes.length)
189
+ pos += 4
190
+ scratch.set(bytes, pos)
191
+ pos += bytes.length
162
192
  }
163
193
  }
164
194
 
195
+ return pos - startOffset
196
+ }
197
+
198
+ /**
199
+ * encode a complete change message wrapped in CopyData(XLogData(...)).
200
+ * avoids intermediate buffer allocations by writing directly into one buffer.
201
+ */
202
+ export function encodeWrappedChange(
203
+ walStart: bigint,
204
+ walEnd: bigint,
205
+ timestamp: bigint,
206
+ changeData: Uint8Array
207
+ ): Uint8Array {
208
+ // CopyData header: 'd' + int32 len
209
+ // XLogData header: 'w' + int64 walStart + int64 walEnd + int64 timestamp
210
+ // then changeData
211
+ const xlogSize = 1 + 8 + 8 + 8 + changeData.length
212
+ const totalSize = 1 + 4 + xlogSize
165
213
  const buf = new Uint8Array(totalSize)
166
- let pos = 0
167
- writeInt16(buf, pos, columns.length)
168
- pos += 2
169
214
 
170
- for (const val of values) {
171
- if (val === null) {
172
- buf[pos++] = 0x6e // 'n' for null
173
- } else {
174
- buf[pos++] = 0x74 // 't' for text
175
- writeInt32(buf, pos, val.length)
176
- pos += 4
177
- buf.set(val, pos)
178
- pos += val.length
179
- }
180
- }
215
+ // CopyData
216
+ buf[0] = 0x64 // 'd'
217
+ writeInt32(buf, 1, 4 + xlogSize)
218
+
219
+ // XLogData
220
+ buf[5] = 0x77 // 'w'
221
+ writeInt64(buf, 6, walStart)
222
+ writeInt64(buf, 14, walEnd)
223
+ writeInt64(buf, 22, timestamp - PG_EPOCH_MICROS)
224
+
225
+ // change payload
226
+ buf.set(changeData, 30)
181
227
 
182
228
  return buf
183
229
  }
@@ -188,13 +234,14 @@ export function encodeInsert(
188
234
  row: Record<string, unknown>,
189
235
  columns: ColumnInfo[]
190
236
  ): Uint8Array {
191
- const tuple = encodeTupleData(row, columns)
192
- const buf = new Uint8Array(1 + 4 + 1 + tuple.length)
193
- buf[0] = 0x49 // 'I'
194
- writeInt32(buf, 1, tableOid)
195
- buf[5] = 0x4e // 'N' for new tuple
196
- buf.set(tuple, 6)
197
- return buf
237
+ // write header + tuple directly into scratch
238
+ const headerSize = 1 + 4 + 1 // 'I' + oid + 'N'
239
+ ensureScratch(headerSize + 2 + columns.length * 64)
240
+ scratch[0] = 0x49 // 'I'
241
+ writeInt32At(1, tableOid)
242
+ scratch[5] = 0x4e // 'N' for new tuple
243
+ const tupleLen = encodeTupleDataInto(row, columns, 6)
244
+ return scratch.slice(0, 6 + tupleLen)
198
245
  }
199
246
 
200
247
  // encode an UPDATE message
@@ -204,26 +251,21 @@ export function encodeUpdate(
204
251
  oldRow: Record<string, unknown> | null,
205
252
  columns: ColumnInfo[]
206
253
  ): Uint8Array {
207
- const newTuple = encodeTupleData(row, columns)
254
+ ensureScratch(1 + 4 + 1 + columns.length * 128)
255
+ scratch[0] = 0x55 // 'U'
256
+ writeInt32At(1, tableOid)
208
257
 
209
258
  if (oldRow) {
210
- const oldTuple = encodeTupleData(oldRow, columns)
211
- const buf = new Uint8Array(1 + 4 + 1 + oldTuple.length + 1 + newTuple.length)
212
- buf[0] = 0x55 // 'U'
213
- writeInt32(buf, 1, tableOid)
214
- buf[5] = 0x4f // 'O' for old tuple
215
- buf.set(oldTuple, 6)
216
- buf[6 + oldTuple.length] = 0x4e // 'N' for new tuple
217
- buf.set(newTuple, 7 + oldTuple.length)
218
- return buf
259
+ scratch[5] = 0x4f // 'O' for old tuple
260
+ const oldLen = encodeTupleDataInto(oldRow, columns, 6)
261
+ scratch[6 + oldLen] = 0x4e // 'N' for new tuple
262
+ const newLen = encodeTupleDataInto(row, columns, 7 + oldLen)
263
+ return scratch.slice(0, 7 + oldLen + newLen)
219
264
  }
220
265
 
221
- const buf = new Uint8Array(1 + 4 + 1 + newTuple.length)
222
- buf[0] = 0x55 // 'U'
223
- writeInt32(buf, 1, tableOid)
224
- buf[5] = 0x4e // 'N'
225
- buf.set(newTuple, 6)
226
- return buf
266
+ scratch[5] = 0x4e // 'N'
267
+ const newLen = encodeTupleDataInto(row, columns, 6)
268
+ return scratch.slice(0, 6 + newLen)
227
269
  }
228
270
 
229
271
  // encode a DELETE message
@@ -232,13 +274,12 @@ export function encodeDelete(
232
274
  oldRow: Record<string, unknown>,
233
275
  columns: ColumnInfo[]
234
276
  ): Uint8Array {
235
- const tuple = encodeTupleData(oldRow, columns)
236
- const buf = new Uint8Array(1 + 4 + 1 + tuple.length)
237
- buf[0] = 0x44 // 'D'
238
- writeInt32(buf, 1, tableOid)
239
- buf[5] = 0x4b // 'K' for key tuple
240
- buf.set(tuple, 6)
241
- return buf
277
+ ensureScratch(1 + 4 + 1 + columns.length * 64)
278
+ scratch[0] = 0x44 // 'D'
279
+ writeInt32At(1, tableOid)
280
+ scratch[5] = 0x4b // 'K' for key tuple
281
+ const tupleLen = encodeTupleDataInto(oldRow, columns, 6)
282
+ return scratch.slice(0, 6 + tupleLen)
242
283
  }
243
284
 
244
285
  // wrap a pgoutput message in XLogData format