diff --git a/packages/indexeddb/src/job-queue/IndexedDbQueueStorage.ts b/packages/indexeddb/src/job-queue/IndexedDbQueueStorage.ts
index aee4a69e6..16011b3ea 100644
--- a/packages/indexeddb/src/job-queue/IndexedDbQueueStorage.ts
+++ b/packages/indexeddb/src/job-queue/IndexedDbQueueStorage.ts
@@ -14,7 +14,7 @@ import type {
} from "@workglow/job-queue";
import { JobStatus, validateLeaseMs } from "@workglow/job-queue";
import { HybridSubscriptionManager } from "@workglow/storage";
-import { createServiceToken, deepEqual, makeFingerprint, uuid4 } from "@workglow/util";
+import { createServiceToken, deepEqual, getLogger, makeFingerprint, uuid4 } from "@workglow/util";
import { IndexedDbMigrationRunner } from "../migrations/IndexedDbMigrationRunner";
import { indexedDbQueueMigrations } from "../migrations/indexedDbQueueMigrations";
import { openIdb } from "../storage/openIdb";
@@ -419,9 +419,10 @@ export class IndexedDbQueueStorage implements IQueueStorage & Record)
| undefined;
if (!existing || existing.queue !== this.queueName || !this.matchesPrefixes(existing)) {
- reject(
- new Error(`Job ${job.id} not found or does not belong to queue ${this.queueName}`)
- );
+ // Contract: complete() must silently no-op (not throw) when the row is
+ // missing or belongs to another queue. Lease-expiry races can legitimately
+ // make the target row disappear between claim and complete, so the
+ // transaction is allowed to commit with no write and resolve.
return;
}
const currentAttempts = existing.attempts ?? 0;
@@ -673,7 +674,12 @@ export class IndexedDbQueueStorage implements IQueueStorage | null
): Promise {
const job = await this.get(id);
- if (!job) throw new Error(`Job ${id} not found`);
+ if (!job) {
+ // Contract: progress updates for a missing job silently no-op — the job may
+ // have already completed/been removed, or a lease-expiry race removed it.
+ getLogger().warn("Job not found for progress update", { id });
+ return;
+ }
job.progress = progress;
job.progress_message = message;
diff --git a/packages/indexeddb/src/storage/IndexedDbVectorStorage.ts b/packages/indexeddb/src/storage/IndexedDbVectorStorage.ts
index 83725ffd0..c0ddc1d2c 100644
--- a/packages/indexeddb/src/storage/IndexedDbVectorStorage.ts
+++ b/packages/indexeddb/src/storage/IndexedDbVectorStorage.ts
@@ -17,8 +17,10 @@ import {
getMetadataProperty,
getVectorProperty,
matchesFilter,
+ safeEmit,
validateVectorEntities,
} from "@workglow/storage";
+import type { EventEmitter } from "@workglow/util";
import { createServiceToken } from "@workglow/util";
import type {
DataPortSchemaObject,
@@ -130,7 +132,10 @@ export class IndexedDbVectorStorage<
options: VectorSearchOptions> = {}
) {
assertVectorShape(query, this.vectorDimensions, "query");
- const { topK = 10, filter, scoreThreshold = 0 } = options;
+ // Default to no floor: cosine similarity ranges over [-1, 1], so a default
+ // of 0 would silently drop negatively-correlated hits and return fewer than
+ // `topK`. Callers opt into a relevance floor explicitly via `scoreThreshold`.
+ const { topK = 10, filter, scoreThreshold = -Infinity } = options;
const results: Array = [];
const allEntities = (await this.getAll()) || [];
@@ -138,9 +143,10 @@ export class IndexedDbVectorStorage<
for (const entity of allEntities) {
// IndexedDB stores TypedArrays natively via structured clone
const vector = entity[this.vectorPropertyName] as TypedArray;
- const metadata = this.metadataPropertyName
- ? (entity[this.metadataPropertyName] as Metadata)
- : ({} as Metadata);
+ // A present-but-null metadata column value coalesces to `{}` so a filtered
+ // search treats the row as a non-match rather than dereferencing null.
+ const rawMetadata = this.metadataPropertyName ? entity[this.metadataPropertyName] : undefined;
+ const metadata = (rawMetadata ?? {}) as Metadata;
if (filter && !matchesFilter(metadata, filter)) {
continue;
@@ -160,6 +166,19 @@ export class IndexedDbVectorStorage<
results.sort((a, b) => b.score - a.score);
const topResults = results.slice(0, topK);
+ // The inherited `events` emitter is typed for the tabular event surface;
+ // `similaritySearch` lives on the vector extension of that surface. The
+ // emitter instance is the same object, so widen the view to a record that
+ // carries the event so it can be emitted type-safely.
+ type SimilaritySearchEvents = {
+ similaritySearch: (query: TypedArray, results: (Entity & { score: number })[]) => void;
+ };
+ safeEmit(
+ this.events as unknown as EventEmitter,
+ "similaritySearch",
+ query,
+ topResults
+ );
return topResults;
}
diff --git a/packages/job-queue/src/job/JobErrorRegistry.ts b/packages/job-queue/src/job/JobErrorRegistry.ts
index b9f3a8845..06176104e 100644
--- a/packages/job-queue/src/job/JobErrorRegistry.ts
+++ b/packages/job-queue/src/job/JobErrorRegistry.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { getLogger } from "@workglow/util";
import { JobError } from "./JobError";
/**
@@ -57,9 +58,9 @@ export function registerErrorCodeReconstructor(
return; // idempotent — same fn, same prefix
}
- console.warn(
- `registerErrorCodeReconstructor: overwriting existing reconstructor for prefix "${prefix}"`
- );
+ getLogger().warn("registerErrorCodeReconstructor: overwriting existing reconstructor", {
+ prefix,
+ });
reconstructors[existingIdx] = { prefix, reconstructor };
return;
}
diff --git a/packages/job-queue/src/job/JobQueueClient.ts b/packages/job-queue/src/job/JobQueueClient.ts
index 2ae48100e..039377a89 100644
--- a/packages/job-queue/src/job/JobQueueClient.ts
+++ b/packages/job-queue/src/job/JobQueueClient.ts
@@ -173,23 +173,7 @@ export class JobQueueClient {
readonly timeoutSeconds?: number;
}
): Promise> {
- const job: JobStorageFormat = {
- queue: this.queueName,
- input,
- job_run_id: options?.jobRunId,
- fingerprint: options?.fingerprint,
- max_attempts: options?.maxAttempts ?? 10,
- visible_at:
- options?.delaySeconds != null
- ? new Date(Date.now() + options.delaySeconds * 1000).toISOString()
- : new Date().toISOString(),
- deadline_at:
- options?.timeoutSeconds != null
- ? new Date(Date.now() + options.timeoutSeconds * 1000).toISOString()
- : null,
- completed_at: null,
- status: JobStatus.PENDING,
- };
+ const job = this.buildJobBody(input, options);
const id = await this.messageQueue.send(job, {
fingerprint: options?.fingerprint,
@@ -208,21 +192,77 @@ export class JobQueueClient {
}
/**
- * Send multiple jobs to the queue
+ * Send multiple jobs to the queue in a single batched insert.
+ *
+ * Delegates to {@link IMessageQueue.sendBatch} so N jobs become one storage
+ * round-trip and a single worker wake, instead of N inserts + N wakes.
+ *
+ * Per the {@link IMessageQueue.sendBatch} contract, a batch-wide
+ * `fingerprint` is intentionally NOT accepted (it would dedup every body
+ * against the first row); use {@link send} per job for fingerprinted dedup.
+ * The other options — `jobRunId`, `maxAttempts`, `delaySeconds`,
+ * `timeoutSeconds` — apply uniformly to every body and are forwarded (the
+ * previous per-item loop silently dropped `delaySeconds`/`timeoutSeconds`).
*/
public async sendBatch(
inputs: readonly Input[],
options?: {
readonly jobRunId?: string;
readonly maxAttempts?: number;
+ /** Delay in seconds before every job becomes visible for processing */
+ readonly delaySeconds?: number;
+ /** Timeout in seconds after which every job's deadline is exceeded */
+ readonly timeoutSeconds?: number;
}
): Promise[]> {
- const handles: JobHandle