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
27 changes: 27 additions & 0 deletions drizzle/migrations/0029_market_watcher_jobs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
CREATE TABLE "market_watcher_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"market_id" text NOT NULL,
"job_key" text NOT NULL,
"event_type" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"attempt" integer DEFAULT 0 NOT NULL,
"lease_token" text,
"lease_until" timestamp with time zone,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"next_attempt_at" timestamp with time zone,
"watchers_notified" integer DEFAULT 0 NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "market_watcher_jobs_job_key_unique" UNIQUE("job_key")
);
--> statement-breakpoint
ALTER TABLE "market_watcher_jobs" ADD CONSTRAINT "market_watcher_jobs_market_id_markets_id_fk" FOREIGN KEY ("market_id") REFERENCES "public"."markets"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "market_watcher_jobs_market_event_idx" ON "market_watcher_jobs" USING btree ("market_id", "event_type");
--> statement-breakpoint
CREATE INDEX "market_watcher_jobs_ready_idx" ON "market_watcher_jobs" USING btree ("status", "next_attempt_at");
--> statement-breakpoint
CREATE INDEX "market_watcher_jobs_lease_idx" ON "market_watcher_jobs" USING btree ("status", "lease_until");
142 changes: 140 additions & 2 deletions src/config/env-schema.ts

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { envSchema, formatEnvErrors } from "./env-schema";

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
if (process.env.NODE_ENV !== "test") {
console.error("“ invalid environment configuration:\n" + formatEnvErrors(parsed.error));
console.error("❌ Invalid environment configuration:\n" + formatEnvErrors(parsed.error));
process.exit(1);
}
}

export const env = Object.freeze({
...(parsed.success ? parsed.data : {}),
anomalyDetectorMaxMemoryEntries: Math.min(Number(process.env.ANOMALY_DETECTOR_MAX_MEMORY_ENTRIES)||10000, 100000),
anomalyDetectorMaxAlertCardinality: Math.min(Number(process.env.ANOMALY_DETECTOR_MAX_ALERT_CARDINALITY)||1000, 10000),
});
export const env = parsed.success
? parsed.data
: ({} as ReturnType<typeof envSchema.parse>);
56 changes: 56 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,62 @@ export const marketWatchers = pgTable(
export type MarketWatcher = typeof marketWatchers.$inferSelect;
export type NewMarketWatcher = typeof marketWatchers.$inferInsert;

// ---------------------------------------------------------------------------
// Market Watcher Jobs
// ---------------------------------------------------------------------------
/**
* market_watcher_jobs — durable job runs for market watcher notifications.
*
* Each row tracks a single market watcher notification execution attempt.
* `jobKey` enforces the database-level idempotency boundary so concurrent
* triggers, failovers, and retries never duplicate watcher notifications.
*/
export const marketWatcherJobs = pgTable(
"market_watcher_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
marketId: text("market_id")
.notNull()
.references(() => markets.id, { onDelete: "cascade" }),
jobKey: text("job_key").notNull().unique(),
eventType: text("event_type").notNull(),
status: text("status").notNull().default("pending"),
attempt: integer("attempt").notNull().default(0),
leaseToken: text("lease_token"),
leaseUntil: timestamp("lease_until", { withTimezone: true }),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
watchersNotified: integer("watchers_notified").notNull().default(0),
payload: jsonb("payload").default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
marketWatcherJobsMarketEventIdx: index("market_watcher_jobs_market_event_idx").on(
t.marketId,
t.eventType,
),
marketWatcherJobsReadyIdx: index("market_watcher_jobs_ready_idx").on(
t.status,
t.nextAttemptAt,
),
marketWatcherJobsLeaseIdx: index("market_watcher_jobs_lease_idx").on(
t.status,
t.leaseUntil,
),
}),
);

export type MarketWatcherJob = typeof marketWatcherJobs.$inferSelect;
export type NewMarketWatcherJob = typeof marketWatcherJobs.$inferInsert;


// ---------------------------------------------------------------------------
// Referrals
// ---------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions src/metrics/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,33 @@ export const scheduledReportLeaseConflictsTotal = new Counter({
registers: [register],
});

export const marketWatcherJobRunsTotal = new Counter({
name: "market_watcher_job_runs_total",
help: "Market watcher job runs by terminal outcome",
labelNames: ["status"] as const,
registers: [register],
});

export const marketWatcherJobRetriesTotal = new Counter({
name: "market_watcher_job_retries_total",
help: "Market watcher job retry attempts by reason",
labelNames: ["reason"] as const,
registers: [register],
});

export const marketWatcherLeaseConflictsTotal = new Counter({
name: "market_watcher_lease_conflicts_total",
help: "Market watcher jobs skipped because another worker owns the lease",
registers: [register],
});

export const marketWatcherNotificationsTotal = new Counter({
name: "market_watcher_notifications_total",
help: "Total number of market watcher notifications generated and dispatched",
registers: [register],
});


export const webhookDeliveriesTotal = new Counter({
name: "webhook_deliveries_total",
help: "Total number of webhook deliveries, segmented by outcome status (success, failed)",
Expand Down
7 changes: 7 additions & 0 deletions src/queue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const backupVerificationQueueName = "backup-verification";
export const reconciliationQueueName = "reconciliation";
export const marketResolutionQueueName = "market-resolution";
export const scheduledReportQueueName = "scheduled-report-runs";
export const marketWatcherQueueName = "market-watcher-jobs";

export const webhookQueue = new Queue(webhookQueueName, {
// IORedis types conflict with BullMQ
Expand All @@ -42,4 +43,10 @@ export const scheduledReportQueue = new Queue(scheduledReportQueueName, {
connection: redisConnection,
});

export const marketWatcherQueue = new Queue(marketWatcherQueueName, {
// IORedis types conflict with BullMQ
connection: redisConnection,
});


export { Queue, Worker, QueueEvents };
Loading