@syncular/server 0.26.0 → 0.26.3

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/src/d1-storage.ts CHANGED
@@ -915,12 +915,29 @@ interface D1MigrationClaim {
915
915
  state: string;
916
916
  }
917
917
 
918
+ /** The reads queued for one `D1ServerStorage#batchedAll` round trip. */
919
+ interface PendingReads {
920
+ readonly db: Pick<D1Database, 'prepare' | 'batch'>;
921
+ readonly entries: {
922
+ readonly statement: D1PreparedStatement;
923
+ readonly resolve: (rows: unknown[]) => void;
924
+ readonly reject: (error: unknown) => void;
925
+ }[];
926
+ }
927
+
918
928
  export class D1ServerStorage implements ServerStorage {
919
929
  readonly #db: D1Database;
920
930
  readonly #pushApplySerialized: boolean;
921
931
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
922
932
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
923
933
  #schemaVersion: number | undefined;
934
+ /**
935
+ * Page reads waiting for the end of the current microtask turn. A pull
936
+ * starts the window read (or first snapshot page) of every subscription
937
+ * before awaiting any of them, so they leave as ONE `db.batch` round trip
938
+ * instead of one per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
939
+ */
940
+ #pendingReads: PendingReads | undefined;
924
941
 
925
942
  constructor(db: D1Database, options: D1ServerStorageOptions = {}) {
926
943
  this.#db = db;
@@ -1400,6 +1417,53 @@ export class D1ServerStorage implements ServerStorage {
1400
1417
  }
1401
1418
 
1402
1419
  /** Every protected read or write shares a transaction with its schema check. */
1420
+ /** Queue one read into the current turn's `db.batch` (see `#pendingReads`). */
1421
+ #batchedAll<T>(sql: string, params: unknown[]): Promise<T[]> {
1422
+ let pending = this.#pendingReads;
1423
+ if (pending === undefined) {
1424
+ const created: PendingReads = {
1425
+ db: this.#schemaDatabase(),
1426
+ entries: [],
1427
+ };
1428
+ pending = created;
1429
+ this.#pendingReads = created;
1430
+ queueMicrotask(() => {
1431
+ this.#pendingReads = undefined;
1432
+ created.db.batch(created.entries.map((entry) => entry.statement)).then(
1433
+ (results) => {
1434
+ created.entries.forEach((entry, i) => {
1435
+ const result = results[i];
1436
+ if (
1437
+ typeof result !== 'object' ||
1438
+ result === null ||
1439
+ !('results' in result) ||
1440
+ !Array.isArray(result.results)
1441
+ ) {
1442
+ entry.reject(
1443
+ new Error('D1 query returned an invalid batch result'),
1444
+ );
1445
+ } else {
1446
+ entry.resolve(result.results);
1447
+ }
1448
+ });
1449
+ },
1450
+ (error: unknown) => {
1451
+ for (const entry of created.entries) entry.reject(error);
1452
+ },
1453
+ );
1454
+ });
1455
+ }
1456
+ const target = pending;
1457
+ const statement = target.db.prepare(sql).bind(...params);
1458
+ return new Promise<T[]>((resolve, reject) => {
1459
+ target.entries.push({
1460
+ statement,
1461
+ resolve: (rows) => resolve(rows as T[]),
1462
+ reject,
1463
+ });
1464
+ });
1465
+ }
1466
+
1403
1467
  #schemaDatabase(): Pick<D1Database, 'prepare' | 'batch'> {
1404
1468
  if (this.#schemaVersion === undefined)
1405
1469
  throw new StorageQueryError('sync.storage.schema_changed');
@@ -2086,37 +2150,34 @@ export class D1ServerStorage implements ServerStorage {
2086
2150
  partition: string,
2087
2151
  query: CommitWindowQuery,
2088
2152
  ): Promise<StoredCommit[]> {
2089
- const db = this.#schemaDatabase();
2090
2153
  const variables = Object.keys(query.scopeFilter).sort();
2091
2154
  const firstVariable = variables[0];
2092
2155
  if (firstVariable === undefined) return [];
2093
2156
  const firstValues = query.scopeFilter[firstVariable] ?? [];
2094
2157
  if (firstValues.length === 0) return [];
2095
2158
  // Candidates via the inverted index (one variable) LEFT JOINed to the
2096
- // commit meta + the table's changes — one D1 round trip per page, never
2097
- // two per candidate (see `commitWindowPageSql`). Exact multi-variable
2098
- // verification against the stored scope map in `collectCommitWindowPage`.
2159
+ // commit meta + the table's changes — one statement per page, never two
2160
+ // per candidate (see `commitWindowPageSql`), and concurrent pages share
2161
+ // one D1 round trip. Exact multi-variable verification against the
2162
+ // stored scope map in `collectCommitWindowPage`.
2099
2163
  const sql = commitWindowPageSql(firstValues.length, 'sqlite');
2100
2164
  const commits: StoredCommit[] = [];
2101
2165
  let deliveredChanges = 0;
2102
2166
  let afterSeq = query.afterSeq;
2103
2167
  const batchSize = Math.max(64, query.limitChanges);
2104
2168
  while (deliveredChanges < query.limitChanges) {
2105
- const { results: records } = await db
2106
- .prepare(sql)
2107
- .bind(
2108
- partition,
2109
- query.table,
2110
- firstVariable,
2111
- ...firstValues,
2112
- afterSeq,
2113
- query.throughSeq,
2114
- batchSize,
2115
- partition,
2116
- partition,
2117
- query.table,
2118
- )
2119
- .all<SqliteCommitWindowRecord>();
2169
+ const records = await this.#batchedAll<SqliteCommitWindowRecord>(sql, [
2170
+ partition,
2171
+ query.table,
2172
+ firstVariable,
2173
+ ...firstValues,
2174
+ afterSeq,
2175
+ query.throughSeq,
2176
+ batchSize,
2177
+ partition,
2178
+ partition,
2179
+ query.table,
2180
+ ]);
2120
2181
  if (records.length === 0) break;
2121
2182
  const page = collectCommitWindowPage(
2122
2183
  records,
@@ -2132,12 +2193,12 @@ export class D1ServerStorage implements ServerStorage {
2132
2193
  }
2133
2194
 
2134
2195
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
2135
- const db = this.#schemaDatabase();
2136
2196
  const firstVariable = assertScopeIndexedScan(query);
2137
2197
  const firstValues = query.scopeFilter[firstVariable] ?? [];
2138
2198
  if (firstValues.length === 0) return [];
2139
2199
  // Candidates via the inverted index LEFT JOINed to the row table — one
2140
- // D1 round trip per page, never one per row (see `scanRowPageSql`).
2200
+ // statement per page, never one per row (see `scanRowPageSql`), and
2201
+ // concurrent pages share one D1 round trip.
2141
2202
  // Exact multi-variable verification against the stored scope map below.
2142
2203
  const sql = scanRowPageSql(
2143
2204
  this.table(query.table),
@@ -2148,18 +2209,17 @@ export class D1ServerStorage implements ServerStorage {
2148
2209
  let afterRowId = query.afterRowId ?? '';
2149
2210
  const batchSize = Math.max(64, query.limit);
2150
2211
  while (rows.length < query.limit) {
2151
- const { results: records } = await db
2152
- .prepare(sql)
2153
- .bind(
2154
- partition,
2155
- query.table,
2156
- firstVariable,
2157
- ...firstValues,
2158
- afterRowId,
2159
- batchSize,
2160
- partition,
2161
- )
2162
- .all<SqliteRowRecord & { payload: Uint8Array | null }>();
2212
+ const records = await this.#batchedAll<
2213
+ SqliteRowRecord & { payload: Uint8Array | null }
2214
+ >(sql, [
2215
+ partition,
2216
+ query.table,
2217
+ firstVariable,
2218
+ ...firstValues,
2219
+ afterRowId,
2220
+ batchSize,
2221
+ partition,
2222
+ ]);
2163
2223
  if (records.length === 0) break;
2164
2224
  for (const record of records) {
2165
2225
  afterRowId = record.row_id;
package/src/handler.ts CHANGED
@@ -47,8 +47,9 @@ import {
47
47
  ACCEPT_INLINE_ROWS,
48
48
  clampPullLimits,
49
49
  type PullSectionTrace,
50
+ prepareSections,
51
+ preparedSection,
50
52
  type SubscriptionPlan,
51
- subscriptionSection,
52
53
  } from './pull';
53
54
  import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
54
55
  import { serveNotReadyError } from './readiness';
@@ -542,16 +543,25 @@ async function* streamResponse(
542
543
  const limits = clampPullLimits(plan.pull);
543
544
  const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
544
545
  const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
545
- for (const subscription of plan.subscriptions) {
546
+ const prepared = await prepareSections(
547
+ ctx,
548
+ limits,
549
+ plan.subscriptions,
550
+ maxSeq,
551
+ horizonSeq,
552
+ );
553
+ for (const [index, subscription] of plan.subscriptions.entries()) {
546
554
  const trace: PullSectionTrace | undefined =
547
555
  summaries !== undefined ? { segments: [] } : undefined;
548
- const section = subscriptionSection(
556
+ const reads = prepared[index];
557
+ if (reads === undefined) throw new Error('prepareSections lost a plan');
558
+ const section = preparedSection(
549
559
  ctx,
550
560
  schema,
551
561
  limits,
552
562
  subscription,
553
563
  maxSeq,
554
- horizonSeq,
564
+ reads,
555
565
  trace,
556
566
  plan.logEpoch,
557
567
  );
@@ -53,11 +53,12 @@ import {
53
53
  asNumber,
54
54
  type PgExecutor,
55
55
  type PgQueryable,
56
+ type PgRow,
56
57
  } from './pg-executor';
57
58
  import {
58
59
  assertPhysicalColumns,
59
60
  assertStoredLayouts,
60
- commitWindowPageSql,
61
+ COMMIT_WINDOW_BATCH_SQL,
61
62
  deleteRowSql,
62
63
  dropTableDdl,
63
64
  indexRowPageStatement,
@@ -70,6 +71,7 @@ import {
70
71
  rewriteValues,
71
72
  SCHEMA_META_DDL_POSTGRES,
72
73
  type StoredColumnLayout,
74
+ scanRowBatchSql,
73
75
  scanRowPageSql,
74
76
  schemaDdl,
75
77
  selectRowScopesSql,
@@ -324,28 +326,29 @@ async function readServeGateOn(
324
326
  partition: string,
325
327
  runningSchemaVersion: number,
326
328
  ): Promise<ServeGate> {
327
- const marker = await client.query<{ schema_version: unknown }>(
328
- 'SELECT schema_version FROM sync_schema_meta WHERE id=1',
329
- );
330
- const epoch = await client.query<{ log_epoch: string }>(
331
- 'SELECT log_epoch FROM sync_partition_registry WHERE partition=$1',
332
- [partition],
333
- );
334
- const pending = await client.query<{ partition: string; name: string }>(
335
- `SELECT partition, name FROM sync_backfill_checkpoints
336
- WHERE schema_version=$1 AND state<>'activated'`,
337
- [runningSchemaVersion],
329
+ // One statement: every request reads the gate twice (entry and verify),
330
+ // so its parts share one round trip and one snapshot.
331
+ const { rows } = await client.query<{
332
+ schema_version: unknown;
333
+ log_epoch: string | null;
334
+ pending: unknown;
335
+ }>(
336
+ `SELECT (SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
337
+ (SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
338
+ (SELECT json_agg(json_build_object('partition', partition, 'name', name))
339
+ FROM sync_backfill_checkpoints
340
+ WHERE schema_version=$2 AND state<>'activated') AS pending`,
341
+ [partition, runningSchemaVersion],
338
342
  );
343
+ const row = rows[0];
339
344
  return {
340
- storedSchemaVersion:
341
- marker.rows[0] === undefined
342
- ? 0
343
- : asNumber(marker.rows[0].schema_version),
344
- logEpoch: epoch.rows[0]?.log_epoch,
345
- pending: pending.rows.map((row) => ({
346
- partition: row.partition,
347
- name: row.name,
348
- })),
345
+ // asNumber maps the NULL of a missing marker row to 0.
346
+ storedSchemaVersion: asNumber(row?.schema_version),
347
+ logEpoch: row?.log_epoch ?? undefined,
348
+ pending:
349
+ row?.pending === null || row?.pending === undefined
350
+ ? []
351
+ : asJson<{ partition: string; name: string }[]>(row.pending),
349
352
  };
350
353
  }
351
354
 
@@ -569,7 +572,7 @@ interface ChangeRecord {
569
572
  }
570
573
 
571
574
  /**
572
- * One result row of `commitWindowPageSql` (candidate LEFT JOIN commit meta
575
+ * One result row of `COMMIT_WINDOW_BATCH_SQL` (candidate LEFT JOIN commit meta
573
576
  * LEFT JOIN changes): meta/change columns are NULL when the joined row
574
577
  * vanished (LEFT JOIN contract).
575
578
  */
@@ -1166,16 +1169,76 @@ class RollbackSignal extends Error {
1166
1169
  }
1167
1170
  }
1168
1171
 
1172
+ /** One page read waiting in `PostgresServerStorage#batchedPage`. */
1173
+ interface PendingPage {
1174
+ readonly spec: Record<string, unknown>;
1175
+ readonly resolve: (rows: PgRow[]) => void;
1176
+ readonly reject: (error: unknown) => void;
1177
+ }
1178
+
1169
1179
  export class PostgresServerStorage implements ServerStorage {
1170
1180
  readonly #exec: PgExecutor;
1171
1181
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
1172
1182
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
1173
1183
  #schemaVersion: number | undefined;
1184
+ /**
1185
+ * Page reads waiting for the end of the current microtask turn, keyed by
1186
+ * statement and fixed params. A pull starts the window read (or first
1187
+ * snapshot page) of every subscription before awaiting any of them, so the
1188
+ * reads of one table leave as ONE set-based statement instead of one round
1189
+ * trip per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
1190
+ */
1191
+ readonly #pageBatches = new Map<string, PendingPage[]>();
1174
1192
 
1175
1193
  constructor(exec: PgExecutor) {
1176
1194
  this.#exec = exec;
1177
1195
  }
1178
1196
 
1197
+ /**
1198
+ * Queue one page read into the batch for (`sql`, `partition`, `table`).
1199
+ * The batch runs as a single statement with the queued specs bound as a
1200
+ * JSON array (`$2`); each spec gets its position as `i`, and the result
1201
+ * rows are split back by their `sub` column.
1202
+ */
1203
+ #batchedPage<Row>(
1204
+ sql: string,
1205
+ partition: string,
1206
+ table: string,
1207
+ spec: Record<string, unknown>,
1208
+ ): Promise<Row[]> {
1209
+ const key = `${sql}\u0000${partition}\u0000${table}`;
1210
+ let batch = this.#pageBatches.get(key);
1211
+ if (batch === undefined) {
1212
+ const queued: PendingPage[] = [];
1213
+ batch = queued;
1214
+ this.#pageBatches.set(key, queued);
1215
+ queueMicrotask(() => {
1216
+ this.#pageBatches.delete(key);
1217
+ const specs = JSON.stringify(
1218
+ queued.map((entry, i) => ({ ...entry.spec, i })),
1219
+ );
1220
+ this.#exec.query<PgRow>(sql, [partition, specs, table]).then(
1221
+ ({ rows }) => {
1222
+ const grouped = queued.map((): PgRow[] => []);
1223
+ for (const row of rows) grouped[asNumber(row.sub)]?.push(row);
1224
+ queued.forEach((entry, i) => entry.resolve(grouped[i] ?? []));
1225
+ },
1226
+ (error: unknown) => {
1227
+ for (const entry of queued) entry.reject(error);
1228
+ },
1229
+ );
1230
+ });
1231
+ }
1232
+ const target = batch;
1233
+ return new Promise<Row[]>((resolve, reject) => {
1234
+ target.push({
1235
+ spec,
1236
+ resolve: (rows) => resolve(rows as Row[]),
1237
+ reject,
1238
+ });
1239
+ });
1240
+ }
1241
+
1179
1242
  /** Apply the schema DDL (idempotent). Call once before use. */
1180
1243
  async migrate(): Promise<void> {
1181
1244
  // Split on the statement boundary so drivers that reject multi-statement
@@ -2091,27 +2154,26 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2091
2154
  // Candidates via the inverted index (one variable): the (partition, tbl,
2092
2155
  // var, value, commit_seq) PK makes the candidate subquery an index range
2093
2156
  // scan returning commit_seq already ascending (see postgres-explain
2094
- // .test.ts), LEFT JOINed to the commit meta + the table's changes — one
2095
- // round trip per page, never two per candidate (see
2096
- // `commitWindowPageSql`). Exact multi-variable verification against the
2097
- // stored scope map happens below.
2098
- const sql = commitWindowPageSql(firstValues.length, 'postgres');
2157
+ // .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
2158
+ // of concurrent window reads on the same table share one statement (see
2159
+ // `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
2160
+ // the stored scope map happens below.
2099
2161
  const commits: StoredCommit[] = [];
2100
2162
  let deliveredChanges = 0;
2101
2163
  let afterSeq = query.afterSeq;
2102
2164
  const batchSize = Math.max(64, query.limitChanges);
2103
2165
  while (deliveredChanges < query.limitChanges) {
2104
- const { rows: records } = await this.#exec.query<CommitWindowRecord>(
2105
- sql,
2106
- [
2107
- partition,
2108
- query.table,
2109
- firstVariable,
2110
- ...firstValues,
2111
- afterSeq,
2112
- query.throughSeq,
2113
- batchSize,
2114
- ],
2166
+ const records = await this.#batchedPage<CommitWindowRecord>(
2167
+ COMMIT_WINDOW_BATCH_SQL,
2168
+ partition,
2169
+ query.table,
2170
+ {
2171
+ var: firstVariable,
2172
+ vals: firstValues,
2173
+ after: afterSeq,
2174
+ through: query.throughSeq,
2175
+ lim: batchSize,
2176
+ },
2115
2177
  );
2116
2178
  if (records.length === 0) break;
2117
2179
  // Fold the page: rows arrive ordered (commit_seq, idx); consecutive
@@ -2170,25 +2232,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2170
2232
  if (firstValues.length === 0) return [];
2171
2233
  // Candidates via the inverted index (ordered + LIMITed at the covering
2172
2234
  // PK) LEFT JOINed to the row table — one round trip per page, never one
2173
- // per row (see `scanRowPageSql`). Exact multi-variable verification
2235
+ // per row, and pages of concurrent scans on the same table share one
2236
+ // statement (see `scanRowBatchSql`). Exact multi-variable verification
2174
2237
  // against the stored scope map happens below.
2175
- const sql = scanRowPageSql(
2176
- this.table(query.table),
2177
- firstValues.length,
2178
- 'postgres',
2179
- );
2238
+ const sql = scanRowBatchSql(this.table(query.table));
2180
2239
  const rows: StoredRow[] = [];
2181
2240
  let afterRowId = query.afterRowId ?? '';
2182
2241
  const batchSize = Math.max(64, query.limit);
2183
2242
  while (rows.length < query.limit) {
2184
- const { rows: records } = await this.#exec.query<RowRecord>(sql, [
2243
+ const records = await this.#batchedPage<RowRecord>(
2244
+ sql,
2185
2245
  partition,
2186
2246
  query.table,
2187
- firstVariable,
2188
- ...firstValues,
2189
- afterRowId,
2190
- batchSize,
2191
- ]);
2247
+ {
2248
+ var: firstVariable,
2249
+ vals: firstValues,
2250
+ after: afterRowId,
2251
+ lim: batchSize,
2252
+ },
2253
+ );
2192
2254
  if (records.length === 0) break;
2193
2255
  for (const record of records) {
2194
2256
  afterRowId = record.row_id;