fix(gateway): disable Redis consumers on local tier (#689)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #689.
This commit is contained in:
2026-06-25 17:17:24 +00:00
parent 940ae3cc41
commit 56787fabf1
9 changed files with 256 additions and 50 deletions

View File

@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
@@ -11,16 +13,54 @@ interface OverrideFragment {
addedAt: number;
}
@Injectable()
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle;
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
constructor() {
this.handle = createQueue();
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/**
* In-memory fallback used on Local tier (no Redis).
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
* this is acceptable; system overrides are re-applied at the next session.
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
*/
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
if (this.mosaicConfig?.queue?.type === 'local') {
this.handle = null;
} else {
this.handle = createQueue();
}
}
async onApplicationShutdown(): Promise<void> {
// On non-local tiers the constructor opens an ioredis connection; close it
// on graceful shutdown to avoid leaking the handle (local tier is null).
await this.handle?.close().catch(() => {});
}
async set(sessionId: string, override: string): Promise<void> {
if (!this.handle) {
// Local tier: in-memory path
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
this.localStore.set(sessionId, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const fragments: OverrideFragment[] = existing
@@ -50,10 +90,17 @@ export class SystemOverrideService {
}
async get(sessionId: string): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(sessionId)?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
}
async renew(sessionId: string): Promise<void> {
if (!this.handle) {
// Local tier: no TTL to renew; entry persists until restart
return;
}
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -61,6 +108,11 @@ export class SystemOverrideService {
}
async clear(sessionId: string): Promise<void> {
if (!this.handle) {
this.localStore.delete(sessionId);
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),