From 0383030817c61a973138a761e59b01f36fd53167 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:21:17 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20Avoid=20Parallel=20Full=20a?= =?UTF-8?q?nd=20Paged=20Prompt=20Loading=20on=20Startup=20(#15031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danny Avila --- api/cache/getLogStores.js | 4 +- api/server/routes/admin/roles.js | 1 + api/server/routes/prompts.js | 39 +- api/server/services/PermissionService.js | 18 +- client/src/Providers/PromptGroupsContext.tsx | 34 +- .../components/Chat/Input/PromptsCommand.tsx | 10 +- .../Input/__tests__/PromptsCommand.spec.tsx | 17 +- .../__tests__/SharedLinkButton.test.tsx | 4 +- .../src/utils/__tests__/promptGroups.test.ts | 134 +++- client/src/utils/promptGroups.ts | 41 +- packages/api/src/admin/roles.ts | 7 + packages/data-provider/src/config.ts | 4 + packages/data-schemas/src/index.ts | 1 + packages/data-schemas/src/methods/aclEntry.ts | 25 +- packages/data-schemas/src/methods/index.ts | 32 +- .../src/methods/prompt.accessContext.spec.ts | 653 ++++++++++++++++++ packages/data-schemas/src/methods/prompt.ts | 313 ++++++++- .../src/methods/userGroup.spec.ts | 10 +- .../data-schemas/src/methods/userGroup.ts | 87 ++- packages/data-schemas/src/types/cache.ts | 3 +- 20 files changed, 1350 insertions(+), 87 deletions(-) create mode 100644 packages/data-schemas/src/methods/prompt.accessContext.spec.ts diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index b7f943cc55..487f67b421 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -11,7 +11,7 @@ const { registerShutdownTask, } = require('@librechat/api'); -/** No-op store registered when the user principals cache is disabled (TTL of 0). */ +/** No-op store for cache namespaces that are deliberately disabled. */ const disabledCache = { get: async () => undefined, set: async () => undefined, @@ -46,6 +46,8 @@ const namespaces = { [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES), [CacheKeys.USER_PRINCIPALS]: userPrincipalsCache() ?? disabledCache, + /** Authorization IDs stay uncached because a failed shared invalidation cannot fail closed. */ + [CacheKeys.PROMPT_GROUPS_ACCESS]: disabledCache, [CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG), [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE), [CacheKeys.TOOL_CACHE]: standardCache(CacheKeys.TOOL_CACHE), diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js index 5c6d4e92e8..de15f3d89a 100644 --- a/api/server/routes/admin/roles.js +++ b/api/server/routes/admin/roles.js @@ -30,6 +30,7 @@ const handlers = createAdminRolesHandlers({ deleteAclEntries: db.deleteAclEntries, deleteGrantsForPrincipal: db.deleteGrantsForPrincipal, recordAuditEntry: db.recordAuditEntry, + invalidatePromptGroupAccessContext: db.invalidatePromptGroupAccessContext, }); router.use(requireJwtAuth, requireAdminAccess); diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 70c9fcacb3..f166a907e3 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -26,7 +26,7 @@ const { const { SystemCapabilities } = require('@librechat/data-schemas'); const { getListPromptGroupsByAccess, - getOwnedPromptGroupIds, + getPromptGroupAccessContext, incrementPromptGroupUsage, makePromptProduction, updatePromptGroup, @@ -46,12 +46,7 @@ const { requireJwtAuth, configMiddleware, } = require('~/server/middleware'); -const { - findPubliclyAccessibleResources, - getEffectivePermissions, - findAccessibleResources, - grantPermission, -} = require('~/server/services/PermissionService'); +const { getEffectivePermissions, grantPermission } = require('~/server/services/PermissionService'); const { hasCapability } = require('~/server/middleware/roles/capabilities'); const router = express.Router(); @@ -130,19 +125,8 @@ router.get('/all', configMiddleware, async (req, res) => { category, }); - const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ - findAccessibleResources({ - userId, - role: req.user.role, - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }), - findPubliclyAccessibleResources({ - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }), - getOwnedPromptGroupIds(userId), - ]); + const { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds } = + await getPromptGroupAccessContext({ userId, role: req.user.role }); const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ accessibleIds, @@ -208,19 +192,8 @@ router.get('/groups', configMiddleware, async (req, res) => { actualCursor = null; } - const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ - findAccessibleResources({ - userId, - role: req.user.role, - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }), - findPubliclyAccessibleResources({ - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }), - getOwnedPromptGroupIds(userId), - ]); + const { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds } = + await getPromptGroupAccessContext({ userId, role: req.user.role }); const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ accessibleIds, diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 6900a90702..7d166505ca 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -5,6 +5,7 @@ const { tenantStorage, getTenantId, logger, + runAfterTransaction, } = require('@librechat/data-schemas'); const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); const { @@ -114,7 +115,7 @@ const grantPermission = async ({ `Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`, ); } - return await db.grantPermission( + const result = await db.grantPermission( principalType, principalId, resourceType, @@ -124,6 +125,12 @@ const grantPermission = async ({ session, role._id, ); + if (resourceType === ResourceType.PROMPTGROUP) { + /** A caller-owned session may not have committed yet; invalidating early + * would let a concurrent read re-cache pre-commit IDs under the new generation. */ + await runAfterTransaction(session, () => db.invalidatePromptGroupAccessContext()); + } + return result; } catch (error) { logger.error(`[PermissionService.grantPermission] Error: ${error.message}`); throw error; @@ -928,6 +935,11 @@ const bulkUpdateResourcePermissions = async ({ await localSession.commitTransaction(); } + if (resourceType === ResourceType.PROMPTGROUP) { + /** The caller's session may still be uncommitted; defer until it commits */ + await runAfterTransaction(localSession, () => db.invalidatePromptGroupAccessContext()); + } + return results; } catch (error) { if (shouldEndSession && supportsTransactions) { @@ -970,6 +982,10 @@ const removeAllPermissions = async ({ resourceType, resourceId }) => { resourceId, }); + if (resourceType === ResourceType.PROMPTGROUP) { + await db.invalidatePromptGroupAccessContext(); + } + return result; } catch (error) { logger.error(`[PermissionService.removeAllPermissions] Error: ${error.message}`); diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx index 70751df846..d86d6d3e6d 100644 --- a/client/src/Providers/PromptGroupsContext.tsx +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -1,8 +1,8 @@ -import React, { createContext, useContext, ReactNode, useMemo } from 'react'; +import React, { createContext, useContext, ReactNode, useCallback, useMemo, useState } from 'react'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; import type { PromptOption } from '~/common'; -import { usePromptGroupsNav, useHasAccess, useCatalogReady } from '~/hooks'; +import { usePromptGroupsNav, useHasAccess, useCatalogReady, activateCatalog } from '~/hooks'; import { useGetAllPromptGroups } from '~/data-provider'; import { CategoryIcon } from '~/components/Prompts'; import { mapPromptGroups } from '~/utils'; @@ -21,6 +21,8 @@ type PromptGroupsContextType = isLoading: boolean; }; hasAccess: boolean; + /** Opts the full prompt list query in; it stays idle until first requested. */ + requestAllPromptGroups: () => void; }) | null; @@ -37,8 +39,20 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => { const promptsEnabled = hasAccess && promptsReady; const promptGroupsNav = usePromptGroupsNav(promptsEnabled); + + /** + * The full prompt list only serves the `/` command popover, so its query stays + * idle until requested instead of racing the paginated sidebar query on startup. + * Requesting it also releases the catalog, for a popover opened before warmup. + */ + const [allPromptsActive, setAllPromptsActive] = useState(false); + const requestAllPromptGroups = useCallback(() => { + activateCatalog('prompts'); + setAllPromptsActive(true); + }, []); + const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, { - enabled: promptsEnabled, + enabled: promptsEnabled && allPromptsActive, select: (data) => { const mappedArray: PromptOption[] = data.map((group) => ({ id: group._id ?? '', @@ -68,11 +82,21 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => { ...promptGroupsNav, allPromptGroups: { data: hasAccess ? allGroupsData : undefined, - isLoading: hasAccess ? isLoadingAll : false, + /** A never-fetched disabled query reports `isLoading` in React Query v4 */ + isLoading: promptsEnabled && allPromptsActive ? isLoadingAll : false, }, hasAccess, + requestAllPromptGroups, }), - [promptGroupsNav, allGroupsData, isLoadingAll, hasAccess], + [ + promptGroupsNav, + allGroupsData, + isLoadingAll, + hasAccess, + promptsEnabled, + allPromptsActive, + requestAllPromptGroups, + ], ); return ( diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 7f08c343ab..92696bdfcd 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -65,8 +65,9 @@ function PromptsCommand({ const localize = useLocalize(); const { mutate: recordUsage } = useRecordPromptUsage(); const promptGroupsContext = usePromptGroupsContext(); - const { allPromptGroups, hasAccess } = promptGroupsContext ?? {}; + const { allPromptGroups, hasAccess, requestAllPromptGroups } = promptGroupsContext ?? {}; const { data, isLoading } = allPromptGroups ?? {}; + const showPromptsPopover = useRecoilValue(store.showPromptsPopoverFamily(index)); const [activeIndex, setActiveIndex] = useState(0); const timeoutRef = useRef(null); @@ -147,6 +148,13 @@ function PromptsCommand({ } }, [open, setSearchValue]); + /** The full prompt list is fetched on first popover open, not at app startup */ + useEffect(() => { + if (showPromptsPopover) { + requestAllPromptGroups?.(); + } + }, [showPromptsPopover, requestAllPromptGroups]); + useEffect(() => { setActiveIndex((prev) => Math.min(prev, Math.max(matches.length - 1, 0))); }, [matches.length]); diff --git a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx index a15eba3760..f99647293d 100644 --- a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx @@ -14,6 +14,7 @@ const PLACEHOLDER = 'com_ui_command_usage_placeholder'; const mockSetShowPromptsPopover = jest.fn(); const mockShowPromptsPopover = { current: true }; +const mockRequestAllPromptGroups = jest.fn(); jest.mock('recoil', () => { const actual = jest.requireActual('recoil'); @@ -126,6 +127,7 @@ beforeEach(() => { data: { promptGroups, promptsMap }, isLoading: false, }, + requestAllPromptGroups: mockRequestAllPromptGroups, }); }); @@ -143,7 +145,6 @@ describe('PromptsCommand keyboard navigation', () => { const { container } = renderCommand(); expect(container).toBeEmptyDOMElement(); }); - it('moves the active highlight with ArrowDown/ArrowUp and wraps around', () => { renderCommand(); const input = getInput(); @@ -219,3 +220,17 @@ describe('PromptsCommand keyboard navigation', () => { expect((getInput() as HTMLInputElement).value).toBe(''); }); }); + +describe('PromptsCommand lazy prompt loading', () => { + it('requests the full prompt list when the popover is shown', () => { + mockShowPromptsPopover.current = true; + renderCommand(); + expect(mockRequestAllPromptGroups).toHaveBeenCalledTimes(1); + }); + + it('does not request the full prompt list while the popover is hidden', () => { + mockShowPromptsPopover.current = false; + renderCommand(); + expect(mockRequestAllPromptGroups).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Conversations/ConvoOptions/__tests__/SharedLinkButton.test.tsx b/client/src/components/Conversations/ConvoOptions/__tests__/SharedLinkButton.test.tsx index 5db81d14bd..90b83e5833 100644 --- a/client/src/components/Conversations/ConvoOptions/__tests__/SharedLinkButton.test.tsx +++ b/client/src/components/Conversations/ConvoOptions/__tests__/SharedLinkButton.test.tsx @@ -132,7 +132,9 @@ describe('SharedLinkButton', () => { snapshotFiles: true, }); }); - expect(updateButton.querySelector('svg')).toHaveClass('animate-refresh-link-spin'); + await waitFor(() => + expect(updateButton.querySelector('svg')).toHaveClass('animate-refresh-link-spin'), + ); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-old')); }); diff --git a/client/src/utils/__tests__/promptGroups.test.ts b/client/src/utils/__tests__/promptGroups.test.ts index 4fa2ccdebf..9346888aa4 100644 --- a/client/src/utils/__tests__/promptGroups.test.ts +++ b/client/src/utils/__tests__/promptGroups.test.ts @@ -1,11 +1,15 @@ -import { InfiniteCollections } from 'librechat-data-provider'; -import type { InfiniteData } from '@tanstack/react-query'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; +import { InfiniteCollections, QueryKeys } from 'librechat-data-provider'; import type { PromptGroupListResponse, TPromptGroup } from 'librechat-data-provider'; +import type { InfiniteData } from '@tanstack/react-query'; import { + addGroupToAll, addPromptGroup, deletePromptGroup, + removeGroupFromAll, updateGroupFields, updateGroupFieldsInPlace, + updateGroupInAll, updatePromptGroup, getSnippet, findPromptGroup, @@ -34,6 +38,34 @@ function makeInfiniteData( }; } +async function expectFirstAllGroupsFetchRestart( + mutate: (queryClient: QueryClient) => void, + freshList: TPromptGroup[], +) { + const queryClient = new QueryClient(); + const queryKey = [QueryKeys.allPromptGroups]; + const staleList = [makeGroup({ _id: 'stale', name: 'Stale Group' })]; + const deferreds: Array<{ resolve: (value: TPromptGroup[]) => void }> = []; + const queryFn = () => new Promise((resolve) => deferreds.push({ resolve })); + const observer = new QueryObserver(queryClient, { queryKey, queryFn }); + const unsubscribe = observer.subscribe(() => undefined); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(deferreds).toHaveLength(1); + + mutate(queryClient); + deferreds[0].resolve(staleList); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(deferreds).toHaveLength(2); + + deferreds[1].resolve(freshList); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(queryClient.getQueryData(queryKey)).toEqual(freshList); + + unsubscribe(); + queryClient.clear(); +} + // --------------------------------------------------------------------------- // updateGroupFieldsInPlace // --------------------------------------------------------------------------- @@ -326,3 +358,101 @@ describe('getSnippet', () => { expect(getSnippet('')).toBe(''); }); }); + +// --------------------------------------------------------------------------- +// addGroupToAll +// --------------------------------------------------------------------------- + +describe('addGroupToAll', () => { + it('does not seed the all-prompt-groups list before its first fetch', () => { + const queryClient = new QueryClient(); + addGroupToAll(queryClient, makeGroup({ _id: 'group-new' })); + expect(queryClient.getQueryData([QueryKeys.allPromptGroups])).toBeUndefined(); + }); + + it('appends to an already fetched list', () => { + const queryClient = new QueryClient(); + const existing = [makeGroup({ _id: 'group-a' })]; + queryClient.setQueryData([QueryKeys.allPromptGroups], existing); + addGroupToAll(queryClient, makeGroup({ _id: 'group-b' })); + expect(queryClient.getQueryData([QueryKeys.allPromptGroups])).toHaveLength(2); + }); + + it('restarts a first fetch already in flight so it settles with the created group', async () => { + const queryClient = new QueryClient(); + const queryKey = [QueryKeys.allPromptGroups]; + const newList = [makeGroup({ _id: 'group-new' })]; + const deferreds: Array<{ resolve: (value: TPromptGroup[]) => void }> = []; + const queryFn = () => new Promise((resolve) => deferreds.push({ resolve })); + const observer = new QueryObserver(queryClient, { queryKey, queryFn }); + const unsubscribe = observer.subscribe(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(deferreds).toHaveLength(1); + + addGroupToAll(queryClient, makeGroup({ _id: 'group-new' })); + deferreds[0].resolve([]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + /* The pre-creation read must not be allowed to stand: a restart has to + have started a second fetch that can settle with the created group. */ + expect(deferreds).toHaveLength(2); + deferreds[1].resolve(newList); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(queryClient.getQueryData(queryKey)).toEqual(newList); + unsubscribe(); + queryClient.clear(); + }); + + it('refetches an errored list after creation so the command appears', async () => { + const queryClient = new QueryClient(); + const queryKey = [QueryKeys.allPromptGroups]; + const newList = [makeGroup({ _id: 'group-new' })]; + const deferreds: Array<{ + resolve: (value: TPromptGroup[]) => void; + reject: (reason: Error) => void; + }> = []; + const queryFn = () => + new Promise((resolve, reject) => deferreds.push({ resolve, reject })); + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn, + retry: false, + }); + const unsubscribe = observer.subscribe(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); + deferreds[0].reject(new Error('boom')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(queryClient.getQueryState(queryKey)?.status).toBe('error'); + + addGroupToAll(queryClient, makeGroup({ _id: 'group-new' })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(deferreds).toHaveLength(2); + deferreds[1].resolve(newList); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(queryClient.getQueryData(queryKey)).toEqual(newList); + unsubscribe(); + queryClient.clear(); + }); +}); + +describe('all-prompt-groups mutation reconciliation', () => { + it('restarts a first fetch that would overwrite a group update', async () => { + expect.assertions(3); + const updatedGroup = makeGroup({ _id: 'stale', name: 'Updated Group' }); + await expectFirstAllGroupsFetchRestart( + (queryClient) => updateGroupInAll(queryClient, { _id: 'stale', name: 'Updated Group' }), + [updatedGroup], + ); + }); + + it('restarts a first fetch that would restore a deleted group', async () => { + expect.assertions(3); + await expectFirstAllGroupsFetchRestart( + (queryClient) => removeGroupFromAll(queryClient, 'stale'), + [], + ); + }); +}); diff --git a/client/src/utils/promptGroups.ts b/client/src/utils/promptGroups.ts index d5cadfb8d4..8076803e93 100644 --- a/client/src/utils/promptGroups.ts +++ b/client/src/utils/promptGroups.ts @@ -1,10 +1,10 @@ import { InfiniteCollections, QueryKeys } from 'librechat-data-provider'; -import type { InfiniteData, QueryClient } from '@tanstack/react-query'; import type { PromptGroupListResponse, PromptGroupListData, TPromptGroup, } from 'librechat-data-provider'; +import type { InfiniteData, QueryClient } from '@tanstack/react-query'; import { addData, deleteData, @@ -93,17 +93,46 @@ export const findPromptGroup = ( ); }; +const restartUnresolvedAllPromptGroups = ( + queryClient: QueryClient, + queryKey: string[], +): boolean => { + const state = queryClient.getQueryState(queryKey); + /** + * An idle, never-fetched list must not be seeded partial; its first fetch + * includes the mutation. A first fetch already in flight may have read the + * database before the mutation and settle stale (invalidating alone does not + * cancel a data-less fetch in React Query v4), and an errored fetch never + * retries on its own because retry and refetch triggers are off. + */ + if (!state || state.data === undefined) { + if (state && (state.fetchStatus === 'fetching' || state.status === 'error')) { + void queryClient.cancelQueries(queryKey).then(() => queryClient.invalidateQueries(queryKey)); + } + return true; + } + return false; +}; + export const addGroupToAll = (queryClient: QueryClient, newGroup: TPromptGroup) => { - addToCacheList(queryClient, [QueryKeys.allPromptGroups], newGroup); + const queryKey = [QueryKeys.allPromptGroups]; + if (restartUnresolvedAllPromptGroups(queryClient, queryKey)) { + return; + } + addToCacheList(queryClient, queryKey, newGroup); }; export const updateGroupInAll = ( queryClient: QueryClient, updatedGroup: Partial & { _id: string }, ) => { + const queryKey = [QueryKeys.allPromptGroups]; + if (restartUnresolvedAllPromptGroups(queryClient, queryKey)) { + return; + } updateCacheList({ queryClient, - queryKey: [QueryKeys.allPromptGroups], + queryKey, searchProperty: '_id', updateData: updatedGroup, searchValue: updatedGroup._id, @@ -111,5 +140,9 @@ export const updateGroupInAll = ( }; export const removeGroupFromAll = (queryClient: QueryClient, groupId: string) => { - removeFromCacheList(queryClient, [QueryKeys.allPromptGroups], '_id', groupId); + const queryKey = [QueryKeys.allPromptGroups]; + if (restartUnresolvedAllPromptGroups(queryClient, queryKey)) { + return; + } + removeFromCacheList(queryClient, queryKey, '_id', groupId); }; diff --git a/packages/api/src/admin/roles.ts b/packages/api/src/admin/roles.ts index 484e1efe2f..3f452977c5 100644 --- a/packages/api/src/admin/roles.ts +++ b/packages/api/src/admin/roles.ts @@ -124,6 +124,8 @@ export interface AdminRolesDeps { principalType: PrincipalType; principalId: string | Types.ObjectId; }) => Promise; + /** Drops cached prompt group access IDs; role deletion can remove PROMPTGROUP grants. */ + invalidatePromptGroupAccessContext?: () => Promise; /** Removes all system capability grants held by this principal and returns * the removed grants so each can be audited. */ deleteGrantsForPrincipal: ( @@ -169,6 +171,7 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps): { deleteAclEntries, deleteGrantsForPrincipal, recordAuditEntry, + invalidatePromptGroupAccessContext, } = deps; /** Emits a `grant.removed` audit entry for each grant the role-deletion cascade @@ -461,6 +464,10 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps): { logger.error('[adminRoles] cascade cleanup failed for role:', name, result.reason); } } + if (aclResult.status === 'fulfilled') { + /** The removed entries can include PROMPTGROUP grants feeding the access cache */ + await invalidatePromptGroupAccessContext?.(); + } if (grantsResult.status === 'fulfilled') { await emitGrantRemovals(req, name, grantsResult.value); } diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 1ecce84dc0..007db9d438 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2697,6 +2697,10 @@ export enum CacheKeys { * Key for cached group memberships used to resolve ACL user principals. */ USER_PRINCIPALS = 'USER_PRINCIPALS', + /** + * Key for cached prompt group access ID sets (accessible, public, owned). + */ + PROMPT_GROUPS_ACCESS = 'PROMPT_GROUPS_ACCESS', /** * Key for per-conversation stateful code sandbox prewarm/warm state. */ diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index b1a56dc206..b63bc6a646 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -28,6 +28,7 @@ export { normalizeSkillFrontmatterKeys, validateSkillDescription, deriveStructuredFrontmatterFields, + runAfterTransaction, AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index d8995a0026..e0a6d09666 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -139,6 +139,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { resourceType: string, requiredPermBit: number, resourceIds?: Types.ObjectId[], + readPrimary?: boolean, ) => Promise; deleteAclEntries: ( filter: Record, @@ -152,6 +153,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { resourceType: string, requiredPermissions: number, resourceIds?: Types.ObjectId[], + readPrimary?: boolean, ) => Promise; aggregateAclEntries: (pipeline: PipelineStage[]) => Promise; getSoleOwnedResourceIds: ( @@ -492,6 +494,8 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { * resources are considered, so the query cost scales with the candidate set * instead of every accessible resource of the type. An empty array matches * nothing rather than lifting the bound. + * @param readPrimary - Read from the primary so a lagging secondary cannot + * pin pre-mutation IDs into a caller's cache. * @returns Array of resource IDs */ async function findAccessibleResources( @@ -499,6 +503,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { resourceType: string, requiredPermBit: number, resourceIds?: Types.ObjectId[], + readPrimary = false, ): Promise { const AclEntry = mongoose.models.AclEntry as Model; const principalsQuery = principalsList.map((p) => ({ @@ -506,12 +511,17 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { ...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }), })); - return await AclEntry.find({ + const query = AclEntry.find({ $or: principalsQuery, ...(resourceIds !== undefined && { resourceId: { $in: resourceIds } }), resourceType, permBits: { $in: permissionBitSupersets(requiredPermBit) }, - }).distinct('resourceId'); + }); + if (readPrimary) { + /** Cache builds must not capture a lagging secondary's pre-mutation state */ + query.read('primary'); + } + return await query.distinct('resourceId'); } /** @@ -546,19 +556,26 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')): { * @param resourceType - The type of resource * @param requiredPermissions - Required permission bits * @param resourceIds - Optional candidate bound; see {@link findAccessibleResources} + * @param readPrimary - Read from the primary; see {@link findAccessibleResources} */ async function findPublicResourceIds( resourceType: string, requiredPermissions: number, resourceIds?: Types.ObjectId[], + readPrimary = false, ): Promise { const AclEntry = mongoose.models.AclEntry as Model; - return await AclEntry.find({ + const query = AclEntry.find({ principalType: PrincipalType.PUBLIC, ...(resourceIds !== undefined && { resourceId: { $in: resourceIds } }), resourceType, permBits: { $in: permissionBitSupersets(requiredPermissions) }, - }).distinct('resourceId'); + }); + if (readPrimary) { + /** Cache builds must not capture a lagging secondary's pre-mutation state */ + query.read('primary'); + } + return await query.distinct('resourceId'); } /** diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index d1bbfa3db1..cae8430995 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -23,7 +23,12 @@ import { createMCPServerMethods, type MCPServerMethods } from './mcpServer'; import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth'; /* Permissions */ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; -import { createUserGroupMethods, type UserGroupMethods, type UserGroupDeps } from './userGroup'; +import { + createUserGroupMethods, + runAfterTransaction, + type UserGroupMethods, + type UserGroupDeps, +} from './userGroup'; import { createAclEntryMethods, permissionBitSupersets, type AclEntryMethods } from './aclEntry'; import { createSystemGrantMethods, type SystemGrantMethods } from './systemGrant'; import { @@ -139,6 +144,7 @@ import { import { createInsightsMethods, type InsightsMethods } from './insights'; export { + runAfterTransaction, RoleConflictError, MCPAuthorityProofError, MAX_MCP_AUTHORITY_TARGETS, @@ -275,11 +281,28 @@ export function createMethods( await aclEntryMethods.deleteAclEntries({ resourceType, resourceId }); }); + // Role and user-group methods with optional cache injection; user-group methods + // are created before prompt methods so prompt methods can resolve ACL principals. + // The membership hook is late-bound: prompt methods do not exist yet here. + const promptAccessInvalidator: { current?: () => Promise } = {}; + const roleDeps: RoleDeps = { getCache: deps.getCache }; + const userGroupDeps: UserGroupDeps = { + getCache: deps.getCache, + onMemberGroupsInvalidated: () => promptAccessInvalidator.current?.(), + }; + const roleMethods = createRoleMethods(mongoose, roleDeps); + const userGroupMethods = createUserGroupMethods(mongoose, userGroupDeps); + const promptDeps: PromptDeps = { removeAllPermissions, getSoleOwnedResourceIds: aclEntryMethods.getSoleOwnedResourceIds, + getCache: deps.getCache, + getUserPrincipals: userGroupMethods.getUserPrincipals, + findAccessibleResources: aclEntryMethods.findAccessibleResources, + findPublicResourceIds: aclEntryMethods.findPublicResourceIds, }; const promptMethods = createPromptMethods(mongoose, promptDeps); + promptAccessInvalidator.current = promptMethods.invalidatePromptGroupAccessContext; const skillDeps: SkillDeps = { removeAllPermissions, @@ -287,11 +310,6 @@ export function createMethods( }; const skillMethods = createSkillMethods(mongoose, skillDeps); - // Role methods with optional cache injection - const roleDeps: RoleDeps = { getCache: deps.getCache }; - const userGroupDeps: UserGroupDeps = { getCache: deps.getCache }; - const roleMethods = createRoleMethods(mongoose, roleDeps); - // Tier 1: action methods (created as variable for agent dependency) const actionMethods = createActionMethods(mongoose); @@ -317,7 +335,7 @@ export function createMethods( ...createAgentApiKeyMethods(mongoose), ...createMCPServerMethods(mongoose), ...createAccessRoleMethods(mongoose), - ...createUserGroupMethods(mongoose, userGroupDeps), + ...userGroupMethods, ...aclEntryMethods, ...systemGrantMethods, ...createAuditLogMethods(mongoose), diff --git a/packages/data-schemas/src/methods/prompt.accessContext.spec.ts b/packages/data-schemas/src/methods/prompt.accessContext.spec.ts new file mode 100644 index 0000000000..87c16cd330 --- /dev/null +++ b/packages/data-schemas/src/methods/prompt.accessContext.spec.ts @@ -0,0 +1,653 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createModels, logger, runAfterTransaction, tenantStorage } from '..'; +import { + PermissionBits, + PrincipalModel, + PrincipalType, + ResourceType, + Time, +} from 'librechat-data-provider'; +import type { CacheStore } from '~/types'; +import { createMethods } from './index'; + +logger.silent = true; + +type Methods = ReturnType; +type AclEntryDoc = { principalType: string; resourceId: unknown; permBits: number }; + +let mongoServer: MongoMemoryServer; +let methods: Methods; +let AclEntry: mongoose.Model; +let Prompt: mongoose.Model; +let PromptGroup: mongoose.Model; +let cacheMap: Map; +let delaySets = false; +let resolvePendingSets: Array<() => void> = []; +let delayDeletes = false; +let resolvePendingDeletes: Array<() => void> = []; +let generationTtls: Array = []; +let failGenerationReads = false; +let failGenerationWrites = false; +let failGenerationDeletes = false; +let notifyPendingSet: (() => void) | undefined; + +const cacheStore: CacheStore = { + get: async (key) => { + if (failGenerationReads && key.includes(':generation')) { + throw new Error('marker read failed'); + } + return cacheMap.get(key); + }, + set: async (key, value, ttl) => { + if (failGenerationWrites && key.includes(':generation')) { + return false; + } + /** The generation entry itself must never be held back by the test gate */ + if (delaySets && !key.includes(':generation')) { + notifyPendingSet?.(); + await new Promise((resolve) => resolvePendingSets.push(resolve)); + } + if (key.includes(':generation')) { + generationTtls.push(ttl); + } + cacheMap.set(key, value); + }, + delete: async (key) => { + if (failGenerationDeletes && key.includes(':generation')) { + return false; + } + if (delayDeletes) { + await new Promise((resolve) => resolvePendingDeletes.push(resolve)); + } + cacheMap.delete(key); + }, + clear: async () => { + cacheMap.clear(); + }, +}; + +const idStrings = (ids: Array): string[] => ids.map((id) => id.toString()); + +async function seedGroupAndPrompt(author: mongoose.Types.ObjectId) { + const prompt = await Prompt.create({ + groupId: new mongoose.Types.ObjectId(), + author, + prompt: 'Hello {{name}}', + type: 'text', + }); + const group = await PromptGroup.create({ + name: `Group ${new mongoose.Types.ObjectId().toString().slice(-6)}`, + author, + authorName: 'Test Author', + productionId: prompt._id, + }); + return group; +} + +async function grantView( + principalType: string, + principalId: mongoose.Types.ObjectId | null, + resourceId: unknown, +) { + const principalModels: Record = { + [PrincipalType.USER]: PrincipalModel.USER, + [PrincipalType.GROUP]: PrincipalModel.GROUP, + [PrincipalType.ROLE]: PrincipalModel.ROLE, + }; + await AclEntry.create({ + principalType, + ...(principalId ? { principalId } : {}), + ...(principalModels[principalType] ? { principalModel: principalModels[principalType] } : {}), + resourceType: ResourceType.PROMPTGROUP, + resourceId, + permBits: PermissionBits.VIEW, + } as AclEntryDoc); +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + AclEntry = mongoose.models.AclEntry; + Prompt = mongoose.models.Prompt; + PromptGroup = mongoose.models.PromptGroup; + cacheMap = new Map(); + methods = createMethods(mongoose, { getCache: () => cacheStore }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +afterEach(async () => { + failGenerationWrites = false; + failGenerationDeletes = false; + await Promise.all([AclEntry.deleteMany({}), Prompt.deleteMany({}), PromptGroup.deleteMany({})]); + await methods.invalidatePromptGroupAccessContext(); + delaySets = false; + resolvePendingSets.forEach((resolve) => resolve()); + resolvePendingSets = []; + delayDeletes = false; + resolvePendingDeletes.forEach((resolve) => resolve()); + resolvePendingDeletes = []; + failGenerationReads = false; + notifyPendingSet = undefined; +}); + +describe('getPromptGroupAccessContext', () => { + it('resolves user-accessible, public, and owned prompt group ID sets', async () => { + const userId = new mongoose.Types.ObjectId(); + const otherId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + const publicGroup = await seedGroupAndPrompt(otherId); + const hiddenGroup = await seedGroupAndPrompt(otherId); + + await grantView(PrincipalType.USER, userId, ownGroup._id); + await grantView(PrincipalType.PUBLIC, null, publicGroup._id); + + const { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds } = + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + + expect(idStrings(publiclyAccessibleIds)).toEqual([publicGroup._id.toString()]); + expect(idStrings(ownedPromptGroupIds)).toEqual([ownGroup._id.toString()]); + + const accessible = idStrings(accessibleIds); + expect(accessible).toContain(ownGroup._id.toString()); + expect(accessible).toContain(publicGroup._id.toString()); + expect(accessible).not.toContain(hiddenGroup._id.toString()); + }); + + it('serves repeat reads from the cache and refreshes after invalidation', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + const sharedGroup = await seedGroupAndPrompt(new mongoose.Types.ObjectId()); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + + await grantView(PrincipalType.USER, userId, sharedGroup._id); + + const stale = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(stale.accessibleIds)).not.toContain(sharedGroup._id.toString()); + + await methods.invalidatePromptGroupAccessContext(); + + const fresh = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(fresh.accessibleIds)).toContain(sharedGroup._id.toString()); + }); + + it('shares the public set across users and invalidates it on group deletion', async () => { + const authorId = new mongoose.Types.ObjectId(); + const publicGroup = await seedGroupAndPrompt(authorId); + await grantView(PrincipalType.PUBLIC, null, publicGroup._id); + + const authorCtx = await methods.getPromptGroupAccessContext({ + userId: authorId.toString(), + role: 'USER', + }); + const otherCtx = await methods.getPromptGroupAccessContext({ + userId: new mongoose.Types.ObjectId().toString(), + role: 'USER', + }); + + expect(idStrings(authorCtx.publiclyAccessibleIds)).toEqual([publicGroup._id.toString()]); + expect(idStrings(otherCtx.publiclyAccessibleIds)).toEqual([publicGroup._id.toString()]); + + await methods.deletePromptGroup({ _id: publicGroup._id.toString() }); + + const afterDelete = await methods.getPromptGroupAccessContext({ + userId: authorId.toString(), + role: 'USER', + }); + expect(idStrings(afterDelete.publiclyAccessibleIds)).toEqual([]); + }); + + it('invalidates cached owned IDs when a prompt group is created', async () => { + const userId = new mongoose.Types.ObjectId(); + const before = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(before.ownedPromptGroupIds).toHaveLength(0); + + await methods.createPromptGroup({ + prompt: { prompt: 'Created', type: 'text' }, + group: { name: 'Created Group' }, + author: userId.toString(), + authorName: 'Test Author', + }); + + const after = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(after.ownedPromptGroupIds).toHaveLength(1); + }); + + it('drops access granted through a user group when the member is removed', async () => { + const userId = new mongoose.Types.ObjectId(); + const userGroup = await methods.createGroup({ + name: 'Editors', + memberIds: [userId.toString()], + }); + const groupPrompt = await seedGroupAndPrompt(new mongoose.Types.ObjectId()); + await grantView(PrincipalType.GROUP, userGroup._id, groupPrompt._id); + + const before = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(before.accessibleIds)).toContain(groupPrompt._id.toString()); + + await methods.removeMemberById(userGroup._id, userId.toString()); + + const after = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(after.accessibleIds)).not.toContain(groupPrompt._id.toString()); + }); + + it('cannot read IDs written back by a build that was in flight across an invalidation', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + const pendingSet = new Promise((resolve) => { + notifyPendingSet = resolve; + }); + delaySets = true; + const firstResolve = methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + await pendingSet; + + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + await methods.invalidatePromptGroupAccessContext(); + + delaySets = false; + resolvePendingSets.forEach((resolve) => resolve()); + resolvePendingSets = []; + await firstResolve; + + const fresh = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(fresh.accessibleIds)).not.toContain(ownGroup._id.toString()); + }); + + it('does not resurrect an old cache era when generation initializers overlap', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + const raceMap = new Map(); + let generationReadCount = 0; + let generationSetCount = 0; + let releaseFirstGenerationRead!: () => void; + let releaseSecondInitialization!: () => void; + let signalSecondInitialization!: () => void; + const firstGenerationRead = new Promise((resolve) => { + releaseFirstGenerationRead = resolve; + }); + const secondInitialization = new Promise((resolve) => { + signalSecondInitialization = resolve; + }); + const secondInitializationGate = new Promise((resolve) => { + releaseSecondInitialization = resolve; + }); + const raceStore: CacheStore = { + get: async (key) => { + if (key === 'access:generation' && generationReadCount < 2) { + generationReadCount += 1; + if (generationReadCount === 1) { + await firstGenerationRead; + } else { + releaseFirstGenerationRead(); + } + return undefined; + } + return raceMap.get(key); + }, + set: async (key, value) => { + if (key === 'access:generation') { + generationSetCount += 1; + if (generationSetCount === 2) { + signalSecondInitialization(); + await secondInitializationGate; + } + } + raceMap.set(key, value); + }, + }; + const raceMethods = createMethods(mongoose, { getCache: () => raceStore }); + const dateNow = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + + try { + const requestA = raceMethods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + const requestB = raceMethods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + + await secondInitialization; + const beforeMutation = await Promise.race([requestA, requestB]); + expect(idStrings(beforeMutation.accessibleIds)).toContain(ownGroup._id.toString()); + + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + await raceMethods.invalidatePromptGroupAccessContext(); + releaseSecondInitialization(); + await Promise.all([requestA, requestB]); + + const afterMutation = await raceMethods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(afterMutation.accessibleIds)).not.toContain(ownGroup._id.toString()); + } finally { + dateNow.mockRestore(); + releaseSecondInitialization(); + } + }); + + it('only marks in-flight lookups stale for the active tenant', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + const pendingSet = new Promise((resolve) => { + notifyPendingSet = resolve; + }); + delaySets = true; + const tenantBResolve = tenantStorage.run({ tenantId: 'tenant-b' }, () => + methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }), + ); + await pendingSet; + + await tenantStorage.run({ tenantId: 'tenant-a' }, () => + methods.invalidatePromptGroupAccessContext(), + ); + + delaySets = false; + resolvePendingSets.forEach((resolve) => resolve()); + resolvePendingSets = []; + await tenantBResolve; + + const userKey = `:user:${userId.toString()}:USER:tenant-b`; + expect([...cacheMap.keys()].some((key) => key.includes(userKey))).toBe(true); + }); + + it('does not reuse an expired generation value after the marker itself expires', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + await methods.invalidatePromptGroupAccessContext(); + const primed = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(primed.accessibleIds)).toContain(ownGroup._id.toString()); + + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + cacheMap.delete('access:generation'); + await methods.invalidatePromptGroupAccessContext(); + + const fresh = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(fresh.accessibleIds)).not.toContain(ownGroup._id.toString()); + }); + + it('does not cache a failed ownership lookup as empty', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + const findSpy = jest.spyOn(PromptGroup, 'find'); + findSpy.mockImplementationOnce(() => { + throw new Error('transient failure'); + }); + + await expect( + methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }), + ).rejects.toThrow('transient failure'); + findSpy.mockRestore(); + + const recovered = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(recovered.ownedPromptGroupIds)).toEqual([ownGroup._id.toString()]); + }); + + it('reinitializes an evicted marker instead of reusing generation zero', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + await methods.invalidatePromptGroupAccessContext(); + /** Simulate the marker being evicted while the pre-revocation entry is still alive */ + cacheMap.delete('access:generation'); + + const after = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(after.accessibleIds)).not.toContain(ownGroup._id.toString()); + }); + + it('bypasses the cache instead of guessing a generation when the marker read fails', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + await methods.invalidatePromptGroupAccessContext(); + + failGenerationReads = true; + const bypassed = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(bypassed.accessibleIds)).not.toContain(ownGroup._id.toString()); + failGenerationReads = false; + }); + + it('removes the old generation before a replacement write can fail', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + + failGenerationWrites = true; + await methods.invalidatePromptGroupAccessContext(); + failGenerationWrites = false; + + const fresh = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(fresh.accessibleIds)).not.toContain(ownGroup._id.toString()); + }); + + it('bypasses access caching when the old generation cannot be replaced or removed', async () => { + const userId = new mongoose.Types.ObjectId(); + const ownGroup = await seedGroupAndPrompt(userId); + await grantView(PrincipalType.USER, userId, ownGroup._id); + + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + const previousGeneration = cacheMap.get('access:generation'); + await AclEntry.deleteMany({ resourceId: ownGroup._id }); + + failGenerationDeletes = true; + failGenerationWrites = true; + await expect(methods.invalidatePromptGroupAccessContext()).rejects.toThrow( + 'Prompt group access generation write failed', + ); + failGenerationDeletes = false; + failGenerationWrites = false; + expect(cacheMap.get('access:generation')).toBe(previousGeneration); + + const bypassed = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(bypassed.accessibleIds)).not.toContain(ownGroup._id.toString()); + }); + + it('writes the generation marker with a ttl that outlives cached entries', async () => { + generationTtls = []; + await methods.invalidatePromptGroupAccessContext(); + expect(generationTtls.length).toBeGreaterThan(0); + for (const ttl of generationTtls) { + expect(ttl).toBeGreaterThanOrEqual(Time.ONE_DAY); + } + }); + + it('does not cache access built from a stale membership while its eviction is pending', async () => { + const userId = new mongoose.Types.ObjectId(); + const userGroup = await methods.createGroup({ + name: 'Editors', + memberIds: [userId.toString()], + }); + const groupPrompt = await seedGroupAndPrompt(new mongoose.Types.ObjectId()); + await grantView(PrincipalType.GROUP, userGroup._id, groupPrompt._id); + + const before = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(before.accessibleIds)).toContain(groupPrompt._id.toString()); + + delayDeletes = true; + const removal = methods.removeMemberById(userGroup._id, userId.toString()); + await new Promise((resolve) => setTimeout(resolve, 25)); + await methods.getPromptGroupAccessContext({ userId: userId.toString(), role: 'USER' }); + + delayDeletes = false; + resolvePendingDeletes.forEach((resolve) => resolve()); + resolvePendingDeletes = []; + await removal; + + const after = await methods.getPromptGroupAccessContext({ + userId: userId.toString(), + role: 'USER', + }); + expect(idStrings(after.accessibleIds)).not.toContain(groupPrompt._id.toString()); + }); +}); + +describe('runAfterTransaction', () => { + function createSession(abortError?: Error) { + let active = true; + const endedHandlers: Array<() => void> = []; + const abortTransaction = jest.fn(async (_options?: { timeoutMS?: number }) => { + active = false; + if (abortError) { + throw abortError; + } + }); + const rawSession = { + inTransaction: () => active, + commitTransaction: jest.fn(async () => { + active = false; + }), + abortTransaction, + once: (event: string, handler: () => void) => { + if (event === 'ended') { + endedHandlers.push(handler); + } + }, + }; + return { + abortTransaction, + endedHandlers, + session: rawSession as unknown as NonNullable[0]>, + startTransaction: () => { + active = true; + }, + }; + } + + it('defers invalidation until a caller-owned session commits', async () => { + const { session } = createSession(); + const invalidate = jest.fn().mockResolvedValue(undefined); + + await runAfterTransaction(session, invalidate); + expect(invalidate).not.toHaveBeenCalled(); + + await session.commitTransaction(); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it('discards deferred invalidation when the transaction aborts', async () => { + const { endedHandlers, session } = createSession(); + const invalidate = jest.fn().mockResolvedValue(undefined); + + await runAfterTransaction(session, invalidate); + await session.abortTransaction(); + endedHandlers.forEach((fire) => fire()); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('does not carry a failed abort queue into a reused session', async () => { + const abortError = new Error('abort failed'); + const { abortTransaction, session, startTransaction } = createSession(abortError); + const abortedInvalidate = jest.fn().mockResolvedValue(undefined); + const committedInvalidate = jest.fn().mockResolvedValue(undefined); + + await runAfterTransaction(session, abortedInvalidate); + await expect(session.abortTransaction({ timeoutMS: 25 })).rejects.toThrow(abortError); + expect(abortTransaction).toHaveBeenCalledWith({ timeoutMS: 25 }); + + startTransaction(); + await runAfterTransaction(session, committedInvalidate); + await session.commitTransaction(); + + expect(abortedInvalidate).not.toHaveBeenCalled(); + expect(committedInvalidate).toHaveBeenCalledTimes(1); + }); + + it('drains only the current queue when a session is reused', async () => { + const { session, startTransaction } = createSession(); + const firstInvalidate = jest.fn().mockResolvedValue(undefined); + const secondInvalidate = jest.fn().mockResolvedValue(undefined); + + await runAfterTransaction(session, firstInvalidate); + await session.commitTransaction(); + startTransaction(); + await runAfterTransaction(session, secondInvalidate); + await session.commitTransaction(); + + expect(firstInvalidate).toHaveBeenCalledTimes(1); + expect(secondInvalidate).toHaveBeenCalledTimes(1); + }); + + it('runs invalidation immediately without an active transaction', async () => { + const invalidate = jest.fn(); + await runAfterTransaction(undefined, invalidate); + expect(invalidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts index 10dbd026fb..40cad9c6b7 100644 --- a/packages/data-schemas/src/methods/prompt.ts +++ b/packages/data-schemas/src/methods/prompt.ts @@ -1,7 +1,14 @@ -import { ResourceType, SystemCategories } from 'librechat-data-provider'; +import { randomUUID } from 'crypto'; +import { + CacheKeys, + PermissionBits, + ResourceType, + SystemCategories, + Time, +} from 'librechat-data-provider'; import type { Model, Types } from 'mongoose'; -import type { IAclEntry, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types'; -import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext'; +import type { IAclEntry, CacheStore, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types'; +import { getTenantId, scopedCacheKey, SYSTEM_TENANT_ID } from '~/config/tenantContext'; import { isValidObjectIdString } from '~/utils/objectId'; import { escapeRegExp } from '~/utils/string'; import logger from '~/config/winston'; @@ -14,6 +21,74 @@ export interface PromptDeps { userObjectId: Types.ObjectId, resourceTypes: string | string[], ) => Promise; + /** Returns a cache store for the given key. Injected from getLogStores. */ + getCache?: (key: string) => CacheStore | undefined; + /** Resolves ACL principals for a user. From createUserGroupMethods. */ + getUserPrincipals: (params: { + userId: string | Types.ObjectId; + role?: string | null; + }) => Promise>; + /** Finds resource IDs accessible to a set of principals. From createAclEntryMethods. */ + findAccessibleResources: ( + principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>, + resourceType: string, + requiredPermBit: number, + resourceIds?: Types.ObjectId[], + readPrimary?: boolean, + ) => Promise; + /** Finds publicly accessible resource IDs. From createAclEntryMethods. */ + findPublicResourceIds: ( + resourceType: string, + requiredPermissions: number, + resourceIds?: Types.ObjectId[], + readPrimary?: boolean, + ) => Promise; +} + +/** In-flight access ID builds, so concurrent same-process misses share one resolution. */ +const pendingAccessLookups = new Map< + string, + { generationKey: string; promise: Promise; markStale: () => void } +>(); +/** Tenant generation markers whose failed invalidation makes cache reads unsafe. */ +const bypassedAccessGenerationKeys = new Set(); + +const ACCESS_GENERATION_KEY = 'access:generation'; + +function isCachedIdArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((id) => typeof id === 'string' && isValidObjectIdString(id)) + ); +} + +/** + * Reads the tenant's invalidation generation. Cached entries are keyed by it, so a + * bump orphans every previous entry for this tenant without touching other tenants. + * A missing marker (fresh tenant, or evicted under a Redis eviction policy) is + * reinitialized to a fresh never-before-used value rather than falling back to + * zero, where an evicted era's orphaned entry could still be read. Resolves + * undefined when the marker cannot be read: guessing a generation then could + * serve an orphaned entry, so callers must bypass the cache. + */ +async function readAccessGeneration(cache: CacheStore): Promise { + const generationKey = scopedCacheKey(ACCESS_GENERATION_KEY); + if (bypassedAccessGenerationKeys.has(generationKey)) { + return undefined; + } + try { + const value = await cache.get(generationKey); + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + const initialized = randomUUID(); + await cache.set(generationKey, initialized, Time.ONE_DAY); + return initialized; + } catch { + return undefined; + } } export interface PromptMethods { @@ -68,7 +143,13 @@ export interface PromptMethods { filter: Record, ): Promise | null | { message: string }>; getPromptGroup(filter: Record): Promise | null>; - getOwnedPromptGroupIds(author: string): Promise; + getOwnedPromptGroupIds(author: string, readPrimary?: boolean): Promise; + getPromptGroupAccessContext(params: { userId: string; role?: string }): Promise<{ + accessibleIds: Types.ObjectId[]; + publiclyAccessibleIds: Types.ObjectId[]; + ownedPromptGroupIds: Types.ObjectId[]; + }>; + invalidatePromptGroupAccessContext(): Promise; deletePrompt(params: { promptId: string | Types.ObjectId; groupId: string | Types.ObjectId; @@ -274,6 +355,8 @@ export function createPromptMethods( logger.error('Error removing promptGroup permissions:', error); } + await invalidatePromptGroupAccessContext(); + return { message: 'Prompt group deleted successfully' }; } @@ -465,6 +548,8 @@ export function createPromptMethods( .select('-__v') .exec())!; + await invalidatePromptGroupAccessContext(); + return { prompt: newPrompt, group: { @@ -647,18 +732,227 @@ export function createPromptMethods( * Used by the "Shared Prompts" and "My Prompts" filters to distinguish * owned prompts from prompts shared with the user. */ - async function getOwnedPromptGroupIds(author: string) { + async function getOwnedPromptGroupIds(author: string, readPrimary = false) { try { const PromptGroup = mongoose.models.PromptGroup as Model; if (!author || !ObjectId.isValid(author)) { logger.warn('getOwnedPromptGroupIds called with invalid author', { author }); return []; } - const groups = await PromptGroup.find({ author: new ObjectId(author) }, { _id: 1 }).lean(); + const groupsQuery = PromptGroup.find({ author: new ObjectId(author) }, { _id: 1 }); + if (readPrimary) { + /** + * Cache builds must not capture a lagging secondary's pre-mutation state + * for the full TTL (`secondaryPreferred` deployments). + */ + groupsQuery.read('primary'); + } + const groups = await groupsQuery.lean(); return groups.map((g) => g._id); } catch (error) { logger.error('Error getting owned prompt group IDs', error); - return []; + /** A failed lookup must not be cached as an empty ownership set */ + throw error; + } + } + + /** + * Resolves one prompt group ID set from the PROMPT_GROUPS_ACCESS cache, building + * it on miss. Entries are stored as hex strings and revived to ObjectIds on read; + * concurrent same-process misses share a single build, which invalidation marks + * stale so a pre-mutation build never writes its IDs back into the cache. + */ + async function resolveCachedIds( + cacheKey: string, + generationKey: string, + build: () => Promise, + ): Promise { + const cache = deps.getCache?.(CacheKeys.PROMPT_GROUPS_ACCESS); + if (!cache) { + return build(); + } + + try { + const cached = await cache.get(cacheKey); + if (isCachedIdArray(cached)) { + return cached.map((id) => new ObjectId(id)); + } + } catch { + /** Cache failures must not block access resolution. */ + } + + const pending = pendingAccessLookups.get(cacheKey); + if (pending) { + const ids = await pending.promise; + return ids.map((id) => new ObjectId(id)); + } + + let stale = false; + const lookup = (async () => { + const ids = await build(); + if (!stale) { + try { + await cache.set( + cacheKey, + ids.map((id) => id.toString()), + ); + } catch { + /** Cache write failures only cost a rebuild on the next request. */ + } + } + return ids.map((id) => id.toString()); + })(); + + pendingAccessLookups.set(cacheKey, { + generationKey, + promise: lookup, + markStale: () => { + stale = true; + }, + }); + try { + const ids = await lookup; + return ids.map((id) => new ObjectId(id)); + } finally { + if (pendingAccessLookups.get(cacheKey)?.promise === lookup) { + pendingAccessLookups.delete(cacheKey); + } + } + } + + /** + * Resolves the prompt group access ID sets shared by the list endpoints: + * user-accessible, publicly accessible, and user-owned group IDs. + * + * The public set is identical for every user of a tenant and the per-user sets + * only change on prompt or permission mutations. Hosts may cache all three in + * the PROMPT_GROUPS_ACCESS namespace to spare overlapping requests the repeated + * ACL queries. Hosts that cannot guarantee shared invalidation can use a no-op + * store so authorization IDs are always rebuilt. + */ + async function getPromptGroupAccessContext({ + userId, + role, + }: { + userId: string; + role?: string; + }): Promise<{ + accessibleIds: Types.ObjectId[]; + publiclyAccessibleIds: Types.ObjectId[]; + ownedPromptGroupIds: Types.ObjectId[]; + }> { + const cache = deps.getCache?.(CacheKeys.PROMPT_GROUPS_ACCESS); + /** + * Keys carry the tenant's invalidation generation, so a bump orphans every + * cached set for this tenant, including late writes from in-flight builds, + * without clearing other tenants' entries in the shared namespace. Builds + * read the primary so a lagging secondary cannot pin pre-mutation IDs for + * the full TTL. + */ + const generationKey = scopedCacheKey(ACCESS_GENERATION_KEY); + const generation = cache ? await readAccessGeneration(cache) : '0'; + const buildAccessible = async (): Promise => { + const principalsList = await deps.getUserPrincipals({ userId, role }); + if (principalsList.length === 0) { + return []; + } + return deps.findAccessibleResources( + principalsList, + ResourceType.PROMPTGROUP, + PermissionBits.VIEW, + undefined, + true, + ); + }; + const buildPublic = () => + deps.findPublicResourceIds(ResourceType.PROMPTGROUP, PermissionBits.VIEW, undefined, true); + const buildOwned = () => getOwnedPromptGroupIds(userId, true); + + if (generation === undefined) { + /** The marker could not be read; guessing a generation could serve an orphaned entry */ + const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + buildAccessible(), + buildPublic(), + buildOwned(), + ]); + return { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds }; + } + + const scopedKey = (key: string) => scopedCacheKey(`access:${generation}:${key}`); + + const accessibleIds = await resolveCachedIds( + scopedKey(`user:${userId}:${role ?? ''}`), + generationKey, + buildAccessible, + ); + + const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + resolveCachedIds(scopedKey('public'), generationKey, buildPublic), + resolveCachedIds(scopedKey(`owned:${userId}`), generationKey, buildOwned), + ]); + + return { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds }; + } + + /** + * Invalidates all cached prompt group access ID sets for the active tenant after + * a mutation that can change them (group create/delete, permission grants/revokes, + * membership changes). Bumping the generation orphans cached entries and any + * writes still in flight from pre-mutation builds, including in other processes + * sharing the store, while other tenants' entries stay intact. In-flight builds + * in this process are additionally marked stale so they skip their cache write. + * The old generation marker is removed before its replacement is written. If + * neither operation succeeds, reads bypass this tenant's cache and the mutation + * rejects instead of allowing the old authorization era to remain authoritative. + */ + async function invalidatePromptGroupAccessContext(): Promise { + const generationKey = scopedCacheKey(ACCESS_GENERATION_KEY); + for (const [cacheKey, pending] of pendingAccessLookups) { + if (pending.generationKey !== generationKey) { + continue; + } + pending.markStale(); + pendingAccessLookups.delete(cacheKey); + } + const cache = deps.getCache?.(CacheKeys.PROMPT_GROUPS_ACCESS); + if (!cache) { + return; + } + let markerRemoved = false; + if (cache.delete) { + try { + const deleteResult = await cache.delete(generationKey); + markerRemoved = deleteResult !== false; + if (!markerRemoved) { + logger.warn('The previous prompt group access generation was not removed'); + } + } catch (error) { + logger.warn('Failed to remove the previous prompt group access generation', error); + } + } + try { + /** + * A collision-resistant token cannot repeat an evicted era or be restored + * by a delayed initializer that selected a different token before this write. + */ + /** + * Keep the marker longer than derived entries so normal expiry does not + * orphan a warm cache era and force avoidable access-query rebuilds. + */ + const setResult = await cache.set(generationKey, randomUUID(), Time.ONE_DAY); + if (setResult === false) { + throw new Error('Prompt group access generation write failed'); + } + bypassedAccessGenerationKeys.delete(generationKey); + } catch (error) { + if (markerRemoved) { + bypassedAccessGenerationKeys.delete(generationKey); + logger.warn('Failed to restore the removed prompt group access generation', error); + return; + } + bypassedAccessGenerationKeys.add(generationKey); + logger.warn('Failed to invalidate prompt group access cache', error); + throw error; } } @@ -702,6 +996,8 @@ export function createPromptMethods( await PromptGroup.deleteOne({ _id: groupId }); + await invalidatePromptGroupAccessContext(); + return { prompt: 'Prompt deleted successfully', promptGroup: { @@ -772,6 +1068,7 @@ export function createPromptMethods( await PromptGroup.deleteMany({ _id: { $in: allGroupIdsToDelete } }); await Prompt.deleteMany({ groupId: { $in: allGroupIdsToDelete } }); + await invalidatePromptGroupAccessContext(); } catch (error) { logger.error('[deleteUserPrompts] General error:', error); } @@ -866,6 +1163,8 @@ export function createPromptMethods( getPromptGroupsWithPrompts, getPromptGroup, getOwnedPromptGroupIds, + getPromptGroupAccessContext, + invalidatePromptGroupAccessContext, deletePrompt, deleteUserPrompts, updatePromptGroup, diff --git a/packages/data-schemas/src/methods/userGroup.spec.ts b/packages/data-schemas/src/methods/userGroup.spec.ts index a825952823..991bba445c 100644 --- a/packages/data-schemas/src/methods/userGroup.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.spec.ts @@ -957,7 +957,7 @@ describe('userGroup methods', () => { expect(groupPrincipalIds(await parkedRead)).toEqual([]); }); - it('defers invalidation for transactional writes until the session ends', async () => { + it('defers invalidation for transactional writes until the session commits', async () => { const user = await createTestUser({ idOnTheSource: 'txn-ext-1' }); const group = await Group.create({ name: 'Transactional Team', @@ -984,13 +984,12 @@ describe('userGroup methods', () => { expect(groupPrincipalIds(await cachedMethods.getUserPrincipals(params))).toEqual([]); await session.commitTransaction(); - await session.endSession(); - await new Promise((resolve) => setTimeout(resolve, 20)); expect(cache.delete).toHaveBeenCalledWith('txn-ext-1'); expect(groupPrincipalIds(await cachedMethods.getUserPrincipals(params))).toEqual([ group._id.toString(), ]); + await session.endSession(); }); it('caches and invalidates under tenant-scoped keys within a tenant context', async () => { @@ -1043,13 +1042,12 @@ describe('userGroup methods', () => { }); expect(cache.delete).not.toHaveBeenCalled(); - /** Session ends outside the tenant context; the snapshot must preserve it */ + /** Commit runs outside the tenant context; the snapshot must preserve it */ await session.commitTransaction(); - await session.endSession(); - await new Promise((resolve) => setTimeout(resolve, 20)); expect(cache.delete).toHaveBeenCalledWith('txn-tenant-ext-1:tenant-b'); expect(cache.delete).toHaveBeenCalledWith('txn-tenant-ext-1'); + await session.endSession(); }); it('runs a delayed second invalidation to evict cross-process stale rewrites', async () => { diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 11ab0cd0f9..2532fde043 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -11,6 +11,11 @@ import { escapeRegExp } from '~/utils/string'; export interface UserGroupDeps { /** Returns the USER_PRINCIPALS cache store when principal caching is enabled. From getLogStores. */ getCache?: (key: string) => CacheStore | undefined; + /** + * Notified after membership caches are invalidated so access caches derived from + * memberships (e.g. prompt group access IDs) drop their stale entries too. + */ + onMemberGroupsInvalidated?: () => void | Promise; } type PendingGroupLookup = { promise: Promise; markStale: () => void }; @@ -32,17 +37,57 @@ type DeferredInvalidation = { invalidate: () => Promise; }; -/** One queue (and one `ended` listener) per session, so many mutations in one transaction - * cannot trip the emitter's max-listeners warning. Weak keys drop abandoned sessions. */ +/** One queue and one set of lifecycle hooks per session, so many mutations in one + * transaction cannot trip the emitter's max-listeners warning. */ const sessionInvalidations = new WeakMap(); +const hookedSessions = new WeakSet(); + +async function drainSessionInvalidations(session: ClientSession): Promise { + const queue = sessionInvalidations.get(session); + if (!queue) { + return; + } + sessionInvalidations.delete(session); + await Promise.all(queue.map((entry) => entry.run(entry.invalidate).catch(() => undefined))); +} + +function hookSessionLifecycle(session: ClientSession): void { + if (hookedSessions.has(session)) { + return; + } + hookedSessions.add(session); + + const commitTransaction = session.commitTransaction.bind(session); + session.commitTransaction = async ( + ...args: Parameters + ): Promise => { + await commitTransaction(...args); + await drainSessionInvalidations(session); + }; + + const abortTransaction = session.abortTransaction.bind(session); + session.abortTransaction = async ( + ...args: Parameters + ): Promise => { + try { + await abortTransaction(...args); + } finally { + sessionInvalidations.delete(session); + } + }; + + session.once('ended', () => { + sessionInvalidations.delete(session); + }); +} /** - * Runs cache invalidation immediately, or defers it to session end for transactional + * Runs cache invalidation immediately, or defers it to commit for transactional * writes. Mid-transaction invalidation would let concurrent readers re-cache pre-commit * state with no later correction; deferring keeps the existing entry serving the - * still-committed old memberships until the transaction commits or aborts. + * still-committed old memberships until the transaction commits. Aborts discard the queue. */ -function runAfterTransaction( +export function runAfterTransaction( session: ClientSession | undefined, invalidate: () => Promise, ): Promise { @@ -57,12 +102,7 @@ function runAfterTransaction( } const newQueue: DeferredInvalidation[] = [deferred]; sessionInvalidations.set(session, newQueue); - session.once('ended', () => { - sessionInvalidations.delete(session); - for (const entry of newQueue) { - entry.run(entry.invalidate).catch(() => undefined); - } - }); + hookSessionLifecycle(session); return Promise.resolve(); } @@ -437,11 +477,26 @@ export function createUserGroupMethods( * non-fatal; the cache TTL bounds any residual staleness (e.g. mutations * running under a different tenant context than the member's reads). */ + /** + * Notifies dependent access caches after memberships have been evicted, never + * before: a dependent rebuild that runs first would read the stale membership + * and cache its derived IDs under the freshly bumped generation, surviving + * the membership eviction that should have prevented them. + */ + const notifyMemberGroupsInvalidated = async (): Promise => { + try { + await deps.onMemberGroupsInvalidated?.(); + } catch { + /** Dependent cache invalidation must not block membership updates. */ + } + }; + async function invalidateMemberGroupsCache( memberIds: Array, ): Promise { const cache = getPrincipalsCache(); if (!cache?.delete) { + await notifyMemberGroupsInvalidated(); return; } const cacheKeys = new Set(); @@ -459,14 +514,19 @@ export function createUserGroupMethods( if (cacheKeys.size > INVALIDATION_CLEAR_THRESHOLD && cache.clear) { return clearMemberGroupsCache(); } - await dropMemberKeys(cache, cacheKeys); - scheduleSecondInvalidation(cache, () => dropMemberKeys(cache, cacheKeys)); + const evict = async (): Promise => { + await dropMemberKeys(cache, cacheKeys); + await notifyMemberGroupsInvalidated(); + }; + await evict(); + scheduleSecondInvalidation(cache, evict); } /** Clears the whole membership cache when affected members cannot be enumerated. */ async function clearMemberGroupsCache(): Promise { const cache = getPrincipalsCache(); if (!cache?.clear) { + await notifyMemberGroupsInvalidated(); return; } const clearAll = async (): Promise => { @@ -479,6 +539,7 @@ export function createUserGroupMethods( } catch { /** Cache failures must not block membership updates. */ } + await notifyMemberGroupsInvalidated(); }; await clearAll(); scheduleSecondInvalidation(cache, clearAll); diff --git a/packages/data-schemas/src/types/cache.ts b/packages/data-schemas/src/types/cache.ts index 2181f83812..04bd4e6ebf 100644 --- a/packages/data-schemas/src/types/cache.ts +++ b/packages/data-schemas/src/types/cache.ts @@ -5,7 +5,8 @@ */ export interface CacheStore { get: (key: string) => Promise; - set: (key: string, value: unknown) => Promise; + /** The optional ttl overrides the store's namespace default for this entry. */ + set: (key: string, value: unknown, ttl?: number) => Promise; delete?: (key: string) => Promise; clear?: () => Promise; /** True when the store is shared across processes (e.g. Redis-backed). */