orez 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pg-proxy-browser.d.ts +31 -0
- package/dist/pg-proxy-browser.d.ts.map +1 -1
- package/dist/pg-proxy-browser.js +146 -16
- package/dist/pg-proxy-browser.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +12 -5
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/handler.d.ts +13 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +57 -1
- package/dist/replication/handler.js.map +1 -1
- package/package.json +2 -2
- package/src/pg-proxy-browser.singledb.test.ts +157 -0
- package/src/pg-proxy-browser.ts +188 -17
- package/src/pg-proxy.ts +12 -5
- package/src/replication/handler.test.ts +70 -0
- package/src/replication/handler.ts +64 -1
package/src/pg-proxy-browser.ts
CHANGED
|
@@ -245,6 +245,41 @@ function extractParseQuery(data: Uint8Array): string | null {
|
|
|
245
245
|
return textDecoder.decode(data.subarray(queryStart, offset))
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// parse postgres ErrorResponse fields. expects `data` to be the full
|
|
249
|
+
// ErrorResponse message (including type byte and length).
|
|
250
|
+
// returns { code, message } extracted from 'C' and 'M' fields.
|
|
251
|
+
function parseErrorFields(
|
|
252
|
+
data: Uint8Array,
|
|
253
|
+
start: number,
|
|
254
|
+
length: number
|
|
255
|
+
): {
|
|
256
|
+
code: string
|
|
257
|
+
message: string
|
|
258
|
+
severity: string
|
|
259
|
+
} {
|
|
260
|
+
let code = ''
|
|
261
|
+
let message = ''
|
|
262
|
+
let severity = ''
|
|
263
|
+
// body starts after the 1-byte type + 4-byte length
|
|
264
|
+
let pos = start + 5
|
|
265
|
+
const end = start + 1 + length
|
|
266
|
+
while (pos < end) {
|
|
267
|
+
const fieldType = data[pos]
|
|
268
|
+
if (fieldType === 0) break
|
|
269
|
+
pos++
|
|
270
|
+
const strStart = pos
|
|
271
|
+
while (pos < end && data[pos] !== 0) pos++
|
|
272
|
+
const value = textDecoder.decode(data.subarray(strStart, pos))
|
|
273
|
+
pos++
|
|
274
|
+
if (fieldType === 0x43)
|
|
275
|
+
code = value // 'C' SQLSTATE
|
|
276
|
+
else if (fieldType === 0x4d)
|
|
277
|
+
message = value // 'M' message
|
|
278
|
+
else if (fieldType === 0x53) severity = value // 'S' severity
|
|
279
|
+
}
|
|
280
|
+
return { code, message, severity }
|
|
281
|
+
}
|
|
282
|
+
|
|
248
283
|
/**
|
|
249
284
|
* rebuild a Parse message with a modified query string.
|
|
250
285
|
*/
|
|
@@ -704,14 +739,50 @@ function messagePortToDuplex(port: MessagePort): {
|
|
|
704
739
|
return { duplex: { readable, writable }, rawWrite }
|
|
705
740
|
}
|
|
706
741
|
|
|
742
|
+
/**
|
|
743
|
+
* wire-protocol database name. anything not matching `zero_cvr` / `zero_cdb`
|
|
744
|
+
* routes to postgres (see `getDbContext`).
|
|
745
|
+
*/
|
|
746
|
+
export type BrowserProxyDbName = 'postgres' | 'zero_cvr' | 'zero_cdb'
|
|
747
|
+
|
|
707
748
|
export interface BrowserProxy {
|
|
708
749
|
handleConnection(port: MessagePort): void
|
|
709
750
|
close(): void
|
|
751
|
+
/**
|
|
752
|
+
* mutex-coordinated query for out-of-band JSON callers (e.g. soot's
|
|
753
|
+
* project-server / main-thread SAB JSON channels). takes the same per-db
|
|
754
|
+
* mutex the wire-protocol path uses and rescues a stale aborted txn before
|
|
755
|
+
* running. critical in singleDb mode where wire-protocol roles share a
|
|
756
|
+
* single PGlite session with these external callers — without going through
|
|
757
|
+
* here, an external query can land on an `E`-state session left by a
|
|
758
|
+
* different role and fail with "current transaction is aborted".
|
|
759
|
+
*/
|
|
760
|
+
query(
|
|
761
|
+
dbName: BrowserProxyDbName,
|
|
762
|
+
sql: string,
|
|
763
|
+
params?: unknown[]
|
|
764
|
+
): Promise<{ rows: unknown[]; affectedRows?: number }>
|
|
765
|
+
exec(dbName: BrowserProxyDbName, sql: string): Promise<Array<{ affectedRows: number }>>
|
|
710
766
|
}
|
|
711
767
|
|
|
712
768
|
export async function createBrowserProxy(
|
|
713
769
|
dbInput: PGlite | PGliteInstances,
|
|
714
|
-
config: {
|
|
770
|
+
config: {
|
|
771
|
+
pgPassword: string
|
|
772
|
+
pgUser: string
|
|
773
|
+
pgPort?: number
|
|
774
|
+
logLevel?: string
|
|
775
|
+
/**
|
|
776
|
+
* declares that postgres/cvr/cdb are all backed by the same underlying
|
|
777
|
+
* PGlite, even if the references are distinct proxy wrappers. forces
|
|
778
|
+
* mutex coalescing so concurrent protocol messages cannot interleave on
|
|
779
|
+
* the shared instance (named-statement collisions, replication-slot races).
|
|
780
|
+
* defaults to autodetect via reference equality — set explicitly when the
|
|
781
|
+
* caller wraps one PGlite in three thin façades (e.g. orez-web's port
|
|
782
|
+
* proxies) that fool the reference check.
|
|
783
|
+
*/
|
|
784
|
+
singleDb?: boolean
|
|
785
|
+
}
|
|
715
786
|
): Promise<BrowserProxy> {
|
|
716
787
|
// normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
|
|
717
788
|
const instances: PGliteInstances =
|
|
@@ -727,8 +798,11 @@ export async function createBrowserProxy(
|
|
|
727
798
|
// per-instance mutexes for serializing pglite access.
|
|
728
799
|
// when all instances are the same object (single-db mode), share one mutex
|
|
729
800
|
// to prevent concurrent protocol messages on the same pglite instance.
|
|
801
|
+
// explicit singleDb wins over reference equality — callers like orez-web
|
|
802
|
+
// hand us three distinct wrapper objects that all point at one PGlite.
|
|
730
803
|
const sharedInstance =
|
|
731
|
-
|
|
804
|
+
config.singleDb === true ||
|
|
805
|
+
(instances.postgres === instances.cvr && instances.postgres === instances.cdb)
|
|
732
806
|
const pgMutex = new Mutex()
|
|
733
807
|
const mutexes = {
|
|
734
808
|
postgres: pgMutex,
|
|
@@ -737,11 +811,18 @@ export async function createBrowserProxy(
|
|
|
737
811
|
}
|
|
738
812
|
|
|
739
813
|
// per-instance transaction state: tracks which connection owns the current transaction
|
|
740
|
-
// so we can auto-ROLLBACK stale aborted transactions from other connections
|
|
814
|
+
// so we can auto-ROLLBACK stale aborted transactions from other connections.
|
|
815
|
+
// shared-instance (singleDb) coalesces txState too — pglite is single-session,
|
|
816
|
+
// so an 'E' state from role A's aborted txn poisons every subsequent query
|
|
817
|
+
// unless role B can see "yes there's an aborted txn here" and ROLLBACK before
|
|
818
|
+
// its own work. without coalesce, each role's per-role txState reads idle
|
|
819
|
+
// even when pglite's actual session is in E, and the rescue at every
|
|
820
|
+
// mutex.acquire() site never fires for the foreign role.
|
|
821
|
+
const pgTxState: PgLiteTxState = { status: 0x49, owner: null }
|
|
741
822
|
const txStates: Record<string, PgLiteTxState> = {
|
|
742
|
-
postgres:
|
|
743
|
-
cvr: { status: 0x49, owner: null },
|
|
744
|
-
cdb: { status: 0x49, owner: null },
|
|
823
|
+
postgres: pgTxState,
|
|
824
|
+
cvr: sharedInstance ? pgTxState : { status: 0x49, owner: null },
|
|
825
|
+
cdb: sharedInstance ? pgTxState : { status: 0x49, owner: null },
|
|
745
826
|
}
|
|
746
827
|
|
|
747
828
|
// helper to get instance + mutex + tx state for a database name
|
|
@@ -943,6 +1024,9 @@ export async function createBrowserProxy(
|
|
|
943
1024
|
let pipelineMutexHeld = false
|
|
944
1025
|
let extWritePending = false
|
|
945
1026
|
let pipelineBuffer: Uint8Array[] = []
|
|
1027
|
+
// remember the most recent Parse / SimpleQuery for diagnostic logging when
|
|
1028
|
+
// an ErrorResponse fires. trimmed to 200 chars so we don't blow up logs.
|
|
1029
|
+
let lastQueryForDiag = ''
|
|
946
1030
|
|
|
947
1031
|
function installQueryHandler() {
|
|
948
1032
|
// message buffer: postgres sends multiple protocol messages in one write,
|
|
@@ -1108,11 +1192,15 @@ export async function createBrowserProxy(
|
|
|
1108
1192
|
}
|
|
1109
1193
|
}
|
|
1110
1194
|
|
|
1111
|
-
// detect writes for replication signaling
|
|
1112
|
-
if (
|
|
1113
|
-
const
|
|
1114
|
-
if (
|
|
1115
|
-
|
|
1195
|
+
// detect writes for replication signaling and remember last query
|
|
1196
|
+
if (msgType === 0x50) {
|
|
1197
|
+
const parsed = extractParseQuery(data)
|
|
1198
|
+
if (parsed) lastQueryForDiag = parsed.slice(0, 200)
|
|
1199
|
+
if (dbName === 'postgres') {
|
|
1200
|
+
const q = parsed?.trimStart().toLowerCase()
|
|
1201
|
+
if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
|
|
1202
|
+
extWritePending = true
|
|
1203
|
+
}
|
|
1116
1204
|
}
|
|
1117
1205
|
}
|
|
1118
1206
|
|
|
@@ -1195,9 +1283,12 @@ export async function createBrowserProxy(
|
|
|
1195
1283
|
const t = result[pos]
|
|
1196
1284
|
const l = readInt32BE(result, pos + 1)
|
|
1197
1285
|
if (t === 0x45) {
|
|
1198
|
-
|
|
1286
|
+
const errFields = parseErrorFields(result, pos, l)
|
|
1199
1287
|
console.warn(
|
|
1200
|
-
`[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq}`
|
|
1288
|
+
`[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq} ` +
|
|
1289
|
+
`code=${errFields.code} severity=${errFields.severity} ` +
|
|
1290
|
+
`msg=${JSON.stringify(errFields.message)} ` +
|
|
1291
|
+
`query=${JSON.stringify(lastQueryForDiag)}`
|
|
1201
1292
|
)
|
|
1202
1293
|
}
|
|
1203
1294
|
pos += 1 + l
|
|
@@ -1287,19 +1378,30 @@ export async function createBrowserProxy(
|
|
|
1287
1378
|
// connection closed flag
|
|
1288
1379
|
let connClosed = false
|
|
1289
1380
|
|
|
1290
|
-
// clean up pglite transaction state when the connection ends
|
|
1381
|
+
// clean up pglite transaction state when the connection ends.
|
|
1382
|
+
// CRITICAL: only ROLLBACK if THIS connection owns the current pglite
|
|
1383
|
+
// transaction. pglite is single-session and the singleDb path coalesces
|
|
1384
|
+
// txState across roles, so an unconditional ROLLBACK here clobbers any
|
|
1385
|
+
// OTHER connection's active transaction — exactly the cross-role
|
|
1386
|
+
// pollution that 2eb1873 fixed on the node side. browser cleanup was
|
|
1387
|
+
// missed and showed up as "current transaction is aborted, commands
|
|
1388
|
+
// ignored" when a foreign connection closed mid-someone-else's-txn.
|
|
1291
1389
|
const cleanup = async () => {
|
|
1292
1390
|
if (connClosed) return
|
|
1293
1391
|
connClosed = true
|
|
1294
1392
|
// replication connections don't own a transaction — skip ROLLBACK
|
|
1295
1393
|
if (isReplicationConnection) return
|
|
1296
1394
|
try {
|
|
1297
|
-
const { db, mutex } = getDbContext(dbName)
|
|
1395
|
+
const { db, mutex, txState } = getDbContext(dbName)
|
|
1298
1396
|
await mutex.acquire()
|
|
1299
1397
|
try {
|
|
1300
|
-
|
|
1398
|
+
if (txState.owner === connId && txState.status !== 0x49) {
|
|
1399
|
+
await db.exec('ROLLBACK')
|
|
1400
|
+
txState.status = 0x49
|
|
1401
|
+
txState.owner = null
|
|
1402
|
+
}
|
|
1301
1403
|
} catch {
|
|
1302
|
-
//
|
|
1404
|
+
// db is closed or rollback failed — ignore
|
|
1303
1405
|
} finally {
|
|
1304
1406
|
mutex.release()
|
|
1305
1407
|
}
|
|
@@ -1619,12 +1721,81 @@ export async function createBrowserProxy(
|
|
|
1619
1721
|
}
|
|
1620
1722
|
}
|
|
1621
1723
|
|
|
1724
|
+
// rescue any stale aborted txn left on the shared session before running an
|
|
1725
|
+
// out-of-band JSON query. external callers don't own a transaction (each
|
|
1726
|
+
// call is a single statement), so we always rescue when status is 'E' —
|
|
1727
|
+
// there's no "is this mine" check like the wire path has.
|
|
1728
|
+
async function rescueStaleAborted(db: PGlite, txState: PgLiteTxState) {
|
|
1729
|
+
if (txState.status === 0x45) {
|
|
1730
|
+
try {
|
|
1731
|
+
await db.exec('ROLLBACK')
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
// surfaced for diagnosis only — a failing ROLLBACK still leaves us in
|
|
1734
|
+
// a state where the next statement will get 25P02; that's the signal
|
|
1735
|
+
// the caller needs anyway.
|
|
1736
|
+
log.proxy(
|
|
1737
|
+
`[pg-proxy] rescueStaleAborted: ROLLBACK failed: ${(err as Error)?.message || err}`
|
|
1738
|
+
)
|
|
1739
|
+
}
|
|
1740
|
+
txState.status = 0x49
|
|
1741
|
+
txState.owner = null
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// detect writes that need replication signaling. wire path does this on
|
|
1746
|
+
// postgres-bound INSERT/UPDATE/DELETE/COPY/TRUNCATE — same rule applies to
|
|
1747
|
+
// out-of-band callers. without it, a project-server write via SAB JSON
|
|
1748
|
+
// wouldn't wake up the replication stream until the next wire-protocol
|
|
1749
|
+
// write came along.
|
|
1750
|
+
function maybeSignalWriteForExternal(dbName: string, sql: string) {
|
|
1751
|
+
if (dbName !== 'postgres' && dbName !== '') return
|
|
1752
|
+
const head = sql.trimStart().toLowerCase()
|
|
1753
|
+
if (/^(insert|update|delete|copy|truncate)/.test(head)) {
|
|
1754
|
+
signalWrite()
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function externalQuery(dbName: string, sql: string, params?: unknown[]) {
|
|
1759
|
+
if (closed) throw new Error('pg-proxy is closed')
|
|
1760
|
+
const { db, mutex, txState } = getDbContext(dbName)
|
|
1761
|
+
await mutex.acquire()
|
|
1762
|
+
try {
|
|
1763
|
+
await rescueStaleAborted(db, txState)
|
|
1764
|
+
const result = await db.query(sql, params as Array<unknown> | undefined)
|
|
1765
|
+
maybeSignalWriteForExternal(dbName, sql)
|
|
1766
|
+
return {
|
|
1767
|
+
rows: result.rows as unknown[],
|
|
1768
|
+
affectedRows: result.affectedRows,
|
|
1769
|
+
}
|
|
1770
|
+
} finally {
|
|
1771
|
+
mutex.release()
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
async function externalExec(dbName: string, sql: string) {
|
|
1776
|
+
if (closed) throw new Error('pg-proxy is closed')
|
|
1777
|
+
const { db, mutex, txState } = getDbContext(dbName)
|
|
1778
|
+
await mutex.acquire()
|
|
1779
|
+
try {
|
|
1780
|
+
await rescueStaleAborted(db, txState)
|
|
1781
|
+
const result = await db.exec(sql)
|
|
1782
|
+
maybeSignalWriteForExternal(dbName, sql)
|
|
1783
|
+
return result.map((r: { affectedRows?: number }) => ({
|
|
1784
|
+
affectedRows: r.affectedRows ?? 0,
|
|
1785
|
+
}))
|
|
1786
|
+
} finally {
|
|
1787
|
+
mutex.release()
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1622
1791
|
return {
|
|
1623
1792
|
handleConnection,
|
|
1624
1793
|
close() {
|
|
1625
1794
|
closed = true
|
|
1626
1795
|
signalPending = false
|
|
1627
1796
|
},
|
|
1797
|
+
query: externalQuery,
|
|
1798
|
+
exec: externalExec,
|
|
1628
1799
|
}
|
|
1629
1800
|
}
|
|
1630
1801
|
|
package/src/pg-proxy.ts
CHANGED
|
@@ -560,8 +560,11 @@ export async function startPgProxy(
|
|
|
560
560
|
// per-instance mutexes for serializing pglite access.
|
|
561
561
|
// when all instances are the same object (single-db mode), share one mutex
|
|
562
562
|
// to prevent concurrent protocol messages on the same pglite instance.
|
|
563
|
+
// explicit config.singleDb wins over reference equality — callers that wrap
|
|
564
|
+
// one PGlite in three distinct façades still need coalesced mutexes.
|
|
563
565
|
const sharedInstance =
|
|
564
|
-
|
|
566
|
+
config.singleDb === true ||
|
|
567
|
+
(instances.postgres === instances.cvr && instances.postgres === instances.cdb)
|
|
565
568
|
const pgMutex = new Mutex()
|
|
566
569
|
const mutexes = {
|
|
567
570
|
postgres: pgMutex,
|
|
@@ -570,11 +573,15 @@ export async function startPgProxy(
|
|
|
570
573
|
}
|
|
571
574
|
|
|
572
575
|
// per-instance transaction state: tracks which socket owns the current transaction
|
|
573
|
-
// so we can auto-ROLLBACK stale aborted transactions from other connections
|
|
576
|
+
// so we can auto-ROLLBACK stale aborted transactions from other connections.
|
|
577
|
+
// shared-instance (singleDb) coalesces txState — pglite is single-session,
|
|
578
|
+
// so an 'E' state from role A's aborted txn poisons every subsequent query
|
|
579
|
+
// unless role B can see the aborted state and ROLLBACK before its own work.
|
|
580
|
+
const pgTxState: PgLiteTxState = { status: 0x49, owner: null }
|
|
574
581
|
const txStates: Record<string, PgLiteTxState> = {
|
|
575
|
-
postgres:
|
|
576
|
-
cvr: { status: 0x49, owner: null },
|
|
577
|
-
cdb: { status: 0x49, owner: null },
|
|
582
|
+
postgres: pgTxState,
|
|
583
|
+
cvr: sharedInstance ? pgTxState : { status: 0x49, owner: null },
|
|
584
|
+
cdb: sharedInstance ? pgTxState : { status: 0x49, owner: null },
|
|
578
585
|
}
|
|
579
586
|
|
|
580
587
|
// helper to get instance + mutex + tx state for a database name
|
|
@@ -4,8 +4,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
4
4
|
import { Mutex } from '../mutex'
|
|
5
5
|
import { installChangeTracking } from './change-tracker'
|
|
6
6
|
import {
|
|
7
|
+
extractStartLsn,
|
|
7
8
|
handleReplicationQuery,
|
|
8
9
|
handleStartReplication,
|
|
10
|
+
lsnFromString,
|
|
9
11
|
resetReplicationState,
|
|
10
12
|
signalReplicationChange,
|
|
11
13
|
type ReplicationWriter,
|
|
@@ -404,3 +406,71 @@ describe('InProcessWriter', () => {
|
|
|
404
406
|
expect(rw.closed).toBe(false)
|
|
405
407
|
})
|
|
406
408
|
})
|
|
409
|
+
|
|
410
|
+
describe('lsnFromString', () => {
|
|
411
|
+
it('parses 0/0 to 0n', () => {
|
|
412
|
+
expect(lsnFromString('0/0')).toBe(0n)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('parses simple LSN', () => {
|
|
416
|
+
expect(lsnFromString('0/1000000')).toBe(0x1000000n)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('combines high and low halves', () => {
|
|
420
|
+
expect(lsnFromString('1/0')).toBe(0x100000000n)
|
|
421
|
+
expect(lsnFromString('1/1')).toBe(0x100000001n)
|
|
422
|
+
expect(lsnFromString('A/B')).toBe(0xa0000000bn)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('is case-insensitive', () => {
|
|
426
|
+
expect(lsnFromString('0/ff')).toBe(0xffn)
|
|
427
|
+
expect(lsnFromString('0/FF')).toBe(0xffn)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('tolerates surrounding whitespace', () => {
|
|
431
|
+
expect(lsnFromString(' 0/100 ')).toBe(0x100n)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('returns null for malformed input', () => {
|
|
435
|
+
expect(lsnFromString('0')).toBeNull()
|
|
436
|
+
expect(lsnFromString('0/')).toBeNull()
|
|
437
|
+
expect(lsnFromString('/0')).toBeNull()
|
|
438
|
+
expect(lsnFromString('xyz')).toBeNull()
|
|
439
|
+
expect(lsnFromString('')).toBeNull()
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
describe('extractStartLsn', () => {
|
|
444
|
+
it('extracts from a basic START_REPLICATION query', () => {
|
|
445
|
+
expect(extractStartLsn('START_REPLICATION SLOT "zero" LOGICAL 0/01000300')).toBe(
|
|
446
|
+
0x1000300n
|
|
447
|
+
)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('handles trailing options', () => {
|
|
451
|
+
expect(
|
|
452
|
+
extractStartLsn(
|
|
453
|
+
`START_REPLICATION SLOT "zero" LOGICAL 0/01000300 (proto_version '4', publication_names 'orez_zero_public')`
|
|
454
|
+
)
|
|
455
|
+
).toBe(0x1000300n)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('handles 0/0 (fresh slot)', () => {
|
|
459
|
+
expect(extractStartLsn('START_REPLICATION SLOT "zero" LOGICAL 0/0')).toBe(0n)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('handles quoted LSN', () => {
|
|
463
|
+
expect(extractStartLsn(`START_REPLICATION SLOT "zero" LOGICAL '0/01000300'`)).toBe(
|
|
464
|
+
0x1000300n
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('is case-insensitive on the keyword', () => {
|
|
469
|
+
expect(extractStartLsn('start_replication slot "z" logical 0/abc')).toBe(0xabcn)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('returns null when no LSN is present', () => {
|
|
473
|
+
expect(extractStartLsn('START_REPLICATION SLOT "z"')).toBeNull()
|
|
474
|
+
expect(extractStartLsn('IDENTIFY_SYSTEM')).toBeNull()
|
|
475
|
+
})
|
|
476
|
+
})
|
|
@@ -133,6 +133,30 @@ function lsnToString(lsn: bigint): string {
|
|
|
133
133
|
return `${high.toString(16).toUpperCase()}/${low.toString(16).toUpperCase()}`
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* parse an LSN string like "0/01000300" into a bigint.
|
|
138
|
+
* returns null if the string doesn't match (e.g. "0/0" still parses to 0n).
|
|
139
|
+
*/
|
|
140
|
+
function lsnFromString(s: string): bigint | null {
|
|
141
|
+
const m = s.trim().match(/^([0-9a-f]+)\/([0-9a-f]+)$/i)
|
|
142
|
+
if (!m) return null
|
|
143
|
+
return (BigInt('0x' + m[1]) << 32n) | BigInt('0x' + m[2])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* extract the client-supplied LSN from a START_REPLICATION query.
|
|
148
|
+
* format: START_REPLICATION SLOT name LOGICAL <high>/<low> [proto_version 'N', publication_names 'X']
|
|
149
|
+
* accepts optional surrounding quotes (some clients send `LOGICAL '0/0'`).
|
|
150
|
+
* returns null if no parseable LSN is found.
|
|
151
|
+
*/
|
|
152
|
+
export function extractStartLsn(query: string): bigint | null {
|
|
153
|
+
const m = query.match(/\bLOGICAL\s+'?([0-9a-f]+\/[0-9a-f]+)'?/i)
|
|
154
|
+
if (!m) return null
|
|
155
|
+
return lsnFromString(m[1])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { lsnFromString }
|
|
159
|
+
|
|
136
160
|
function nowMicros(): bigint {
|
|
137
161
|
return BigInt(Date.now()) * 1000n
|
|
138
162
|
}
|
|
@@ -359,6 +383,23 @@ export async function handleStartReplication(
|
|
|
359
383
|
): Promise<void> {
|
|
360
384
|
log.debug.repl('entering streaming mode')
|
|
361
385
|
|
|
386
|
+
// honor zero-cache's resume LSN. without this, after a page reload the
|
|
387
|
+
// in-memory currentLsn / lastStreamedWatermark are reset to defaults but
|
|
388
|
+
// changeLog persists with prior LSNs — re-streaming from BIGINT 0 makes
|
|
389
|
+
// the change-streamer try to INSERT (watermark, pos) tuples that already
|
|
390
|
+
// exist, hitting `changeLog_pkey` violations and tearing down the loop.
|
|
391
|
+
// by advancing currentLsn past the client's last-seen LSN we guarantee
|
|
392
|
+
// newly-emitted batches use strictly higher LSNs, and by jumping
|
|
393
|
+
// lastStreamedWatermark to the current sequence value we skip already-
|
|
394
|
+
// streamed _zero_changes rows.
|
|
395
|
+
const clientStartLsn = extractStartLsn(query)
|
|
396
|
+
if (clientStartLsn !== null && clientStartLsn > currentLsn) {
|
|
397
|
+
log.debug.repl(
|
|
398
|
+
`advancing currentLsn ${lsnToString(currentLsn)} → ${lsnToString(clientStartLsn)} from client START_REPLICATION`
|
|
399
|
+
)
|
|
400
|
+
currentLsn = clientStartLsn
|
|
401
|
+
}
|
|
402
|
+
|
|
362
403
|
// send CopyBothResponse to enter streaming mode
|
|
363
404
|
const copyBoth = new Uint8Array(1 + 4 + 1 + 2)
|
|
364
405
|
copyBoth[0] = 0x57 // 'W' CopyBothResponse
|
|
@@ -368,7 +409,29 @@ export async function handleStartReplication(
|
|
|
368
409
|
writer.write(copyBoth)
|
|
369
410
|
|
|
370
411
|
// resume from where the previous handler left off to avoid
|
|
371
|
-
// replaying already-streamed changes after reconnect
|
|
412
|
+
// replaying already-streamed changes after reconnect.
|
|
413
|
+
// when client supplied a NON-ZERO LSN (i.e. this is a reconnect to an
|
|
414
|
+
// existing slot with prior progress), also bump lastStreamedWatermark to
|
|
415
|
+
// the current sequence value — anything before that has already been
|
|
416
|
+
// written to changeLog, so re-streaming would just produce duplicate-key
|
|
417
|
+
// errors. `0/0` indicates "fresh slot" and must NOT trigger this jump,
|
|
418
|
+
// otherwise we'd skip rows that legitimately need to be streamed for the
|
|
419
|
+
// initial sync.
|
|
420
|
+
if (clientStartLsn !== null && clientStartLsn > 0n) {
|
|
421
|
+
try {
|
|
422
|
+
const currentWm = await getCurrentWatermark(db)
|
|
423
|
+
if (currentWm > lastStreamedWatermark) {
|
|
424
|
+
log.debug.repl(
|
|
425
|
+
`advancing lastStreamedWatermark ${lastStreamedWatermark} → ${currentWm} on reconnect`
|
|
426
|
+
)
|
|
427
|
+
lastStreamedWatermark = currentWm
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log.repl(
|
|
431
|
+
`getCurrentWatermark failed on reconnect: ${(err as Error)?.message || err}`
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
372
435
|
let lastWatermark = lastStreamedWatermark
|
|
373
436
|
|
|
374
437
|
// use cached setup results on reconnect to avoid holding the mutex
|