b82d100483
## Summary Reliability and authorization fixes for realtime chat sessions: - Session-stream waitpoint delivery is scoped to the environment, so two environments using the same session `externalId` can no longer complete each other's waitpoints. - The session snapshot-url routes now enforce per-session authorization, and appending to a session's `out` channel requires secret-key auth, so a session-scoped token can't read another session's snapshot or forge assistant output. - Appends that carry an `X-Part-Id` header are deduplicated on retry, so a retried send can't duplicate a message. - Session creation rejects expired sessions (instead of triggering a run that can never receive input), `externalId` is immutable after creation, and the sessions list endpoint returns friendly `run_*` ids to match the single-session routes. ## Rollout The waitpoint cache key gains an environment prefix. To keep waitpoints registered by the previous deploy working across the boundary, the drain reads both the new and the previous key for this release; the legacy read can be removed a release later once no pre-deploy waitpoints remain.
259 lines
8.2 KiB
TypeScript
259 lines
8.2 KiB
TypeScript
import { Redis } from "ioredis";
|
|
import { defaultReconnectOnError } from "@internal/redis";
|
|
import { env } from "~/env.server";
|
|
import { singleton } from "~/utils/singleton";
|
|
import { logger } from "./logger.server";
|
|
|
|
// "ssw" — session-stream-waitpoint. Parallel to the input-stream variant
|
|
// (`isw:{runFriendlyId}:{streamId}`). Keyed on `{environmentId, addressingKey, io}`
|
|
// so a send() lands on the channel regardless of which run is waiting, and
|
|
// multiple concurrent waiters (e.g. two agents on one chat) all wake.
|
|
// The environmentId prefix is load-bearing: the addressing key is the
|
|
// user-supplied externalId (unique only per environment), and this Redis
|
|
// is shared — without it, two environments using the same externalId
|
|
// would drain each other's waitpoints.
|
|
const KEY_PREFIX = "ssw:";
|
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
function buildKey(environmentId: string, addressingKey: string, io: "out" | "in"): string {
|
|
return `${KEY_PREFIX}${environmentId}:${addressingKey}:${io}`;
|
|
}
|
|
|
|
// Pre-env-scoping key format, drained for one release so waitpoints from the
|
|
// previous deploy still wake. Removable once this has been live > turn timeout.
|
|
function buildLegacyKey(addressingKey: string, io: "out" | "in"): string {
|
|
return `${KEY_PREFIX}${addressingKey}:${io}`;
|
|
}
|
|
|
|
function initializeRedis(): Redis | undefined {
|
|
const host = env.CACHE_REDIS_HOST;
|
|
if (!host) {
|
|
return undefined;
|
|
}
|
|
|
|
return new Redis({
|
|
connectionName: "sessionStreamWaitpointCache",
|
|
host,
|
|
port: env.CACHE_REDIS_PORT,
|
|
username: env.CACHE_REDIS_USERNAME,
|
|
password: env.CACHE_REDIS_PASSWORD,
|
|
keyPrefix: "tr:",
|
|
enableAutoPipelining: true,
|
|
reconnectOnError: defaultReconnectOnError,
|
|
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
|
});
|
|
}
|
|
|
|
const redis = singleton("sessionStreamWaitpointCache", initializeRedis);
|
|
|
|
// Atomic SADD + PEXPIRE that only ever extends the key's TTL.
|
|
//
|
|
// Two concerns rolled into one script:
|
|
// 1. SADD + PEXPIRE as separate commands can leave the key with no TTL
|
|
// if the second call fails (or the process crashes in between).
|
|
// 2. Each waitpoint registers with its own `ttlMs` (derived from the
|
|
// waitpoint's timeout). Calling PEXPIRE unconditionally would let a
|
|
// short-TTL registration shrink the key's TTL below a longer-TTL
|
|
// sibling — evicting the sibling early and degrading the append-path
|
|
// fast drain to engine-timeout-only.
|
|
//
|
|
// The script: SADD the member, then set PEXPIRE only if the new TTL is
|
|
// greater than the current PTTL (or the key has no TTL yet). Engine-
|
|
// level timeouts still fire per-waitpoint; this keeps the Redis key
|
|
// alive for the longest-lived member.
|
|
const ADD_WAITPOINT_SCRIPT = `
|
|
redis.call("SADD", KEYS[1], ARGV[1])
|
|
local newTtl = tonumber(ARGV[2])
|
|
local currentTtl = redis.call("PTTL", KEYS[1])
|
|
if currentTtl < 0 or newTtl > currentTtl then
|
|
redis.call("PEXPIRE", KEYS[1], newTtl)
|
|
end
|
|
return 1
|
|
`;
|
|
|
|
/**
|
|
* Register a waitpoint as pending on the given session channel. Called
|
|
* from the `.wait()` create-waitpoint route. Multiple waiters on the same
|
|
* channel are allowed (stored as a Redis set).
|
|
*/
|
|
export async function addSessionStreamWaitpoint(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in",
|
|
waitpointId: string,
|
|
ttlMs?: number
|
|
): Promise<void> {
|
|
if (!redis) return;
|
|
|
|
try {
|
|
const key = buildKey(environmentId, addressingKey, io);
|
|
await redis.eval(
|
|
ADD_WAITPOINT_SCRIPT,
|
|
1,
|
|
key,
|
|
waitpointId,
|
|
String(ttlMs ?? DEFAULT_TTL_MS)
|
|
);
|
|
} catch (error) {
|
|
logger.error("Failed to set session stream waitpoint cache", {
|
|
environmentId,
|
|
addressingKey,
|
|
io,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically read + clear all waitpoints registered on the given session
|
|
* channel. Called from the append handler so the next append sees an
|
|
* empty set even if two appends race.
|
|
*/
|
|
export async function drainSessionStreamWaitpoints(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in"
|
|
): Promise<string[]> {
|
|
if (!redis) return [];
|
|
|
|
try {
|
|
const key = buildKey(environmentId, addressingKey, io);
|
|
const legacyKey = buildLegacyKey(addressingKey, io);
|
|
const pipeline = redis.multi();
|
|
pipeline.smembers(key);
|
|
pipeline.del(key);
|
|
pipeline.smembers(legacyKey);
|
|
pipeline.del(legacyKey);
|
|
const results = await pipeline.exec();
|
|
if (!results) return [];
|
|
// Union members from the env-scoped key and the legacy key (dual-read).
|
|
const ids = new Set<string>();
|
|
for (const idx of [0, 2]) {
|
|
const entry = results[idx];
|
|
if (!entry) continue;
|
|
const [err, members] = entry;
|
|
if (err || !Array.isArray(members)) continue;
|
|
for (const m of members as string[]) ids.add(m);
|
|
}
|
|
return [...ids];
|
|
} catch (error) {
|
|
logger.error("Failed to drain session stream waitpoint cache", {
|
|
environmentId,
|
|
addressingKey,
|
|
io,
|
|
error,
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a single waitpoint from the pending set. Called after a race
|
|
* where `.wait()` completed the waitpoint from pre-arrived data.
|
|
*/
|
|
// "ssa" — session-stream-append. Idempotency claim for the append route:
|
|
// when a caller supplies an `X-Part-Id`, the first request atomically claims
|
|
// the key (SET NX) before appending; a concurrent or retried POST with the
|
|
// same id fails the claim and skips the append, so it never produces a
|
|
// duplicate record (or double-fires the waitpoint drain). The claim is
|
|
// released if the append fails, so a retry of a genuinely failed append
|
|
// still goes through. 5-minute window — covers retry storms, not a
|
|
// permanent idempotency store.
|
|
const APPEND_DEDUPE_PREFIX = "ssa:";
|
|
const APPEND_DEDUPE_TTL_SECONDS = 5 * 60;
|
|
|
|
function buildAppendDedupeKey(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in",
|
|
partId: string
|
|
): string {
|
|
// Encode the free-form segments — `addressingKey` (externalId) and `partId`
|
|
// (X-Part-Id) are user-supplied and may contain `:`, which would otherwise
|
|
// let different tuples collide and falsely suppress an append.
|
|
return `${APPEND_DEDUPE_PREFIX}${encodeURIComponent(environmentId)}:${encodeURIComponent(
|
|
addressingKey
|
|
)}:${io}:${encodeURIComponent(partId)}`;
|
|
}
|
|
|
|
/**
|
|
* Atomically claim a part id before appending. Returns true if this caller
|
|
* won the claim (first to see this id) and should perform the append, false
|
|
* if the id was already claimed (a concurrent or retried POST) and the append
|
|
* should be skipped. Fails open (returns true) when Redis is unavailable —
|
|
* appends degrade to at-least-once, never to dropped.
|
|
*/
|
|
export async function claimSessionStreamPart(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in",
|
|
partId: string
|
|
): Promise<boolean> {
|
|
if (!redis) return true;
|
|
|
|
try {
|
|
// SET NX is the atomic claim: "OK" when set (we won), null when the key
|
|
// already exists (someone else owns this id).
|
|
const result = await redis.set(
|
|
buildAppendDedupeKey(environmentId, addressingKey, io, partId),
|
|
"1",
|
|
"EX",
|
|
APPEND_DEDUPE_TTL_SECONDS,
|
|
"NX"
|
|
);
|
|
return result === "OK";
|
|
} catch (error) {
|
|
logger.error("Failed to claim session stream append part", {
|
|
environmentId,
|
|
addressingKey,
|
|
io,
|
|
partId,
|
|
error,
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** Release a claim so a retry can proceed — called when the append itself failed. */
|
|
export async function releaseSessionStreamPart(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in",
|
|
partId: string
|
|
): Promise<void> {
|
|
if (!redis) return;
|
|
|
|
try {
|
|
await redis.del(buildAppendDedupeKey(environmentId, addressingKey, io, partId));
|
|
} catch (error) {
|
|
logger.error("Failed to release session stream append part", {
|
|
environmentId,
|
|
addressingKey,
|
|
io,
|
|
partId,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function removeSessionStreamWaitpoint(
|
|
environmentId: string,
|
|
addressingKey: string,
|
|
io: "out" | "in",
|
|
waitpointId: string
|
|
): Promise<void> {
|
|
if (!redis) return;
|
|
|
|
try {
|
|
const key = buildKey(environmentId, addressingKey, io);
|
|
await redis.srem(key, waitpointId);
|
|
} catch (error) {
|
|
logger.error("Failed to remove session stream waitpoint cache entry", {
|
|
environmentId,
|
|
addressingKey,
|
|
io,
|
|
error,
|
|
});
|
|
}
|
|
}
|