Skip to content

Build1 publisher2 min readPublished

A singleton cron must list its tenants before a tenant filter has anything to bind to

A daily email worker in a Next.js monorepo read a shared queue with no tenant scope, and the obvious one-line filter passed a Jest suite that had only ever seeded one organization. The rewrite discovers tenants first.

The Engineer · Build desk

Illustration accompanying A singleton cron must list its tenants before a tenant filter has anything to bind to

What happened

  • A single Node cron in a Next.js/Node monorepo, apps/api/src/email/email.cron.ts, pulled pending rows from a shared email_queue table without scoping the query to a tenant ID.
  • The existing Jest suite only ever exercised the cron with one organization seeded, and reported coverage sat at 1.28 percent.
  • Adding a WHERE organization_id clause inside the existing fetchPendingEmails helper left the tests green and threw a ReferenceError at runtime, because organization_id was never defined in that context.
  • The shipped rewrite first fetches the distinct organization_ids that have pending rows, then processes each tenant with the ID passed explicitly, under a new suite that seeds two orgs in in-memory SQLite.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint A background job has no request to inherit tenant identity from, so isolation has to be threaded through every helper signature in the module, and each new helper is another place to forget it.
  • exposure One symptom crashes the job with a duplicate key violation. The other quietly puts Org A contract details in an Org B user's inbox, where the recipient is the only party positioned to notice.
  • decision Coverage percentage is the wrong gate for tenant isolation. The question at review is whether the fixture seeds a second organization at all.
  • cost Tenants run one after another, so the run takes the sum of all tenants and one slow mailbox delays every tenant behind it; the code defers parallelism to a later change.

The patched query failed for a structural reason: the cron runs as a singleton process, and the write-up on dev.to reports there is no request-level context to provide `organizationId` [6]. The identifier in the new WHERE clause had nothing to bind to. The suite that went green had seeded exactly one organization [4], so no assertion in it could tell a scoped read from an unscoped one.

The environment-variable attempt is the more instructive failure. Reading the tenant from `process.env.ORG_ID` runs fine, and the author rejected it because each run would then be scoped to a single organization [7]. That answer is a cron deployment per customer, with a schedule and a secret each.

The shipped version splits the run. Discovery calls `findMany` on `emailQueue` where `sent` is false, selecting only `organization_id`, distinct on that column [8]. Processing loops the returned IDs into `processTenant(organizationId)` [9], and every helper below it now takes `organizationId` as its first argument [10].

Discovery is still an unscoped read of the shared table, and it returns one column [17]. Row bodies come back only inside the per-tenant fetch. The list is computed once, before the loop. A pending row for an organization that was not in the snapshot waits for the next run [18]. And because `fetchPendingEmails` orders by `createdAt` ascending within a tenant while the loop walks tenants in sequence, the queue no longer drains in global insertion order [19].

The new fixture is the part I would copy. It seeds orgA with two pending rows and orgB with one, in an in-memory SQLite database, then runs `runCron()` and asserts each org receives only its own mail [13][14]. The post says coverage was bumped to realistic levels, and the post-refactor figure is not in it [20]. Three pending rows exist and two belong to orgA, so an unscoped fetch on orgA's iteration returns one row too many and the assertion fails on a single misdirected recipient [15].

`sendEmailForRecord` is declared with one parameter, `email`, and its body compares `email.organization_id` against `currentTenant` [12]. `currentTenant` is not a parameter, and the excerpt does not define it [16]. The defensive check that logs a tenant mismatch and skips the record [11] needs the tenant handed to it the way `fetchPendingEmails` gets it, or it throws the error the refactor was written to remove.

What to watch

  • Whether the follow-up promotes currentTenant to a parameter of sendEmailForRecord, or drops the mismatch check.
  • Whether processTenant is parallelized as the code comment anticipates, and whether the discovery list is then recomputed per batch.
  • Whether the author publishes the post-refactor coverage figure and a test for the tenant-mismatch warning path.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories