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
146 changes: 104 additions & 42 deletions apps/api/src/services/org/OrgClickHouseSettingsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ type RuntimeBackendConfig = {

type ActiveRow = typeof orgClickHouseSettings.$inferSelect

/** The columns `resolveRuntimeConfig` actually caches — see `selectCachedRow`. */
type CachedSettingsRow = Pick<
ActiveRow,
| "schemaVersion"
| "syncStatus"
| "chUrl"
| "chUser"
| "chDatabase"
| "chPasswordCiphertext"
| "chPasswordIv"
| "chPasswordTag"
>

// Edge-cache bucket + TTL for the per-org runtime ClickHouse config lookup.
// `resolveRuntimeConfig` runs on the hot path of every warehouse SQL execution
// (and once per missing bucket in the cache fan-out), so a long-lived
Expand Down Expand Up @@ -104,7 +117,7 @@ const CachedChSettings = Schema.Struct({
type CachedChSettings = typeof CachedChSettings.Type
const CachedChSettingsOrNull = Schema.NullOr(CachedChSettings)

const toCachedChSettings = (row: ActiveRow): CachedChSettings => ({
const toCachedChSettings = (row: CachedSettingsRow): CachedChSettings => ({
schemaVersion: row.schemaVersion,
chUrl: row.chUrl,
chUser: row.chUser,
Expand Down Expand Up @@ -772,6 +785,34 @@ export class OrgClickHouseSettingsService extends Context.Service<
return Option.fromNullishOr(rows[0])
})

// The hot-path read: only the columns that end up in `CachedChSettings`.
// `selectActiveRow`'s `SELECT *` pulls all 14 including the encrypted
// password blobs and the migration bookkeeping, none of which this path
// looks at — and this is the read that runs on every warehouse query.
const selectCachedRow = Effect.fn("OrgClickHouseSettingsService.selectCachedRow")(function* (
orgId: OrgId,
) {
const rows = yield* database
.execute((db) =>
db
.select({
schemaVersion: orgClickHouseSettings.schemaVersion,
syncStatus: orgClickHouseSettings.syncStatus,
chUrl: orgClickHouseSettings.chUrl,
chUser: orgClickHouseSettings.chUser,
chDatabase: orgClickHouseSettings.chDatabase,
chPasswordCiphertext: orgClickHouseSettings.chPasswordCiphertext,
chPasswordIv: orgClickHouseSettings.chPasswordIv,
chPasswordTag: orgClickHouseSettings.chPasswordTag,
})
.from(orgClickHouseSettings)
.where(eq(orgClickHouseSettings.orgId, orgId))
.limit(1),
)
.pipe(Effect.mapError(toPersistenceError))
return Option.fromNullishOr(rows[0])
})

// Bust the cached runtime config for an org after any write to its settings
// row, so the next warehouse query re-resolves rather than serving a stale
// value. Clears both the in-isolate memo (this isolate only — other isolates
Expand Down Expand Up @@ -1166,52 +1207,65 @@ export class OrgClickHouseSettingsService extends Context.Service<
})
})

const resolveRuntimeConfig = Effect.fn("OrgClickHouseSettingsService.resolveRuntimeConfig")(
// The cached settings row behind both `resolveRuntimeConfig` and
// `isWarehouseWriteReady`.
//
// `selectCachedRow` is a Postgres round-trip on the hot path of EVERY
// warehouse SQL execution, and the bucket-cache fan-out re-runs it once per
// missing range. Two cache layers sit in front: a module-scoped in-isolate
// memo (zero network on a warm isolate) and, on a memo miss, the shared edge
// cache (a 1h entry, removing the cold round-trip on repeat loads).
//
// Note there is NO single-flight: `EdgeCacheService.getOrCompute` deliberately
// refuses to share an in-flight Effect across requests, because Cloudflare ties
// I/O objects to the request that created them. Concurrent cold misses each pay
// their own lookup and converge through the cache afterwards.
//
// Both layers store the ENCRYPTED row projection (or `null`) and decryption
// happens per-request in `resolveRuntimeConfig`, so plaintext credentials never
// enter a cache.
const resolveCachedSettings = Effect.fn("OrgClickHouseSettingsService.resolveCachedSettings")(
function* (orgId: OrgId) {
// `selectActiveRow` is a Postgres round-trip on the hot path of EVERY
// warehouse SQL execution, and the bucket-cache fan-out re-runs it once
// per missing range. Two cache layers sit in front: a module-scoped
// in-isolate memo (zero network on a warm isolate) and, on a memo miss,
// the shared edge cache (its in-flight single-flight collapses the
// concurrent fan-out into one lookup; the 5-min entry removes the cold
// round-trip on repeat loads). Both store the ENCRYPTED row projection
// (or `null`) and decrypt per-request below, so plaintext credentials
// never enter a cache.
const nowMs = yield* Clock.currentTimeMillis
const memoized = runtimeConfigMemo.get(orgId)
let cached: CachedChSettings | null
if (memoized !== undefined && memoized.expiresAt > nowMs) {
yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", true)
cached = memoized.value
} else {
yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", false)
const edgeCache = yield* Effect.serviceOption(EdgeCacheService)
const lookup = selectActiveRow(orgId).pipe(
Effect.map((row) => (Option.isSome(row) ? toCachedChSettings(row.value) : null)),
)
cached = Option.isNone(edgeCache)
? yield* lookup
: yield* edgeCache.value
.getOrCompute(
{
bucket: ORG_CH_CONFIG_BUCKET,
key: orgId,
ttlSeconds: ORG_CH_CONFIG_TTL_SECONDS,
schema: CachedChSettingsOrNull,
},
lookup,
)
.pipe(
Effect.tap((result) =>
Effect.annotateCurrentSpan("clickhouse.config.cacheHit", result.hit),
),
Effect.map((result) => result.value),
)
runtimeConfigMemo.set(orgId, {
value: cached,
expiresAt: nowMs + ORG_CH_CONFIG_MEMO_TTL_MS,
})
return memoized.value
}
yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", false)
const edgeCache = yield* Effect.serviceOption(EdgeCacheService)
const lookup = selectCachedRow(orgId).pipe(
Effect.map((row) => (Option.isSome(row) ? toCachedChSettings(row.value) : null)),
)
const cached: CachedChSettings | null = Option.isNone(edgeCache)
? yield* lookup
: yield* edgeCache.value
.getOrCompute(
{
bucket: ORG_CH_CONFIG_BUCKET,
key: orgId,
ttlSeconds: ORG_CH_CONFIG_TTL_SECONDS,
schema: CachedChSettingsOrNull,
},
lookup,
)
.pipe(
Effect.tap((result) =>
Effect.annotateCurrentSpan("clickhouse.config.cacheHit", result.hit),
),
Effect.map((result) => result.value),
)
runtimeConfigMemo.set(orgId, {
value: cached,
expiresAt: nowMs + ORG_CH_CONFIG_MEMO_TTL_MS,
})
return cached
},
)

const resolveRuntimeConfig = Effect.fn("OrgClickHouseSettingsService.resolveRuntimeConfig")(
function* (orgId: OrgId) {
const cached = yield* resolveCachedSettings(orgId)

if (cached === null) {
return Option.none<RuntimeBackendConfig>()
Expand Down Expand Up @@ -1250,9 +1304,17 @@ export class OrgClickHouseSettingsService extends Context.Service<
// Unlike `resolveRuntimeConfig` — which deliberately ignores readiness because
// the org's own collector writes traces/logs straight to its CH regardless —
// this gate matters for data whose ONLY writer is the readiness-aware gateway.
//
// Deliberately NOT behind `resolveCachedSettings`, even though it is the same
// row. That path memoizes per isolate for 5 minutes, and this gate decides
// which warehouse a read is answered from: a stale `false` right after
// onboarding flips sends reads to Tinybird while the gateway is already
// writing to the org's ClickHouse, so the data silently goes missing until the
// memo expires. It reads the narrow projection rather than `SELECT *`, but it
// reads it fresh.
const isWarehouseWriteReady = Effect.fn("OrgClickHouseSettingsService.isWarehouseWriteReady")(
function* (orgId: OrgId) {
const row = yield* selectActiveRow(orgId)
const row = yield* selectCachedRow(orgId)
return (
Option.isSome(row) &&
row.value.syncStatus === "connected" &&
Expand Down
97 changes: 97 additions & 0 deletions apps/web/src/components/replays/replays-filter-inputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { encodeKey } from "@/lib/cache-key"
import { REPLAYS_PAGE_SIZE } from "@/hooks/use-infinite-replays"
import { replaysFilterInputs, type ReplaysSearchState } from "./replays-filter-inputs"

/**
* The route's `loader` prefetches both list queries and the component then reads
* them. That only pays off if the two produce the *same* atom-family key — a
* mismatch silently doubles the requests instead of halving the latency, and
* nothing else in the app would notice. These lock the invariant down.
*/
describe("replaysFilterInputs", () => {
const listKey = (search: ReplaysSearchState) =>
encodeKey({ ...replaysFilterInputs(search), limit: REPLAYS_PAGE_SIZE, offset: 0 })
const facetsKey = (search: ReplaysSearchState) => encodeKey(replaysFilterInputs(search))

afterEach(() => {
vi.useRealTimers()
})

it("keys identically across the loader/component gap for a relative preset", () => {
// The loader resolves `now`, then the component resolves it again after the
// route chunk evaluates. `encodeKey` snaps to a 15s grid, so both land on one
// entry — this is what makes the prefetch a prefetch rather than a duplicate.
// Pinned to the start of a bucket and advanced 14s, which is far more than the
// real gap and keeps the assertion off a grid boundary.
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-08-04T12:00:00.000Z"))
const search: ReplaysSearchState = { timePreset: "24h" }

const loaderList = listKey(search)
const loaderFacets = facetsKey(search)
vi.advanceTimersByTime(14_000)

expect(listKey(search)).toBe(loaderList)
expect(facetsKey(search)).toBe(loaderFacets)
})

it("keys identically with no search params at all (the default 24h entry)", () => {
expect(listKey({})).toBe(listKey({ timePreset: "24h" }))
})

it("keys identically for an absolute window", () => {
const search: ReplaysSearchState = {
startTime: "2026-08-01 00:00:00",
endTime: "2026-08-02 00:00:00",
}

expect(listKey(search)).toBe(listKey(search))
expect(replaysFilterInputs(search)).toMatchObject({
startTime: "2026-08-01 00:00:00",
endTime: "2026-08-02 00:00:00",
})
})

it("separates the list and facets keys, which query different shapes", () => {
const search: ReplaysSearchState = { timePreset: "24h" }

expect(listKey(search)).not.toBe(facetsKey(search))
})

it("changes the key when a filter changes", () => {
const base: ReplaysSearchState = { startTime: "2026-08-01 00:00:00", endTime: "2026-08-02 00:00:00" }

expect(listKey({ ...base, browser: "Chrome" })).not.toBe(listKey(base))
expect(listKey({ ...base, hasErrors: true })).not.toBe(listKey(base))
expect(listKey({ ...base, q: "checkout" })).not.toBe(listKey(base))
})

it("converts the whole-second URL params the warehouse wants in milliseconds", () => {
const inputs = replaysFilterInputs({
startTime: "2026-08-01 00:00:00",
endTime: "2026-08-02 00:00:00",
durationMin: 30,
durationMax: 600,
activeMin: 5,
activeMax: 120,
})

expect(inputs).toMatchObject({
durationMinMs: 30_000,
durationMaxMs: 600_000,
activeTimeMinMs: 5_000,
activeTimeMaxMs: 120_000,
})
})

it("leaves unset bounds undefined rather than sending 0", () => {
const inputs = replaysFilterInputs({
startTime: "2026-08-01 00:00:00",
endTime: "2026-08-02 00:00:00",
})

expect(inputs.durationMinMs).toBeUndefined()
expect(inputs.activeTimeMaxMs).toBeUndefined()
})
})
58 changes: 58 additions & 0 deletions apps/web/src/components/replays/replays-filter-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { resolveEffectiveTimeRange } from "@/hooks/use-effective-time-range"

/**
* The URL state the replays list filters on. Structurally the decoded search
* schema from the route, declared here so this module stays importable without
* pulling in the route (and its component tree).
*/
export interface ReplaysSearchState {
readonly startTime?: string
readonly endTime?: string
readonly timePreset?: string
readonly service?: string
readonly browser?: string
readonly country?: string
readonly deviceType?: string
readonly userId?: string
readonly visitorId?: string
readonly hasErrors?: boolean
readonly q?: string
readonly durationMin?: number
readonly durationMax?: number
readonly activeMin?: number
readonly activeMax?: number
}

/**
* Warehouse filter inputs for a given URL state.
*
* Shared by the route's `loader` and its component so both key to the identical
* atom-family entry — the loader's prefetch is only worth anything if the
* component reads the same key. Atom family keys run through `encodeKey`, which
* snaps timestamps to a 15s grid, so resolving `now` twice a few milliseconds
* apart still lands on one entry rather than fetching twice.
*/
export const replaysFilterInputs = (search: ReplaysSearchState) => {
const { startTime, endTime } = resolveEffectiveTimeRange(
search.startTime,
search.endTime,
search.timePreset ?? "24h",
)
return {
startTime,
endTime,
serviceName: search.service,
browser: search.browser,
country: search.country,
deviceType: search.deviceType,
userId: search.userId,
visitorId: search.visitorId,
hasErrors: search.hasErrors,
search: search.q,
// URL params are whole seconds; the warehouse filters in ms.
durationMinMs: search.durationMin != null ? search.durationMin * 1000 : undefined,
durationMaxMs: search.durationMax != null ? search.durationMax * 1000 : undefined,
activeTimeMinMs: search.activeMin != null ? search.activeMin * 1000 : undefined,
activeTimeMaxMs: search.activeMax != null ? search.activeMax * 1000 : undefined,
}
}
32 changes: 24 additions & 8 deletions apps/web/src/hooks/use-effective-time-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ interface TimeRange {
endTime: string
}

/**
* The hook's resolution, without the hook — so a router `loader` can build the
* exact same query inputs the component will and prefetch against them. Safe to
* call from both: atom family keys run through `encodeKey`, which snaps
* timestamps to a 15s grid, so a loader and a component resolving `now`
* milliseconds apart land on the same cache entry.
*/
export function resolveEffectiveTimeRange(
startTime?: string,
endTime?: string,
defaultRange: string = "12h",
): TimeRange {
if (startTime && endTime) {
return { startTime, endTime }
}
const resolved = relativeToAbsolute(defaultRange)
if (resolved) return resolved
return relativeToAbsolute("12h")!
}

/**
* Returns effective time range, applying defaults when not specified.
*
Expand All @@ -24,13 +44,9 @@ export function useEffectiveTimeRange(
const pageRefresh = useOptionalPageRefreshContext()
const refreshVersion = pageRefresh?.refreshVersion ?? 0

return useMemo(() => {
if (startTime && endTime) {
return { startTime, endTime }
}
const resolved = relativeToAbsolute(defaultRange)
if (resolved) return resolved
return relativeToAbsolute("12h")!
return useMemo(
() => resolveEffectiveTimeRange(startTime, endTime, defaultRange),
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startTime, endTime, defaultRange, refreshVersion])
[startTime, endTime, defaultRange, refreshVersion],
)
}
Loading
Loading