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
2 changes: 0 additions & 2 deletions apps/ai/src/mcp/lib/maintenance-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import { doc } from "./tool-doc"

type WindowRow = typeof MaintenanceWindowRow.Type

export const MAINTENANCE_WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const

const listOf = <K extends string>(key: K, values: ReadonlyArray<string> | undefined) =>
values === undefined ? undefined : { [key]: [...values] }

Expand Down
3 changes: 1 addition & 2 deletions apps/ai/src/mcp/tools/create-maintenance-window.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { McpInvalidInputError, type McpToolRegistrar } from "./types"
import { Clock, DateTime, Effect, Schema } from "effect"
import { AlertMaintenanceWindowCreateRequest } from "@maple/domain/http"
import { AlertMaintenanceWindowCreateRequest, MAINTENANCE_WEEKDAYS } from "@maple/domain/http"
import { MaintenanceWindowMutationOutput } from "@maple/domain/mcp-outputs"
import { AlertMaintenanceWindowsService } from "@maple/backend/services/alerts/AlertMaintenanceWindowsService"
import { parseWarehouseDateTime } from "@maple/query-engine"
import { CurrentMcpTenant } from "../lib/query-warehouse"
import {
createWindowErrors,
MAINTENANCE_WEEKDAYS,
maintenanceWindowFields,
toMaintenanceWindowRow,
} from "../lib/maintenance-windows"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* read writes nothing, and window-owned silences are managed only through the window.
*/
import { afterEach, assert, describe, it } from "@effect/vitest"
import { Array as Arr, Effect, Layer, Option, Order, Schema } from "effect"
import { Array as Arr, Effect, Exit, Layer, Option, Order, Schema } from "effect"
import { TestClock } from "effect/testing"
import {
AlertMaintenanceWindowCreateRequest,
Expand Down Expand Up @@ -261,6 +261,43 @@ describe("maintenance window sync", () => {
}).pipe(Effect.provide(layer))
})

it.effect("writes nothing from a read taken in the same millisecond as an edit", () => {
const { layer } = setup()
return Effect.gen(function* () {
yield* TestClock.setTime(NOW)
const window = yield* createWindow(nightly)
const database = yield* Database
const readWindow = database.execute((db) =>
db.run(selectMaintenanceWindows().where(($) => [$.orgId.eq(orgId), $.id.eq(window.id)])),
)
const [staleRow] = yield* readWindow
const stale = Option.getOrThrow(decodeMaintenanceWindow(staleRow))

// The clock does not move: the edit and the stale read share one updatedAt.
yield* updateWindow(
window.id,
new AlertMaintenanceWindowUpdateRequest({ schedule: { ...nightly, startTime: "23:45" } }),
)
const edited = yield* spans(window.id)
assert.isTrue(edited.length > 0)
assert.isTrue(Arr.every(edited, ([startsAt]) => new Date(startsAt).getUTCMinutes() === 45))

const written = yield* database.execute((db) =>
db.transaction(syncWindow(db, () => crypto.randomUUID(), stale, NOW, false)),
)
assert.strictEqual(written, 0)
assert.deepStrictEqual(yield* spans(window.id), edited)

const [freshRow] = yield* readWindow
const fresh = Option.getOrThrow(decodeMaintenanceWindow(freshRow))
const writtenFresh = yield* database.execute((db) =>
db.transaction(syncWindow(db, () => crypto.randomUUID(), fresh, NOW, false)),
)
assert.isTrue(writtenFresh > 0)
assert.deepStrictEqual(yield* spans(window.id), edited)
}).pipe(Effect.provide(layer))
})

it.effect("drops a future row that is not one of the window's occurrences on the next pass", () => {
const { testDb, layer } = setup()
return Effect.gen(function* () {
Expand Down Expand Up @@ -330,6 +367,39 @@ describe("maintenance window sync", () => {
}).pipe(Effect.provide(layer))
})

it.effect("lets only one of two concurrent creates take an org's last window slot", () => {
const { testDb, layer } = setup()
return Effect.gen(function* () {
yield* TestClock.setTime(NOW)
yield* Effect.promise(() =>
executeSql(
testDb,
`INSERT INTO alert_maintenance_windows
(id, org_id, name, reason, matchers_json, schedule_json, exclude_from_baselines, enabled,
once_ends_at, created_at, updated_at, created_by, updated_by)
SELECT gen_random_uuid()::text, $1, 'seeded ' || n, null, '{"services":["x"]}',
'{"_tag":"weekly","timeZone":"UTC","days":["sat"],"startTime":"02:00","durationMinutes":60}',
true, false, null, now(), now(), 'u', 'u'
FROM generate_series(1, 99) AS n`,
[orgId],
),
)
// PGlite runs one transaction at a time, so this pins the recount inside the
// transaction; the advisory lock only matters on a real multi-connection Postgres.
const results = yield* Effect.all(
[Effect.exit(createWindow(nightly, "First")), Effect.exit(createWindow(nightly, "Second"))],
{ concurrency: "unbounded" },
)
assert.lengthOf(Arr.filter(results, Exit.isSuccess), 1)
const refused = Arr.findFirst(results, Exit.isFailure).pipe(Option.getOrThrow)
assert.instanceOf(getError(refused), AlertValidationError)
const listed = yield* AlertMaintenanceWindowsService.use((service) =>
service.list(orgId, { limit: 200 }),
)
assert.lengthOf(listed.windows, 100)
}).pipe(Effect.provide(layer))
})

it.effect("leaves window silences out of the open-silence cap", () => {
const { testDb, layer } = setup()
return Effect.gen(function* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Array as Arr, Clock, Context, Effect, Layer, Option, Schema } from "eff
import { Database } from "@maple/backend/platform/DatabaseLive"
import { makeDbExecute } from "@maple/backend/platform/db-execute"
import * as Policy from "@maple/domain/policy"
import { makePersistenceError } from "./alert-persistence"
import { lockOrgMaintenanceWindows, makePersistenceError } from "./alert-persistence"
import { AlertRuntime } from "./AlertRuntime"
import { retireSilences, syncWindow } from "./alert-maintenance-window-sync"
import {
Expand Down Expand Up @@ -248,22 +248,6 @@ export class AlertMaintenanceWindowsService extends Context.Service<
yield* validateSchedule(schedule, nowMs)
yield* requireRuleIds(orgId, request.matchers)

const existing = yield* dbExecute((db) =>
db.run(
PG.from(AlertMaintenanceWindows)
.select("id")
.where(($) => [
$.orgId.eq(orgId),
PG.or($.onceEndsAt.isNull(), $.onceEndsAt.gt(nowMs)),
])
.limit(MAX_WINDOWS_PER_ORG),
),
)
if (existing.length >= MAX_WINDOWS_PER_ORG)
return yield* validationError(
`An organization can have at most ${MAX_WINDOWS_PER_ORG} maintenance windows`,
)

const row: AlertMaintenanceWindowRow = {
id: decodeWindowIdSync(runtime.makeUuid()),
orgId,
Expand All @@ -274,24 +258,35 @@ export class AlertMaintenanceWindowsService extends Context.Service<
excludeFromBaselines: request.excludeFromBaselines ?? true,
enabled: request.enabled ?? true,
onceEndsAt: onceEndsAtOf(schedule),
revision: 1,
createdAt: nowMs,
updatedAt: nowMs,
createdBy: userId,
updatedBy: userId,
}
const window: DecodedMaintenanceWindow = { row, matchers: request.matchers, schedule }
// The cap is counted under the org lock, so concurrent creates cannot both pass it.
// Occurrences are written right away, so one starting before the next pass is not missed.
yield* dbExecute((db) =>
db.transaction(
db
.run(PG.insertInto(AlertMaintenanceWindows).values(row))
.pipe(
Effect.andThen(
row.enabled
? syncWindow(db, runtime.makeUuid, window, nowMs, false)
: Effect.void,
),
),
Effect.gen(function* () {
yield* lockOrgMaintenanceWindows(db, orgId)
const existing = yield* db.run(
PG.from(AlertMaintenanceWindows)
.select("id")
.where(($) => [
$.orgId.eq(orgId),
PG.or($.onceEndsAt.isNull(), $.onceEndsAt.gt(nowMs)),
])
.limit(MAX_WINDOWS_PER_ORG),
)
if (existing.length >= MAX_WINDOWS_PER_ORG)
return yield* validationError(
`An organization can have at most ${MAX_WINDOWS_PER_ORG} maintenance windows`,
)
yield* db.run(PG.insertInto(AlertMaintenanceWindows).values(row))
if (row.enabled) yield* syncWindow(db, runtime.makeUuid, window, nowMs, false)
}),
),
)
yield* Effect.annotateCurrentSpan({ "maple.maintenance_window.id": row.id })
Expand Down Expand Up @@ -336,22 +331,34 @@ export class AlertMaintenanceWindowsService extends Context.Service<
yield* dbExecute((db) =>
db.transaction(
Effect.gen(function* () {
yield* db.run(
// The bumped revision voids any materialize pass that read the row before this edit.
const written = yield* db.run(
PG.update(AlertMaintenanceWindows)
.set({
.set(($) => ({
name: next.row.name,
reason: next.row.reason,
matchersJson: next.matchers,
scheduleJson: next.schedule,
excludeFromBaselines: next.row.excludeFromBaselines,
enabled: next.row.enabled,
onceEndsAt: next.row.onceEndsAt,
revision: $.revision.add(1),
updatedAt: nowMs,
updatedBy: userId,
})
.where(($) => [$.orgId.eq(orgId), $.id.eq(windowId)]),
}))
.where(($) => [$.orgId.eq(orgId), $.id.eq(windowId)])
.returning("revision"),
)
if (next.row.enabled) yield* syncWindow(db, runtime.makeUuid, next, nowMs, true)
const revision = written[0]?.revision
if (revision === undefined) return
if (next.row.enabled)
yield* syncWindow(
db,
runtime.makeUuid,
{ ...next, row: { ...next.row, revision } },
nowMs,
true,
)
else yield* retireSilences(db, orgId, windowId, nowMs, userId)
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const insertOccurrence = (
/**
* Brings the window's silences in line with its occurrences in [now, now + 48h), inside
* the caller's transaction. Writes only while the window row is still enabled at the
* version read, so a pass holding a stale read cannot undo an edit. Each occurrence pairs
* revision read, so a pass holding a stale read cannot undo an edit. Each occurrence pairs
* with the row of its own start. `refresh` (after an edit) gives running rows the new
* shape and reopens an occurrence whose row ended early (a disable, then re-enable).
*/
Expand All @@ -109,7 +109,7 @@ export const syncWindow = (
$.orgId.eq(orgId),
$.id.eq(id),
$.enabled.eq(true),
$.updatedAt.eq(window.row.updatedAt),
$.revision.eq(window.row.revision),
])
.limit(1)
.forUpdate(),
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/services/alerts/alert-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ export const makePersistenceError = makePersistenceErrorMapper(
*/
export const lockOrgAlertRules = (db: MapleDb, orgId: OrgId) =>
db.execute(Orm.sql`select pg_advisory_xact_lock(hashtext(${orgId}))`)

/** The per-org lock a maintenance window create takes, so the org cap count and the insert are atomic. */
export const lockOrgMaintenanceWindows = (db: MapleDb, orgId: OrgId) =>
db.execute(
Orm.sql`select pg_advisory_xact_lock(hashtext(${orgId}), hashtext('alert_maintenance_windows'))`,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- metadata
ALTER TABLE "alert_maintenance_windows" ADD COLUMN IF NOT EXISTS "revision" integer NOT NULL DEFAULT 1;
Loading
Loading