Skip to content
Merged
Show file tree
Hide file tree
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
47 changes: 47 additions & 0 deletions packages/backend/src/services/alerts/AlertsService.uptime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,53 @@ describe("AlertsService uptime rules", () => {
}).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub({}))))
})

it.effect("a missed heartbeat counts as a down check and marks the pages showing it", () => {
const testDb = createTestDb(trackedDbs)
return Effect.gen(function* () {
yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS)
const alerts = yield* AlertsService
const orgId = asOrgId("org_uptime_heartbeat_page")
const { monitorId } = yield* seedUptimeMonitor(testDb, {
orgId,
name: "Nightly backup",
serviceName: "backup-job",
destinationIds: [],
config: { _tag: "heartbeat", schedule: { _tag: "interval", seconds: 300 }, graceSeconds: 60 },
status: "up",
lastPingAt: DEFAULT_CLOCK_EPOCH_MS,
})
yield* Effect.promise(() =>
executeSql(
testDb,
`INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at)
VALUES ('11111111-1111-4111-8111-111111111111', $1, 'Acme', 'acme', 'user_1', now(), now())`,
[orgId],
).then(() =>
executeSql(
testDb,
`INSERT INTO status_page_components (id, org_id, page_id, name, monitor_id, created_at, updated_at)
VALUES ('22222222-2222-4222-8222-222222222222', $1, '11111111-1111-4111-8111-111111111111', 'Backups', $2, now(), now())`,
[orgId, monitorId],
),
),
)
yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS + 7 * 60_000)
yield* alerts.runSchedulerTick()
const page = yield* Effect.promise(() =>
queryFirstRow<{ revision: string }>(testDb, "select revision::text from status_pages"),
)
const daily = yield* Effect.promise(() =>
queryFirstRow<{ checks: number; down_checks: number }>(
testDb,
"select checks, down_checks from uptime_monitor_daily where monitor_id = $1",
[monitorId],
),
)
assert.strictEqual(page?.revision, "2")
assert.deepStrictEqual(daily, { checks: 1, down_checks: 1 })
}).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub({}))))
})

it.effect(
"the generic rule APIs refuse to create, edit or delete uptime rules and do not list them",
() => {
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/services/org/OrganizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FetchHttpClient, HttpClient } from "effect/http"
import { UPTIME_MONITORS_FEATURE_ID } from "@maple/domain/billing"
import { trackFeatureUsage } from "@maple/backend/services/billing/autumn-tracker"
import { ownBillingCustomer } from "@maple/backend/services/billing/billing-customer"
import { queueOrgPageRemovals } from "@maple/backend/services/status-pages/outbox"
import {
ChooseOrganizationRegionResponse,
CreateOrganizationResponse,
Expand Down Expand Up @@ -73,6 +74,12 @@ import {
ScrapeTargets,
Sources,
SupabaseConnections,
StatusPageComponents,
StatusPageIncidentComponents,
StatusPageIncidentUpdates,
StatusPageIncidents,
StatusPages,
UptimeMonitorDaily,
UptimeMonitorStates,
UptimeMonitors,
SupabaseProjectInsights,
Expand Down Expand Up @@ -145,8 +152,16 @@ const ORG_SCOPED_TABLES: ReadonlyArray<OrgScopedTable> = [
Sources,
OrgClickHouseSettings,
ScrapeTargets,
// Status pages first: their components point at monitors. The edge copy of a page outlives
// these rows until the publisher's sweep removes pages it no longer finds.
StatusPageIncidentComponents,
StatusPageIncidentUpdates,
StatusPageIncidents,
StatusPageComponents,
StatusPages,
// A surviving monitor keeps Maple probing the org's endpoints, and its heartbeat token
// keeps accepting pings. The state rows also go with the monitor by foreign key.
UptimeMonitorDaily,
UptimeMonitorStates,
UptimeMonitors,
OAuthConnections,
Expand Down Expand Up @@ -278,6 +293,8 @@ export const UNPURGED_ORG_SCOPED_TABLES = [
"PlanetscaleEvents",
"PlanetscalePollState",
"ScrapeTargetChecks",
// Edge cleanups queued by the delete itself; the publisher removes each row once done.
"StatusPageCleanups",
"VcsRepositoryBranches",
] as const

Expand Down Expand Up @@ -357,6 +374,8 @@ export class OrganizationService extends Context.Service<OrganizationService, Or
const purgeOrgScopedRows = Effect.fn("OrganizationService.purgeOrgScopedRows")(function* (
orgId: OrgId,
) {
// Public pages live on at the edge until the publisher drains these.
yield* dbExecute((db) => queueOrgPageRemovals(db, orgId))
yield* Effect.forEach(
ORG_SCOPED_TABLES,
(table) =>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
51 changes: 51 additions & 0 deletions packages/backend/src/services/status-pages/outbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as Orm from "@maple-dev/effect-orm/database"
import type { MapleDb } from "@maple/db/client"
import type { OrgId, StatusPageId, UptimeMonitorId } from "@maple/domain/primitives"
import { Effect } from "effect"

// The status-page outbox: a write a visitor could see bumps `status_pages.revision` in its own
// transaction, and the publisher republishes every page whose `published_revision` lags.

/** Marks one page as needing a publish. */
export const bumpPage = (db: MapleDb, orgId: OrgId, pageId: StatusPageId) =>
db.execute(
Orm.sql`UPDATE status_pages SET revision = revision + 1 WHERE org_id = ${orgId}::text AND id = ${pageId}::text`,
)

/** Marks every page showing one of these monitors (a status flip, a pause, a delete). */
export const bumpPagesForMonitors = (
db: MapleDb,
orgId: OrgId,
monitorIds: ReadonlyArray<UptimeMonitorId>,
) => {
if (monitorIds.length === 0) return Effect.void
return db.execute(Orm.sql`
UPDATE status_pages SET revision = revision + 1
WHERE org_id = ${orgId}::text AND id IN (
SELECT page_id FROM status_page_components
WHERE org_id = ${orgId}::text AND monitor_id IN (${Orm.sql.join(
monitorIds.map((id) => Orm.sql`${id}::text`),
Orm.sql`, `,
)})
)`)
}

/** Queues removal of a page's edge data; call in the delete's transaction, before the delete. */
export const queuePageRemoval = (db: MapleDb, orgId: OrgId, pageId: StatusPageId) =>
db.execute(Orm.sql`
INSERT INTO status_page_cleanups (id, org_id, page_id, slug, delete_snapshot, created_at)
SELECT gen_random_uuid()::text, org_id, id, slug, true, now()
FROM status_pages WHERE org_id = ${orgId}::text AND id = ${pageId}::text`)

/** Queues removal of every page of an org, for org deletion. */
export const queueOrgPageRemovals = (db: MapleDb, orgId: OrgId) =>
db.execute(Orm.sql`
INSERT INTO status_page_cleanups (id, org_id, page_id, slug, delete_snapshot, created_at)
SELECT gen_random_uuid()::text, org_id, id, slug, true, now()
FROM status_pages WHERE org_id = ${orgId}::text`)

/** Queues removal of a renamed page's old slug route. */
export const queueSlugRemoval = (db: MapleDb, orgId: OrgId, pageId: StatusPageId, slug: string) =>
db.execute(Orm.sql`
INSERT INTO status_page_cleanups (id, org_id, page_id, slug, delete_snapshot, created_at)
VALUES (gen_random_uuid()::text, ${orgId}::text, ${pageId}::text, ${slug}::text, false, now())`)
71 changes: 44 additions & 27 deletions packages/backend/src/services/uptime/UptimeAlertSource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto"
import { bumpPagesForMonitors } from "@maple/backend/services/status-pages/outbox"
import { countOne, upsertDailyCounts } from "./uptime-daily"
import * as Orm from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
import { AlertRules, UptimeMonitorStates, UptimeMonitors } from "@maple/db/tables"
Expand Down Expand Up @@ -153,35 +155,50 @@ export class UptimeAlertSource extends Context.Service<UptimeAlertSource, Uptime
// Fenced on the state read above: a ping or check landing in between wins.
const error = describeMissedHeartbeat(row.lastPingAt, nowMs)
const marked = yield* dbExecute((db) =>
db.run(
PG.update(UptimeMonitorStates)
.set({
status: "down",
consecutiveFailures: row.consecutiveFailures + 1,
consecutiveSuccesses: 0,
statusChangedAt: nowMs,
lastCheckJson: {
at: nowMs,
ok: false,
durationMs: null,
statusCode: null,
error,
traceId: null,
db.transaction(
Effect.gen(function* () {
const rows = yield* db.run(
PG.update(UptimeMonitorStates)
.set({
status: "down",
consecutiveFailures: row.consecutiveFailures + 1,
consecutiveSuccesses: 0,
statusChangedAt: nowMs,
lastCheckJson: {
at: nowMs,
ok: false,
durationMs: null,
statusCode: null,
error,
traceId: null,
},
recentJson: appendRecentCheck(row.recentJson, {
at: nowMs,
ok: false,
durationMs: null,
}),
updatedAt: nowMs,
})
.where(($) => [
$.orgId.eq(orgId),
$.monitorId.eq(row.id),
$.status.eq(row.status),
$.updatedAt.eq(row.stateUpdatedAt),
])
.returning("monitorId"),
)
if (rows.length === 0) return rows
// The miss counts as a down check, so the day's bar shows it, and pages showing it republish.
yield* upsertDailyCounts(db, [
{
monitorId: row.id,
orgId,
count: countOne({ at: nowMs, ok: false, down: true }),
},
recentJson: appendRecentCheck(row.recentJson, {
at: nowMs,
ok: false,
durationMs: null,
}),
updatedAt: nowMs,
})
.where(($) => [
$.orgId.eq(orgId),
$.monitorId.eq(row.id),
$.status.eq(row.status),
$.updatedAt.eq(row.stateUpdatedAt),
])
.returning("monitorId"),
yield* bumpPagesForMonitors(db, orgId, [row.id])
return rows
}),
),
)
yield* Effect.annotateCurrentSpan({
Expand Down
85 changes: 85 additions & 0 deletions packages/backend/src/services/uptime/UptimeMonitorsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,91 @@ describe("UptimeMonitorsService", () => {
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("counts checks per UTC day and marks pages showing a monitor whose status flipped", () => {
const testDb = createTestDb(trackedDbs)
const day1 = Date.UTC(2026, 9, 27, 23, 59)
const day2 = Date.UTC(2026, 9, 28, 0, 1)
return Effect.gen(function* () {
const service = yield* UptimeMonitorsService
const monitor = yield* createHttp("API", { failuresToDown: 1 })
yield* Effect.promise(() =>
executeSql(
testDb,
`INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at)
VALUES ('11111111-1111-4111-8111-111111111111', $1, 'Acme', 'acme', $2, now(), now())`,
[ORG, USER],
).then(() =>
executeSql(
testDb,
`INSERT INTO status_page_components (id, org_id, page_id, name, monitor_id, created_at, updated_at)
VALUES ('22222222-2222-4222-8222-222222222222', $1, '11111111-1111-4111-8111-111111111111', 'API', $2, now(), now())`,
[ORG, monitor.id],
),
),
)
const revision = () =>
Effect.promise(() =>
queryFirstRow<{ revision: string }>(testDb, `SELECT revision::text FROM status_pages`),
).pipe(Effect.map((row) => row?.revision))

yield* service.recordChecks([
report(monitor.id, day1 - 60_000, true),
report(monitor.id, day1, false),
])
// Up then down: the flip marks the page.
assert.strictEqual(yield* revision(), "2")
yield* service.recordChecks([report(monitor.id, day2, false)])
// Still down: nothing new to publish.
assert.strictEqual(yield* revision(), "2")
// A retried batch is not counted twice.
yield* service.recordChecks([report(monitor.id, day2, false)])

const rows = yield* Effect.promise(() =>
testDb.pglite.query<{ day: string; checks: number; failures: number; down_checks: number }>(
`SELECT day, checks, failures, down_checks FROM uptime_monitor_daily ORDER BY day`,
),
)
assert.deepStrictEqual(rows.rows, [
{ day: "2026-10-27", checks: 2, failures: 1, down_checks: 1 },
{ day: "2026-10-28", checks: 1, failures: 1, down_checks: 1 },
])
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("marks pages showing a monitor when it is paused, resumed or deleted", () => {
const testDb = createTestDb(trackedDbs)
return Effect.gen(function* () {
const service = yield* UptimeMonitorsService
const monitor = yield* createHttp("API", { failuresToDown: 1 })
yield* Effect.promise(() =>
executeSql(
testDb,
`INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at)
VALUES ('11111111-1111-4111-8111-111111111111', $1, 'Acme', 'acme', $2, now(), now())`,
[ORG, USER],
).then(() =>
executeSql(
testDb,
`INSERT INTO status_page_components (id, org_id, page_id, name, monitor_id, created_at, updated_at)
VALUES ('22222222-2222-4222-8222-222222222222', $1, '11111111-1111-4111-8111-111111111111', 'API', $2, now(), now())`,
[ORG, monitor.id],
),
),
)
const revision = () =>
Effect.promise(() =>
queryFirstRow<{ revision: string }>(testDb, `SELECT revision::text FROM status_pages`),
).pipe(Effect.map((row) => row?.revision))

yield* service.update(ORG, monitor.id, { enabled: false, updatedBy: USER })
assert.strictEqual(yield* revision(), "2")
yield* service.update(ORG, monitor.id, { enabled: true, updatedBy: USER })
assert.strictEqual(yield* revision(), "3")
yield* service.delete(ORG, monitor.id)
assert.strictEqual(yield* revision(), "4")
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("records the rest of a batch when a monitor is deleted between the read and the write", () => {
const testDb = createTestDb(trackedDbs)
return Effect.gen(function* () {
Expand Down
Loading
Loading