@dbos-inc/dbos-sdk 4.20.7-preview → 4.20.9-preview

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.
@@ -386,8 +386,14 @@ class SystemDatabase {
386
386
  workflowEventsMap = new NotificationMap();
387
387
  streamsMap = new NotificationMap();
388
388
  customPool = false;
389
+ /**
390
+ * Caps how many DB-backed polling reads (from wait operations) may run
391
+ * concurrently against the pool, so a polling storm cannot check out every
392
+ * client and starve control-plane operations. See {@link #pollWithLimiter}.
393
+ */
394
+ pollLimiter;
389
395
  runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
390
- constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true) {
396
+ constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency) {
391
397
  this.systemDatabaseUrl = systemDatabaseUrl;
392
398
  this.logger = logger;
393
399
  this.serializer = serializer;
@@ -406,6 +412,11 @@ class SystemDatabase {
406
412
  };
407
413
  this.pool = new pg_1.Pool(systemPoolConfig);
408
414
  }
415
+ // Default the polling limit to half the pool (minimum 1), reserving the rest
416
+ // of the pool for control-plane operations.
417
+ const effectivePoolSize = this.pool.options.max ?? sysDbPoolSize;
418
+ const pollingLimit = pollingConcurrency ?? Math.max(1, Math.floor(effectivePoolSize / 2));
419
+ this.pollLimiter = new utils_1.Semaphore(pollingLimit);
409
420
  this.pool.on('error', (err) => {
410
421
  this.logger.warn(`Unexpected error in pool: ${err}`);
411
422
  });
@@ -1169,16 +1180,38 @@ class SystemDatabase {
1169
1180
  //throw new Error('Message notification map is not empty - shutdown is not clean.');
1170
1181
  }
1171
1182
  }
1183
+ /**
1184
+ * Run a DB-backed polling read under the polling concurrency limiter, so that
1185
+ * high-fan-out wait loops cannot check out every pool client and starve
1186
+ * control-plane operations. Only polling reads should go through here;
1187
+ * control-plane work hits the pool directly and bypasses the limiter.
1188
+ */
1189
+ #pollWithLimiter(query) {
1190
+ return this.pollLimiter.runExclusive(query);
1191
+ }
1192
+ /**
1193
+ * Cancellation check for use inside polling wait loops: the status read runs
1194
+ * under the polling limiter so it counts against the same concurrency budget
1195
+ * as the rest of the loop's reads.
1196
+ */
1197
+ async #checkIfCanceledLimited(workflowID) {
1198
+ await this.#pollWithLimiter(() => this.#checkIfCanceled(this.pool, workflowID));
1199
+ }
1172
1200
  async awaitWorkflowResult(workflowID, timeoutSeconds, callerID, timerFuncID, pollingIntervalMs) {
1173
1201
  const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
1174
1202
  let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
1175
1203
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
1204
+ // Record the durable timeout deadline once before polling. #durableSleep persists it on the
1205
+ // first call and reads back the same value on recovery, so it never changes across iterations.
1206
+ if (timerFuncID !== undefined && callerID !== undefined && timeoutms !== undefined) {
1207
+ finishTime = await this.#durableSleep(callerID, timerFuncID, timeoutms);
1208
+ }
1176
1209
  while (true) {
1177
1210
  if (callerID)
1178
- await this.checkIfCanceled(callerID);
1211
+ await this.#checkIfCanceledLimited(callerID);
1179
1212
  try {
1180
- const { rows } = await this.pool.query(`SELECT status, output, error, serialization FROM "${this.schemaName}".workflow_status
1181
- WHERE workflow_uuid=$1`, [workflowID]);
1213
+ const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output, error, serialization FROM "${this.schemaName}".workflow_status
1214
+ WHERE workflow_uuid=$1`, [workflowID]));
1182
1215
  if (rows.length > 0) {
1183
1216
  const status = rows[0].status;
1184
1217
  if (status === workflow_1.StatusString.SUCCESS) {
@@ -1206,9 +1239,6 @@ class SystemDatabase {
1206
1239
  const ct = Date.now();
1207
1240
  if (finishTime && ct > finishTime)
1208
1241
  return undefined; // Time's up
1209
- if (timerFuncID !== undefined && callerID !== undefined && timeoutms !== undefined) {
1210
- finishTime = await this.#durableSleep(callerID, timerFuncID, timeoutms);
1211
- }
1212
1242
  let poll = finishTime ? finishTime - Date.now() : pollIntervalMs;
1213
1243
  poll = Math.min(pollIntervalMs, poll);
1214
1244
  await (0, utils_1.sleepms)(poll);
@@ -1219,11 +1249,11 @@ class SystemDatabase {
1219
1249
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
1220
1250
  while (true) {
1221
1251
  if (callerID)
1222
- await this.checkIfCanceled(callerID);
1223
- const { rows } = await this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1224
- WHERE workflow_uuid IN (${placeholders})
1225
- AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')
1226
- LIMIT 1`, workflowIds);
1252
+ await this.#checkIfCanceledLimited(callerID);
1253
+ const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1254
+ WHERE workflow_uuid IN (${placeholders})
1255
+ AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')
1256
+ LIMIT 1`, workflowIds));
1227
1257
  if (rows.length > 0) {
1228
1258
  return rows[0].workflow_uuid;
1229
1259
  }
@@ -1236,10 +1266,10 @@ class SystemDatabase {
1236
1266
  while (remainingWorkflowIds.size > 0) {
1237
1267
  const currentWorkflowIds = [...remainingWorkflowIds];
1238
1268
  if (callerID)
1239
- await this.checkIfCanceled(callerID);
1240
- const { rows } = await this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1241
- WHERE workflow_uuid = ANY($1::text[])
1242
- AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')`, [currentWorkflowIds]);
1269
+ await this.#checkIfCanceledLimited(callerID);
1270
+ const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1271
+ WHERE workflow_uuid = ANY($1::text[])
1272
+ AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')`, [currentWorkflowIds]));
1243
1273
  for (const row of rows) {
1244
1274
  remainingWorkflowIds.delete(row.workflow_uuid);
1245
1275
  }
@@ -1318,6 +1348,11 @@ class SystemDatabase {
1318
1348
  const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
1319
1349
  let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
1320
1350
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalEventMs;
1351
+ // Record the durable timeout deadline once before polling. #durableSleep persists it on the
1352
+ // first call and reads back the same value on recovery, so it never changes across iterations.
1353
+ if (timeoutms) {
1354
+ finishTime = await this.#durableSleep(workflowID, timeoutFunctionID, timeoutms);
1355
+ }
1321
1356
  while (true) {
1322
1357
  // register the key with the global notifications listener.
1323
1358
  let resolveNotification;
@@ -1327,17 +1362,14 @@ class SystemDatabase {
1327
1362
  const payload = `${workflowID}::${topic}`;
1328
1363
  const cbr = this.notificationsMap.registerCallback(payload, resolveNotification);
1329
1364
  try {
1330
- await this.checkIfCanceled(workflowID);
1365
+ await this.#checkIfCanceledLimited(workflowID);
1331
1366
  // Check if the key is already in the DB, then wait for the notification if it isn't.
1332
- const initRecvRows = (await this.pool.query(`SELECT topic FROM "${this.schemaName}".notifications WHERE destination_uuid=$1 AND topic=$2 AND consumed = false;`, [workflowID, topic])).rows;
1367
+ const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT topic FROM "${this.schemaName}".notifications WHERE destination_uuid=$1 AND topic=$2 AND consumed = false;`, [workflowID, topic]))).rows;
1333
1368
  if (initRecvRows.length !== 0)
1334
1369
  break;
1335
1370
  const ct = Date.now();
1336
1371
  if (finishTime && ct > finishTime)
1337
1372
  break; // Time's up
1338
- if (timeoutms) {
1339
- finishTime = await this.#durableSleep(workflowID, timeoutFunctionID, timeoutms);
1340
- }
1341
1373
  let poll = finishTime ? finishTime - Date.now() : pollIntervalMs;
1342
1374
  poll = Math.min(pollIntervalMs, poll);
1343
1375
  const { promise, cancel } = (0, utils_1.cancellableSleep)(poll);
@@ -1442,6 +1474,12 @@ class SystemDatabase {
1442
1474
  const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
1443
1475
  let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
1444
1476
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalEventMs;
1477
+ // If we have a callerWorkflow, we want a durable sleep, otherwise, not. Record the durable
1478
+ // timeout deadline once before polling. #durableSleep persists it on the first call and reads
1479
+ // back the same value on recovery, so it never changes across iterations.
1480
+ if (callerWorkflow && timeoutms) {
1481
+ finishTime = await this.#durableSleep(callerWorkflow.workflowID, callerWorkflow.timeoutFunctionID ?? -1, timeoutms);
1482
+ }
1445
1483
  // Register the key with the global notifications listener first... we do not want to look in the DB first
1446
1484
  // or that would cause a timing hole.
1447
1485
  while (true) {
@@ -1452,11 +1490,11 @@ class SystemDatabase {
1452
1490
  const cbr = this.workflowEventsMap.registerCallback(payloadKey, resolveNotification);
1453
1491
  try {
1454
1492
  if (callerWorkflow?.workflowID)
1455
- await this.checkIfCanceled(callerWorkflow?.workflowID);
1493
+ await this.#checkIfCanceledLimited(callerWorkflow?.workflowID);
1456
1494
  // Check if the key is already in the DB, then wait for the notification if it isn't.
1457
- const initRecvRows = (await this.pool.query(`SELECT key, value, serialization
1495
+ const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT key, value, serialization
1458
1496
  FROM "${this.schemaName}".workflow_events
1459
- WHERE workflow_uuid=$1 AND key=$2;`, [workflowID, key])).rows;
1497
+ WHERE workflow_uuid=$1 AND key=$2;`, [workflowID, key]))).rows;
1460
1498
  if (initRecvRows.length > 0) {
1461
1499
  value = initRecvRows[0].value;
1462
1500
  valueSer = initRecvRows[0].serialization;
@@ -1465,10 +1503,6 @@ class SystemDatabase {
1465
1503
  const ct = Date.now();
1466
1504
  if (finishTime && ct > finishTime)
1467
1505
  break; // Time's up
1468
- // If we have a callerWorkflow, we want a durable sleep, otherwise, not
1469
- if (callerWorkflow && timeoutms) {
1470
- finishTime = await this.#durableSleep(callerWorkflow.workflowID, callerWorkflow.timeoutFunctionID ?? -1, timeoutms);
1471
- }
1472
1506
  let poll = finishTime ? finishTime - Date.now() : pollIntervalMs;
1473
1507
  poll = Math.min(pollIntervalMs, poll);
1474
1508
  const { promise, cancel } = (0, utils_1.cancellableSleep)(poll);