125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
import { createTwoTierCache, type SharedStore, type TwoTierCache } from "@hivegrid/shared-store";
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
import { clearCacheKey, clearCacheKeyPrefix } from "~/utils/cache.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
export type TenantSecuritySnapshot = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
deactivatedReason: string | null;
|
|
};
|
|
|
|
/**
|
|
* Tenant cache (two-tier). The existing in-process `cachified` LRU in
|
|
* `tenants.db.server.ts` stays the L1 read path; this module owns the
|
|
* pub/sub channel so peer replicas drop their L1 entries when a
|
|
* mutation happens on a different replica.
|
|
*
|
|
* Keys broadcast over the channel are tenant ids. The subscriber clears
|
|
* every cachified key derived from a tenant (`tenant:`, `tenantSimple:`,
|
|
* `tenantIdOrSlug:`).
|
|
*/
|
|
const log = createScopedLogger("tenant-cache");
|
|
|
|
export const TENANT_SNAPSHOT_SAFETY_TTL_SECONDS = 30;
|
|
|
|
let cacheSingleton: TwoTierCache<TenantSecuritySnapshot> | null = null;
|
|
let subscribeDisposer: (() => Promise<void>) | null = null;
|
|
|
|
function pubsubEnabledFromEnv(): boolean {
|
|
const raw = process.env.CACHE_INVALIDATION_ENABLED;
|
|
if (raw === undefined || raw === null || raw === "") return true;
|
|
return raw.toLowerCase() !== "false" && raw !== "0";
|
|
}
|
|
|
|
export function createTenantSecurityCache(store: SharedStore, options: { now?: () => number } = {}): TwoTierCache<TenantSecuritySnapshot> {
|
|
return createTwoTierCache<TenantSecuritySnapshot>({
|
|
store,
|
|
namespace: "tenant",
|
|
ttlSeconds: TENANT_SNAPSHOT_SAFETY_TTL_SECONDS,
|
|
l1Now: options.now,
|
|
pubsubEnabled: pubsubEnabledFromEnv(),
|
|
logger: { warn: (obj, msg) => log.warn(obj, msg), error: (obj, msg) => log.error(obj, msg) },
|
|
});
|
|
}
|
|
|
|
export function getTenantCache(): TwoTierCache<TenantSecuritySnapshot> {
|
|
if (cacheSingleton) return cacheSingleton;
|
|
cacheSingleton = createTenantSecurityCache(getSharedStore());
|
|
return cacheSingleton;
|
|
}
|
|
|
|
export async function loadTenantSecuritySnapshot(
|
|
identifier: string,
|
|
getFreshValue: () => Promise<TenantSecuritySnapshot | null>
|
|
): Promise<TenantSecuritySnapshot | null> {
|
|
const cache = getTenantCache();
|
|
try {
|
|
const cached = await cache.get(identifier);
|
|
if (cached !== null) return cached;
|
|
} catch (error) {
|
|
log.warn(
|
|
{ op: "tenant-security-cache.read", err: { name: error instanceof Error ? error.name : "Error" } },
|
|
"Tenant security cache read failed; falling back to the database."
|
|
);
|
|
}
|
|
const fresh = await getFreshValue();
|
|
if (fresh !== null) {
|
|
try {
|
|
await cache.set(identifier, fresh);
|
|
} catch (error) {
|
|
log.warn(
|
|
{ op: "tenant-security-cache.write", err: { name: error instanceof Error ? error.name : "Error" } },
|
|
"Tenant security cache write failed; continuing with the database result."
|
|
);
|
|
}
|
|
}
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Publish a tenant-invalidation. Call after any tenant mutation in the
|
|
* web app (settings update, logo upload, member add/remove). The local
|
|
* cachified entries are also cleared as a defence-in-depth in case the
|
|
* caller forgot to call clearCacheKey().
|
|
*/
|
|
export async function publishTenantInvalidation(...identifiers: string[]): Promise<void> {
|
|
const uniqueIdentifiers = [...new Set(identifiers.filter((identifier) => identifier.length > 0))];
|
|
const cache = getTenantCache();
|
|
for (const identifier of uniqueIdentifiers) {
|
|
clearCacheKey(`tenant:${identifier}`);
|
|
clearCacheKey(`tenantSimple:${identifier}`);
|
|
clearCacheKey(`tenantIdOrSlug:${identifier}`);
|
|
cache.deleteLocal(identifier);
|
|
}
|
|
// Tenant deactivation and slug changes are authorization/routing changes.
|
|
// Propagate failures so the mutating request cannot report success while a
|
|
// peer replica may still serve an old snapshot.
|
|
await Promise.all(uniqueIdentifiers.map((identifier) => cache.l2.invalidate(identifier)));
|
|
}
|
|
|
|
export async function subscribeTenantCache(): Promise<() => Promise<void>> {
|
|
if (subscribeDisposer) return subscribeDisposer;
|
|
const cache = getTenantCache();
|
|
subscribeDisposer = await cache.subscribe((key) => {
|
|
if (!key || key === "*") {
|
|
clearCacheKeyPrefix("tenant:");
|
|
clearCacheKeyPrefix("tenantSimple:");
|
|
clearCacheKeyPrefix("tenantIdOrSlug:");
|
|
return;
|
|
}
|
|
clearCacheKey(`tenant:${key}`);
|
|
clearCacheKey(`tenantSimple:${key}`);
|
|
clearCacheKey(`tenantIdOrSlug:${key}`);
|
|
});
|
|
log.info({ namespace: "tenant" }, "tenant cache subscriber started");
|
|
return subscribeDisposer;
|
|
}
|
|
|
|
/** Test seam: drop the singleton + disposer. */
|
|
export function __resetTenantCacheForTests(): void {
|
|
cacheSingleton = null;
|
|
subscribeDisposer = null;
|
|
}
|