Skip to content

147 scheduling jobs#192

Merged
theosiemensrhodes merged 33 commits intomainfrom
147-scheduling-jobs
Mar 12, 2026
Merged

147 scheduling jobs#192
theosiemensrhodes merged 33 commits intomainfrom
147-scheduling-jobs

Conversation

@theosiemensrhodes
Copy link
Copy Markdown
Collaborator

Summary

  • Integrate pg-boss using existing Postgres (DATABASE_URL) and add a central job runtime service at src/server/services/jobService.ts.
  • Add a typed jobs module under src/server/jobs:
    • definitions/cleanup-orphaned-images.job.ts
    • definitions/scheduled-test.job.ts
    • registry.ts
    • types.ts
  • Register jobService in DI (src/server/api/di-container.ts) and bootstrap it at server startup.
  • Add test support via src/test/mocks/mock-job-service.ts and wire it into src/test/test-container.ts.

Key Design Changes

  • Introduced a single-source job registry with:
    • known job-name typing (KnownJobName)
    • payload inference by job name (JobPayload<TJobName>)
    • duplicate-name guard at startup
  • Unified scheduling API into run(jobName, data?, options?):
    • one-off immediate
    • one-off delayed (runAt / startAfter)
    • recurring (cron, optional startAt, endAt, tz)
  • Startup scheduling simplified:
    • each job has optional startup
    • startup.cron present => recurring startup schedule
    • startup.cron absent => one-off startup enqueue
  • Added correlation-id concept for recurring schedules:
    • run(..., { cron, correlationId }) creates correlation-specific recurring streams
    • unschedule(jobName, { correlationId }) cancels only that stream
    • unschedule(jobName) remains supported to cancel all schedules for a job

Test Plan

  • Start backend and verify jobService boots without errors.
  • Confirm recurring startup registration for jobs.cleanup-orphaned-images.
  • Confirm one-off startup enqueue for jobs.scheduled-test.
  • Call run() for:
    • immediate one-off
    • delayed one-off (runAt)
    • recurring (cron)
  • Call unschedule(jobName, { correlationId }) and verify only targeted recurring stream is removed.
  • Call unschedule(jobName) and verify all recurring schedules for that job are removed.
  • Run unit/integration tests that use MockJobService.

jjohngrey and others added 30 commits March 8, 2026 15:31
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* main:
  fix: profile permission for availability
  fix: dependabot
  Aside state persisted in query params (#189)
  174 (#190)
@theosiemensrhodes theosiemensrhodes merged commit b55fe5d into main Mar 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 12, 2026

Greptile Summary

This PR introduces a well-architected pg-boss-backed job scheduling system with typed job names, payload inference, a central JobService, DI registration, and a MockJobService for tests. The registry design (duplicate-name guard, KnownJobName/RunnableJobName typing, per-job startup config) is clean and extensible.

Two issues need attention before the correlationId API can be considered production-ready:

  • correlationId recurring schedules are completely broken. getQueueName builds names like jobs.foo:tenant-1 using a colon separator, which pg-boss rejects because its queue names only allow [A-Za-z0-9_\-\.\/]. Every run(name, data, { cron, correlationId }) call will throw "Name can only contain…". The integration test at line 276–287 explicitly marks this as a known bug. The same separator is used in the startsWith(${name}:) filters in unschedule and registerWorkersForPersistedCorrelationSchedules, so those paths are equally affected. A straightforward fix is to switch to / (which pg-boss permits) as the separator.
  • run() calls into an unstarted boss when NODE_ENV === "test". start() returns early in test mode but does not prevent the subsequent call to this.boss.send() on the unstarted PgBoss instance. In practice this is guarded by MockJobService being used in unit tests and NODE_ENV being overridden to "development" in the integration suite, but a misregistered mock would produce a confusing pg-boss internal error instead of a clear diagnostic.

Confidence Score: 3/5

  • Safe to merge for basic one-off and recurring job functionality, but the correlationId feature is completely non-functional and should be fixed before callers depend on it.
  • The core scheduling infrastructure (one-off, delayed, and cron jobs without correlationId) works correctly and is well-tested. However, a key advertised design feature — correlationId-scoped recurring schedules — is broken due to an invalid queue name separator, and this is explicitly acknowledged as a known bug in the test suite. Shipping a broken public API surface warrants holding the score at 3 until the separator is fixed.
  • src/server/services/jobService.ts — contains both the broken correlationId queue naming and the unstarted-boss guard issue.

Important Files Changed

Filename Overview
src/server/services/jobService.ts Core job service implementation — contains two significant bugs: (1) correlationId queue names use a colon separator that pg-boss disallows, breaking the feature entirely; (2) start() short-circuits in test mode leaving the boss unstarted, but run() still calls this.boss.send() on the unstarted instance.
src/server/jobs/registry.ts Job registry with typed name lookup and duplicate-name guard at module load time — clean and well-designed; KnownJobName/RunnableJobName alias separation is forward-thinking for future per-environment filtering.
src/server/jobs/types.ts Type definitions for jobs, payloads, and scheduling options — well-structured with appropriate conditional types for StartupSchedule; no issues.
src/server/jobs/definitions/cleanup-orphaned-images.job.ts Stub job that intentionally throws with retryLimit: 0 and startup: undefined to prevent accidental use before the handler is implemented — appropriate placeholder design.
src/server/api/di-container.ts Registers JobService as a singleton and bootstraps it at container creation with fire-and-forget error logging — deliberate design to avoid crashing the server on pg-boss startup failure.
src/test/integration/job-service.test.ts Comprehensive integration test suite with good lifecycle, scheduling, and error-handling coverage; explicitly documents the correlationId colon-separator bug as a known failure case.
src/test/mocks/mock-job-service.ts Minimal, correct mock implementation of IJobService with call recording — no issues.
src/test/test-container.ts Correctly wires MockJobService into the test DI container as a pre-instantiated singleton so call history is inspectable across resolutions — no issues.

Last reviewed commit: 746e707

Comment on lines +328 to +335
private getQueueName(jobName: KnownJobName, correlationId?: string): string {
if (!correlationId) return jobName;
const normalized = correlationId.trim();
if (!normalized) {
throw new Error("correlationId must be a non-empty string.");
}
return `${jobName}:${normalized}`;
}
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.

correlationId feature broken — colon is an invalid pg-boss queue name character

getQueueName produces names like jobs.cleanup-orphaned-images:tenant-1, but pg-boss only allows [A-Za-z0-9_\-\.\/] in queue names. As a result, every call to run(jobName, data, { cron, correlationId }) will fail with a "Name can only contain..." error at the createQueue/updateQueue step inside ensureWorkerRegistered. The integration test at line 276–287 explicitly documents this as a "known bug" and asserts the throw — which means this advertised feature is completely non-functional in its current form.

Consider using / (which pg-boss allows) or a hyphen/underscore as the separator:

  private getQueueName(jobName: KnownJobName, correlationId?: string): string {
    if (!correlationId) return jobName;
    const normalized = correlationId.trim();
    if (!normalized) {
      throw new Error("correlationId must be a non-empty string.");
    }
    return `${jobName}/${normalized}`;
  }

The same separator change would need to be applied wherever correlated queue names are detected (e.g. the startsWith(${definition.name}:) filter in unschedule and registerWorkersForPersistedCorrelationSchedules).

Comment on lines +80 to +88
async start(): Promise<void> {
if (this.env.NODE_ENV === "test") return;
if (sharedBossState.isStarted) return;

sharedBossState.startPromise ??= this.bootstrap().catch((error) => {
sharedBossState.startPromise = undefined;
throw error;
});
return sharedBossState.startPromise;
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.

run() proceeds with an unstarted boss when NODE_ENV === "test"

start() returns early on line 81 without calling bootstrap(), so sharedBossState.isStarted remains false and the PgBoss instance is never started. run() then falls through to runWithStartedBoss(), which calls this.boss.send() on the unstarted instance — pg-boss will throw an internal "not started" error rather than giving a clear diagnostic.

The integration tests work around this by overriding NODE_ENV to "development", and MockJobService is used in unit tests, so this path is currently never hit in practice. However, if the real JobService is inadvertently resolved in a test environment (e.g. a missing mock registration), the developer will see a confusing pg-boss error instead of a clear signal.

A safer approach is to also guard runWithStartedBoss or make the early-return in start() propagate clearly:

async start(): Promise<void> {
  if (this.env.NODE_ENV === "test") {
    throw new Error(
      "JobService.start() must not be called in test environments. Use MockJobService instead.",
    );
  }
  ...
}

Alternatively, add a guard at the top of run() and unschedule() that checks sharedBossState.isStarted after start() returns.

Comment on lines +189 to +196
await this.boss.schedule(
queueName,
cron,
(data ?? null) as object | null,
scheduleOptions as any,
);
this.trackRecurringQueue(jobName, queueName);
return null;
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.

scheduleOptions passed as any may silently forward unsupported options

The destructuring on lines 182–188 intentionally strips cron, runAt, startAfter, and correlationId from mergedOptions, but the remainder is cast to any and passed directly to boss.schedule(). Fields like singletonKey, singletonSeconds, retryLimit, etc., in RunJobOptions may not be valid ScheduleOptions in pg-boss. Casting scheduleOptions as any suppresses type-checking that would catch such mismatches.

Consider keeping the as any only at the call site but narrowing the intermediate type (e.g. Pick<ScheduleOptions, ...>) so unsupported fields generate a compile-time error rather than silently being ignored or forwarded.

@theosiemensrhodes theosiemensrhodes linked an issue Mar 12, 2026 that may be closed by this pull request
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.

Set Up pgBoss for Scheduling and Queuing Jobs

2 participants