Files
triggerdotdev--trigger.dev/apps/webapp/app/services/sessionsReplicationInstance.server.ts
Daniel Sutton bea7e2be90 feat(webapp,run-store): route run-graph reads and writes through the run-store router (#4237)
## Summary

Run-graph data (runs, batches, waitpoints, and their related tables) can
now live in a database separate from the control plane, with every read
and write routed to the correct database by each run's residency. This
makes reading and writing run data more reliable once the two are split,
and is a no-op for single-database installs.

## Design

- Run-graph table access goes through the run-store router, which
selects the legacy or the new run-ops store per run instead of assuming
one shared client.
- The legacy run-ops client is now independently pointable, so legacy
run data can be served from its own database (and replica) rather than
the control-plane connection.
- Run-graph writes go straight to the run-graph database instead of
being forwarded through the control plane, and replication targets are
split so runs in the new database still replicate to analytics without
under-counting.
- Read-through slots refuse the control-plane client, so a missing
residency fails loudly instead of silently reading the wrong database.
- Migration `20260710120000_drop_remaining_run_graph_seam_foreign_keys`
drops the foreign keys that still crossed the run-graph / control-plane
seam, which is what lets the two live in separate databases.

The split stays off unless explicitly enabled and the two databases are
confirmed physically distinct; startup fails closed otherwise.

Verified by running the full dashboard end-to-end suite against both a
single-database configuration and a three-database configuration
(control plane, the new database, and a physically separate legacy
database), with runs on both residencies. No misrouted reads in either
configuration.
2026-07-13 13:54:54 +01:00

90 lines
3.9 KiB
TypeScript

import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { meter, provider } from "~/v3/tracer.server";
import { SessionsReplicationService } from "./sessionsReplicationService.server";
import { signalsEmitter } from "./signals.server";
export const sessionsReplicationInstance = singleton(
"sessionsReplicationInstance",
initializeSessionsReplicationInstance
);
function initializeSessionsReplicationInstance() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
if (!env.SESSION_REPLICATION_CLICKHOUSE_URL) {
console.log("🗃️ Sessions replication service not enabled");
return;
}
console.log("🗃️ Sessions replication service enabled");
const service = new SessionsReplicationService({
clickhouseFactory,
// Sessions-replication source DSN; falls back to DATABASE_URL when its dedicated var is unset.
pgConnectionUrl: env.SESSION_REPLICATION_DATABASE_URL ?? DATABASE_URL,
serviceName: "sessions-replication",
slotName: env.SESSION_REPLICATION_SLOT_NAME,
publicationName: env.SESSION_REPLICATION_PUBLICATION_NAME,
redisOptions: {
keyPrefix: "sessions-replication:",
port: env.RUN_REPLICATION_REDIS_PORT ?? undefined,
host: env.RUN_REPLICATION_REDIS_HOST ?? undefined,
username: env.RUN_REPLICATION_REDIS_USERNAME ?? undefined,
password: env.RUN_REPLICATION_REDIS_PASSWORD ?? undefined,
enableAutoPipelining: true,
...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
maxFlushConcurrency: env.SESSION_REPLICATION_MAX_FLUSH_CONCURRENCY,
flushIntervalMs: env.SESSION_REPLICATION_FLUSH_INTERVAL_MS,
flushBatchSize: env.SESSION_REPLICATION_FLUSH_BATCH_SIZE,
leaderLockTimeoutMs: env.SESSION_REPLICATION_LEADER_LOCK_TIMEOUT_MS,
leaderLockExtendIntervalMs: env.SESSION_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS,
leaderLockAcquireAdditionalTimeMs: env.SESSION_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS,
leaderLockRetryIntervalMs: env.SESSION_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS,
ackIntervalSeconds: env.SESSION_REPLICATION_ACK_INTERVAL_SECONDS,
logLevel: env.SESSION_REPLICATION_LOG_LEVEL,
waitForAsyncInsert: env.SESSION_REPLICATION_WAIT_FOR_ASYNC_INSERT === "1",
tracer: provider.getTracer("sessions-replication-service"),
meter,
insertMaxRetries: env.SESSION_REPLICATION_INSERT_MAX_RETRIES,
insertBaseDelayMs: env.SESSION_REPLICATION_INSERT_BASE_DELAY_MS,
insertMaxDelayMs: env.SESSION_REPLICATION_INSERT_MAX_DELAY_MS,
insertStrategy: env.SESSION_REPLICATION_INSERT_STRATEGY,
});
if (env.SESSION_REPLICATION_ENABLED === "1") {
// Gate start() on the org data-stores registry being loaded. Starting earlier would
// race the registry load — sync factory lookups would return `null` and route org-scoped
// sessions to the default ClickHouse, writing them to the wrong cluster.
clickhouseFactory
.isReady()
.then(() => service.start())
.then(() => {
console.log("🗃️ Sessions replication service started");
})
.catch((error) => {
console.error("🗃️ Sessions replication service failed to start", {
error,
});
});
// SIGTERM/SIGINT fire during process teardown; wrap the async shutdown so an
// unhandled rejection doesn't bubble past process exit.
const shutdownSessionsReplication = () => {
service.shutdown().catch((error) => {
console.error("🗃️ Sessions replication service shutdown error", {
error,
});
});
};
signalsEmitter.on("SIGTERM", shutdownSessionsReplication);
signalsEmitter.on("SIGINT", shutdownSessionsReplication);
}
return service;
}