Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions src/store/gmail-watch-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPgliteDb, type Db } from '../db/client.js'
import { migrate } from '../db/migrate.js'
import { createGmailWatchStateStore } from './gmail-watch-state.js'
Expand Down Expand Up @@ -349,15 +349,55 @@ describe('createGmailWatchStateStore', () => {

// --- claimReconcileLease / releaseReconcileLease (HT-48, gmail-push.md Β§6) ---

/** Directly rewinds a mailbox's claimed_until into the past β€” mirrors conversations.test.ts's expireLease for the outbound lease, exercising expiry without a real sleep. */
async function expireReconcileLease(db: Db, mailboxId: string) {
await db.query(
"UPDATE gmail_watch_state SET claimed_until = now() - interval '1 second' WHERE mailbox_id = $1",
[mailboxId],
)
}

describe('claimReconcileLease / releaseReconcileLease', () => {
/**
* These tests run on a FROZEN, fixed clock. Only `Date` is faked β€” PGlite
* reads the system clock for `now()`, so faking it moves the database's
* clock too, while leaving `setTimeout` real so nothing PGlite relies on
* stalls. The clock is pinned to a constant instant rather than whatever
* `useFakeTimers` inherited from the wall clock, so no assertion in this
* block can depend on when it happened to run; time moves only when
* {@link expireReconcileLease} moves it.
*/
const FROZEN_NOW = new Date('2026-01-01T00:00:00.000Z')

beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(FROZEN_NOW)
})
afterEach(() => {
vi.useRealTimers()
})

/**
* Expire a lease the way production does β€” by moving the clock past it,
* not by rewinding `claimed_until` with SQL. Advances to ONE MILLISECOND
* past expiry (the smallest step PGlite's `now()` can resolve), so the
* claim guard's strict `claimed_until < now()` boundary stays under test
* rather than being cleared by a wide margin.
*
* ## Why not the SQL rewind these tests used to do
*
* The lease token IS `claimed_until` (`now() + leaseMs`), so two claims
* whose `now()` lands on the same instant with the same `leaseMs` render
* the IDENTICAL token β€” and PGlite's `now()` only advances in whole
* MILLISECONDS, so back-to-back claims collide routinely. Rewinding
* `claimed_until` in SQL faked the expiry WITHOUT the elapsed time it
* implies, so the successor's claim ran in the same millisecond as its
* predecessor's and the two tokens came back equal β€” a real-clock race
* that failed roughly half of all runs.
*
* In production the successor can only claim once the prior lease has
* actually expired, i.e. at least `leaseMs` later, so its `claimed_until`
* is necessarily later too and the tokens necessarily differ. Jumping the
* clock past the lease reproduces that ORDERING guarantee deterministically
* β€” it models the expiry, not the passage of time itself: the clock stays
* frozen at its new instant until moved again.
*/
function expireReconcileLease(leaseMs: number) {
vi.setSystemTime(Date.now() + leaseMs + 1)
}

it('claims an unclaimed mailbox, setting claimed_until in the future and returning it as the lease token', async () => {
const { db, store } = await freshStore()
const mailboxId = await insertMailbox(db)
Expand Down Expand Up @@ -403,7 +443,7 @@ describe('createGmailWatchStateStore', () => {

const first = await store.claimReconcileLease(mailboxId, 30_000)
expect(first).not.toBeNull()
await expireReconcileLease(db, mailboxId)
expireReconcileLease(30_000)

const second = await store.claimReconcileLease(mailboxId, 30_000)
expect(second).not.toBeNull()
Expand Down Expand Up @@ -469,7 +509,7 @@ describe('createGmailWatchStateStore', () => {
// its lease before ever calling release.
const tokenA = await store.claimReconcileLease(mailboxId, 30_000)
expect(tokenA).not.toBeNull()
await expireReconcileLease(db, mailboxId)
expireReconcileLease(30_000)

// Holder B β€” a legitimate successor β€” claims the now-expired lease and
// is actively working.
Expand Down