Skip to content

use commitTS for workpool - #220

Draft
ianmacartney wants to merge 8 commits into
mainfrom
ian/use-commit-ts
Draft

use commitTS for workpool#220
ianmacartney wants to merge 8 commits into
mainfrom
ian/use-commit-ts

Conversation

@ianmacartney

@ianmacartney ianmacartney commented Aug 4, 2026

Copy link
Copy Markdown
Member

use segment field to capture commit TS, and new runAt sidecar when it needs to be in the future, but not so far in the future that we can safely assign it directly

`v.commitTs()` and `db.vars.commitTs` landed in convex 1.43, and the pending
queues are about to be ordered by them, so raise the peer dependency to match.

Also bumps convex-test to 0.0.55-alpha.0, the first version that resolves the
commitTs placeholder at commit time instead of storing it verbatim.
The `segment` fields on pendingStart/pendingCompletion/pendingCancelation held
a 100ms bucket derived from `Date.now()`. That is read when a mutation *starts*,
so a slow transaction could commit an entry behind a cursor the main loop had
already read past. 0.4.7 compensated by rewinding each cursor 15 seconds on
every scan and rescanning each table from the beginning once a minute, which at
load meant re-reading thousands of tombstones per iteration.

Those fields are now `v.commitTs()`, written as `db.vars.commitTs` and resolved
at commit, so an entry cannot appear behind a timestamp already read. The rewind
buffer and the periodic rescan are gone; the recovery iteration still runs, but
only to find stuck running jobs. On a 5000-task saturation benchmark
(parallelism 200, 20ms tasks) throughput went from 121 to 144 tasks/s and p99
latency from ~32s to ~25s.

The field and index keep their names so existing documents still validate. The
values are now nanoseconds rather than 100ms buckets; an older version's much
smaller ones sort first and get processed promptly on upgrade.

Work that shouldn't start yet stores its start time in the same field, which
sorts after everything committed before then, so one index covers both cases:

- A retry backoff is written directly, however short. `rescheduleJob` runs
  inside the transaction that writes the cursor, so a time past now is certain
  to sort ahead of it.
- A caller's `runAt` is written directly only when it is more than
  SAFE_FUTURE_MS out, far enough that no commit latency could place it in the
  past. Nearer than that it is ordered by the commit timestamp with `runAt`
  stored alongside, and the loop moves it forward itself (`promoteScheduled`)
  rather than starting it.

Eligibility is bounded by `endOfMs(now)` rather than `toTimestamp(now)`:
`Date.now()` is whole milliseconds but a commit timestamp is not, so a bound at
the start of the current millisecond would skip everything that just committed.

Two paths that returned without deleting a pendingStart — work already in
`running`, and a cancelation whose work document is gone — now delete it. They
used to be repaired by the periodic rescan; with the cursor no longer rewinding,
an entry left behind would never be read again.
The throughput scenarios only ever enqueue work that is ready immediately, so
nothing exercised the two paths that write a wall-clock time into the ordering
field instead of the commit-timestamp placeholder. Those are also the paths
convex-test could be lenient about, since it does not enforce what the backend
accepts for a `v.commitTs()` field.

`test/scheduling:default` enqueues one delayed mutation and one action that
fails twice, then reports when each ran. Against the dev deployment: a
`runAfter: 8000` ran 59ms after its runAt and never early, retries backed off
722ms then 939ms over 3 attempts, and a `runAfter: 360000` was stored at its
start time with no promotion pass and ran 196ms late.
Notes the new ordering, the benchmark result, what happens to entries an older
version wrote, and the convex 1.43 requirement.
@ianmacartney
ianmacartney requested a review from reeceyang August 4, 2026 00:43

Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/workpool/@convex-dev/workpool@220

commit: 0ee3c9c

Copy link
Copy Markdown
Member Author

One thing to note here is that reverting to an older version would require a migration to convert segments into their old format, - which could be hard to get right, but could make some assumptions about the maximum segment a user would have previously used, or migrate based on creation time of the documents or something..?

@ianmacartney ianmacartney changed the title update convex use commitTS for workpool Aug 4, 2026

@reeceyang reeceyang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When users migrate to this version, would work that was originally scheduled to run in the future get executed immediately?

* work until then; the default runs it as soon as the loop sees it. `segment`
* pins the ordering value, e.g. to give several entries the same one the way
* a single batch enqueue does.
* Bypasses the public enqueue API to keep tests focused on the loop.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to use the enqueueHandler here? or extract out a common helper function so the segment logic stays in sync between the test and the actual implementation

expect(o.running).toHaveLength(0); // moved, not started
expect(o.pendingStart[0].segment).toBe(toTimestamp(runAt));
// The cursor moved past where it used to sit, so it won't come back.
expect(o.segmentCursors!.incoming).toBeLessThan(toTimestamp(runAt));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this actually check that the cursor moved forward? The cursor would also be less than runAt before the loop runs for the first time, right?

Comment thread src/component/loop.ts
// Entries whose `runAt` hasn't arrived were only visible because we
// couldn't safely write that time into `segment` at enqueue. We can here:
// this transaction also writes the cursor, so a value past `now` is
// guaranteed to land ahead of it. Move them and they stop coming back.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this is ok because we never advance the cursor past eligibleBefore (which is endOfMs(Date.now()))?

A pendingStart written by an older version stores a 100ms bucket in `segment`
and has no `runAt`, so the bucket is the only record of when the work should
start. Read as a nanosecond timestamp that bucket is ~1.8e10 against ~1.8e18 for
a real one, which put every such entry below the eligibility bound: work
scheduled minutes or years out was started immediately on upgrade, and its
reported start lag was nonsense.

Entries below a threshold — nanoseconds for the year 2000, orders of magnitude
away from either format — are now read back as a scheduled time and handed to
`run` as `runAt`. From there the existing path applies: due work starts, and
anything still in the future is rewritten as a timestamp and left alone until
then. Verified against a deployment by seeding a 0.4.9-format entry 90s out
alongside 0.4.9-format cursors; it was rewritten to its exact bucketed time
rather than started, then ran when due.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants