← Blog·Release··9 min read

Announcing Vercube 1.4

Scheduled jobs that run once across any number of replicas, a testing package that boots your real app without a port, graceful shutdown, and more than thirty fixes to how a request is handled.

Vercube 1.4 is out, and it is the biggest release so far. Two new packages: @vercube/scheduler runs cron and interval jobs once per occurrence however many replicas you scale to, and @vercube/testing boots your real application for a test without opening a port. Under them, the application learned to shut down properly, and a long list of places where a request was handled almost right are now handled right.

Scheduler

The problem a scheduler inside a web app has to solve is not the cron expression. It is that the app runs as four pods, and a nightly billing job written as a setInterval runs four times. @vercube/scheduler separates when something runs from who runs it: every replica computes the same schedule from the same code, and a shared store decides which one takes each occurrence.

src/jobs/BillingJobs.ts
import { Inject } from '@vercube/di';
import { Cron, Every } from '@vercube/scheduler';
import { InvoiceService } from '../services/InvoiceService';
import type { SchedulerTypes } from '@vercube/scheduler';

export class BillingJobs {
  @Inject(InvoiceService)
  private gInvoices!: InvoiceService;

  // once per day across the whole cluster, on Warsaw wall clock time
  @Cron('0 3 * * *', { name: 'billing.close-day', timezone: 'Europe/Warsaw' })
  public async closeDay(ctx: SchedulerTypes.JobContext): Promise<void> {
    for (const batch of await this.gInvoices.openBatches()) {
      ctx.signal.throwIfAborted();
      await this.gInvoices.close(batch, { idempotencyKey: ctx.occurrenceId });
    }
  }

  // every replica clears its own cache
  @Every('5m', { scope: 'instance' })
  public flushLocalCache(): void {
    this.gInvoices.clearCache();
  }
}

A job class is an ordinary container service, bound where you bind your controllers. The plugin takes the store:

vercube.config.ts
import { defineConfig } from '@vercube/core';
import { defineSchedulerStore, SchedulerPlugin } from '@vercube/scheduler';
import { RedisStore } from '@vercube/scheduler/stores/RedisStore';

export default defineConfig({
  plugins: [
    [SchedulerPlugin, {
      store: defineSchedulerStore({ store: RedisStore, initOptions: { url: process.env.REDIS_URL } }),
    }],
  ],
});

RedisStore claims an occurrence with SET NX PX and touches a single key per operation, so Redis Cluster works without hash tags. PostgresStore keeps claims in two tables and computes every expiry with the database's now(), so pods whose clocks drift apart still agree on when a claim expires. I left advisory locks out on purpose: they belong to a session, and that breaks behind a connection pool.

The decision I care about most is what happens without a shared store. MemoryStore only coordinates the process it lives in, and with NODE_ENV=production a cluster job on it refuses to start rather than quietly running on every pod. The failure you get is a startup error, not a customer charged four times.

The guarantee is worth stating precisely, because "exactly once" is not something a scheduler can promise. A cluster job runs at most once per occurrence in normal operation, and at least once as long as one replica is up when it is due. The edge is a lost lock: when the heartbeat cannot extend it, ctx.signal is aborted with reason lock-lost, since another replica may take over. That is why the example uses ctx.occurrenceId as an idempotency key and checks the signal between batches.

The rest is what you would otherwise build around it:

  • Misfires. misfire: 'run-once' catches up on the latest occurrence nobody ran, for example while every pod was restarting, through the normal claim, so twenty pods starting together run it once.
  • Queue targets. For heavy work, the replica that wins the occurrence only enqueues it to @vercube/queue, and the queue's attempts and backoff apply.
  • External triggers. On platforms without a long-lived process, vercube scheduler run billing.close-day fires a job from a Kubernetes CronJob, and still claims the occurrence, so a CronJob that fires twice runs it once.
  • Telemetry and devtools. Every run is a scheduler.run span. One completed and N-1 skipped-claimed per occurrence in vercube.scheduler.runs is what proves exactly one replica ran it, and devtools shows the jobs, their next runs and the recent outcomes.

Read more: Scheduler overview · Jobs · Clustering.

Testing

Until now, testing a Vercube app meant choosing between booting the whole thing on a port, with its config file and .env, or unit testing services and hoping the controller, the middleware and the validation in between behaved. @vercube/testing boots the real thing without the parts that make tests slow and leaky.

test/users.test.ts
import { createTestApp } from '@vercube/testing';
import { expect, it } from 'vitest';

it('returns a user', async () => {
  await using app = await createTestApp({
    controllers: [UserController],
    providers: [UserService],
    overrides: [[UserRepository, { findById: async (id) => ({ id, name: 'Ada' }) }]],
  });

  const res = await app.get('/users/1').expect(200);

  expect(await res.json()).toEqual({ id: '1', name: 'Ada' });
});

Controllers, dependency injection, middleware and validation are real. Requests go through app.fetch(), so no port is opened, and vercube.config.ts and .env are not read unless you ask for them. Every app gets its own container, router and configuration, so test files do not see each other's overrides.

It does not depend on a test runner. The status and header assertions are built in, everything else is your runner's expect, and the same test runs under Vitest, bun test, deno test and node --test with only the import changed. Mocks are whatever you already use.

A few details that came from writing the framework's own tests with it:

  • Overrides win over plugins. They are applied after every plugin, so a fake replaces what a plugin bound.
  • stub() names what you forgot. A partial fake usually fails with undefined is not a function far from the cause. A stub fails with UserRepository.save is not stubbed.
  • Failures are readable. A failed .expect() prints the request and the response, with credentials withheld.
  • Logs are assertable. app.logs.find('error', /order not found/) sees everything the app logged, including what the framework logged for an unhandled error.

Extensions cover the modules that are awkward to fake by hand. testAuth() lets a request run as a user with .as({ id: '1', roles: ['admin'] }), and the scheduler ships a ManualClock, so a test of the 03:00 billing job advances time by a minute instead of waiting for it:

import { withManualClock } from '@vercube/scheduler/testing';

const time = withManualClock('2026-01-01T02:59:00Z');
const app = await createTestApp({ providers: [BillingJobs], extensions: [time] });

await time.clock.advance('1m');

The module tests in the Vercube repository now run through this package, which is the best argument I have that it holds up.

Read more: Testing overview · Requests · Dependencies · Extensions.

Shutting down properly

Both packages above needed something core did not have: a way to stop. app.close() now stops the server from accepting connections, waits for the requests in flight, then runs every callback registered with app.onClose(), last registered first. The scheduler and the queue register their own close there, so on SIGTERM the server stops first, then running jobs drain, then the process exits.

vercube.config.ts
export default defineConfig({
  shutdown: {
    handleSignals: true,
    timeout: 25_000,
  },
});

Your own resources use the same hook, and app.onStart() is the counterpart: callbacks run before the port is bound, and one that throws fails listen(), so a database that cannot connect stops the deploy instead of serving errors.

app.onStart(() => pool.connect());
app.onClose(() => pool.end());

await app.listen();

Keep timeout below the pod's terminationGracePeriodSeconds, which is 30 seconds by default, or Kubernetes kills the process before the callbacks run.

Read more: Graceful shutdown.

Thirty fixes to how a request is handled

Writing @vercube/testing meant writing a lot of tests against the request path, and a lot of them found something. None of these was a crash. Each was a request handled almost right, which is the kind of bug that survives longest. The ones you are most likely to have met:

  • Global middlewares run everywhere. They now run for 404, 405, static files and CORS preflight too, so CORS and security headers reach responses no route produced. A 405 carries an Allow header.
  • The body is parsed by content type, after the before middlewares, so an auth middleware rejects a request with 401 before its body is read. JSON, form data and text are parsed. Anything else is 415, and a body over server.bodyLimit (1 MiB by default) is 413 before it is read.
  • Errors are JSON, with a content-type that says so, and an error only sets the status when it is an error status. A thrown error carrying status: 200 used to answer with a 200.
  • Middlewares run in the order you wrote them, and app-wide ones run before route ones at equal priority.
  • Repeated query keys: ?tag=a&tag=b resolves to the first value. Ask for all of them with array: true.
  • Headers set by middleware survive errors, so a 401 from an auth middleware reaches the browser as a 401 and not as a CORS failure.
  • app.listen() decides whether to listen, instead of the import.meta.main guard our examples used. PM2 imports the entry through a wrapper, so with the guard the server never started there, and nothing said so.

The modules got the same treatment. The queue honours each handler's attempts and backoff and fixes AMQP priority, Kafka consumes every topic of a mount with a single group member, storage fails on a mount that does not exist, WebSockets report errors to the peer, the serverless adapter stops corrupting Set-Cookie, and @User() fails closed.

Two small features fell out of it. @Param takes a validationSchema, so path parameters are validated and coerced like query parameters:

@Get('/:userId/posts/:page')
getPosts(
  @Param({ name: 'userId', validationSchema: z.string().uuid() }) userId: string,
  @Param({ name: 'page', validationSchema: z.coerce.number().int().min(1) }) page: number,
) {}

And @MultipartFormData() parses uploads into strings and Files.

OpenAPI from what the route already says

@vercube/schema used to document what @Schema told it. It now derives what the route already declares: responses from @Status, @Redirect, @SetHeader and @Sse, the operationId and tags from the controller, and a security requirement for routes behind @Auth(). Path parameters are documented with their schema now that @Param has one, and Valibot and ArkType schemas are converted through Standard JSON Schema. With includeAllRoutes: true every route is documented, not only the ones with @Schema.

A schema that cannot be converted is left out with a warning, and a route that fails to generate is skipped, so one bad route no longer takes /_schema/ down with it.

Read more: Schema overview.

Upgrade

Every @vercube/* package is released together and has to be on the same version, so update all of them at once rather than only @vercube/core:

pnpm up "@vercube/*" --latest

Then add the new packages at the same version:

pnpm add @vercube/[email protected]
pnpm add -D @vercube/[email protected]

Most applications upgrade without touching anything. A few behaviours changed on purpose, and they are worth a search through your code:

  • A missing @QueryParam is undefined, not null. This is the one breaking change. null broke .optional() and .default(), and with z.coerce.number() a missing limit silently became 0. Code that checks === null should use == null, and a schema with .nullable() but no .optional() now rejects a missing parameter.
  • @Body() rejects content types it cannot parse with 415, and bodies over 1 MiB with 413. Raise server.bodyLimit if you accept larger ones.
  • A repeated query key resolves to its first value. Use array: true where you expect several.
  • A @Param name that is not in the route path fails at registration, rather than resolving to undefined on every request.
  • Remove an if (import.meta.main) guard around app.listen(). It is no longer needed, and VERCUBE_LISTEN=0 is how tools that import your entry keep it from listening.

The full list of changes is in the changelog.


I would like to hear how the scheduler holds up on real clusters, and which test runner people actually use with @vercube/testing. Issues and discussions are on GitHub, and I am on X.