mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
fix(plugins): bound plugin preview cache to one current revision per plugin (STA-12859) (#36089)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginMarketplaceHostListing } from '../../../../../preload/api-types'
|
||||
import { pluginManifestSchema } from '../../../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { PLUGIN_PREVIEW_CACHE_CAP, PluginPreviewCache } from './plugin-preview-cache'
|
||||
import type { PluginPagePreview } from './use-plugin-catalog'
|
||||
|
||||
function listing(pluginKey: string, commit: string): PluginMarketplaceHostListing {
|
||||
return {
|
||||
marketplaceSourceId: 'source',
|
||||
marketplaceName: 'Catalog',
|
||||
marketplaceOwner: 'acme',
|
||||
marketplaceCommit: commit,
|
||||
pluginKey,
|
||||
source: { kind: 'git', url: 'https://example.invalid/p.git', ref: 'main' },
|
||||
categories: [],
|
||||
official: false,
|
||||
bundled: false
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'dusk',
|
||||
publisher: 'acme',
|
||||
name: 'Dusk',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: { themes: [{ id: 'dusk', title: 'Dusk', path: 'themes/dusk.json' }] }
|
||||
})
|
||||
|
||||
function setup(): { cache: PluginPreviewCache; load: ReturnType<typeof vi.fn> } {
|
||||
const load = vi.fn(async (entry: PluginMarketplaceHostListing): Promise<PluginPagePreview> => ({
|
||||
ok: true,
|
||||
preview: {
|
||||
...entry,
|
||||
resolvedCommit: 'c'.repeat(40),
|
||||
contentHash: 'd'.repeat(64),
|
||||
consentFingerprint: 'sha256-shown',
|
||||
manifest
|
||||
}
|
||||
}))
|
||||
return { cache: new PluginPreviewCache(load), load }
|
||||
}
|
||||
|
||||
describe('PluginPreviewCache', () => {
|
||||
it('keeps one preview per plugin across revisions, bounded overall', async () => {
|
||||
const { cache, load } = setup()
|
||||
for (let revision = 0; revision < 10; revision++) {
|
||||
const latest = listing('a', `c${revision}`)
|
||||
cache.retainListed([latest])
|
||||
await cache.fetch(latest)
|
||||
}
|
||||
expect(cache.size).toBe(1)
|
||||
await cache.fetch(listing('a', 'c9'))
|
||||
expect(load).toHaveBeenCalledTimes(10)
|
||||
|
||||
cache.retainListed([])
|
||||
for (let index = 0; index < PLUGIN_PREVIEW_CACHE_CAP * 2; index++) {
|
||||
await cache.fetch(listing(`p${index}`, 'c'))
|
||||
}
|
||||
expect(cache.size).toBe(PLUGIN_PREVIEW_CACHE_CAP)
|
||||
})
|
||||
|
||||
it('does not let a pinned older revision displace the current one', async () => {
|
||||
const { cache, load } = setup()
|
||||
const current = listing('a', 'new')
|
||||
cache.retainListed([current])
|
||||
await cache.fetch(current)
|
||||
await cache.fetch(listing('a', 'old'))
|
||||
await cache.fetch(current)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PluginMarketplaceHostListing } from '../../../../../preload/api-types'
|
||||
import type { PluginPagePreview } from './use-plugin-catalog'
|
||||
|
||||
/** Plugins whose preview one Settings visit keeps; each holds a parsed manifest. */
|
||||
export const PLUGIN_PREVIEW_CACHE_CAP = 32
|
||||
|
||||
type Entry = { commit: string; request: Promise<PluginPagePreview> }
|
||||
|
||||
function pluginId(listing: PluginMarketplaceHostListing): string {
|
||||
return `${listing.marketplaceSourceId}:${listing.pluginKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One preview per plugin, for the catalog revision last seen: a newer revision replaces the
|
||||
* older one, a refresh drops revisions the catalog no longer lists, and the least recently
|
||||
* used plugin goes past the cap. Callers keep the promise they got, so eviction never
|
||||
* changes a preview already on screen or approved.
|
||||
*/
|
||||
export class PluginPreviewCache {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
// Why: a page pinned to an older listing must not displace the current revision's preview.
|
||||
private currentCommits = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly load: (listing: PluginMarketplaceHostListing) => Promise<PluginPagePreview>
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size
|
||||
}
|
||||
|
||||
fetch(listing: PluginMarketplaceHostListing): Promise<PluginPagePreview> {
|
||||
const id = pluginId(listing)
|
||||
const commit = listing.marketplaceCommit
|
||||
const cached = this.entries.get(id)
|
||||
if (cached?.commit === commit) {
|
||||
this.entries.delete(id)
|
||||
this.entries.set(id, cached)
|
||||
return cached.request
|
||||
}
|
||||
const request = this.load(listing).then((result) => {
|
||||
// Why: only successes are cached, so Retry asks again.
|
||||
if (!result.ok && this.entries.get(id)?.request === request) {
|
||||
this.entries.delete(id)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const current = this.currentCommits.get(id)
|
||||
if (current === undefined || current === commit) {
|
||||
this.entries.delete(id)
|
||||
this.entries.set(id, { commit, request })
|
||||
for (const oldest of this.entries.keys()) {
|
||||
if (this.entries.size <= PLUGIN_PREVIEW_CACHE_CAP) {
|
||||
break
|
||||
}
|
||||
this.entries.delete(oldest)
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/** Records the catalog's current revisions and drops previews of any other. */
|
||||
retainListed(listings: readonly PluginMarketplaceHostListing[]): void {
|
||||
this.currentCommits = new Map(
|
||||
listings.map((listing) => [pluginId(listing), listing.marketplaceCommit])
|
||||
)
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (this.currentCommits.get(id) !== entry.commit) {
|
||||
this.entries.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { usePluginPreviewRequestStore } from '@/store/plugin-preview-request'
|
||||
import { PluginPreviewCache } from './plugin-preview-cache'
|
||||
|
||||
/** A plugin page's manifest read; a failure means nothing is known, not "nothing needed". */
|
||||
export type PluginPagePreview =
|
||||
@@ -36,6 +37,21 @@ export function usePluginCatalog({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
const requestRef = useRef(0)
|
||||
const [previewCache] = useState(
|
||||
() =>
|
||||
new PluginPreviewCache((listing) =>
|
||||
window.api.plugins
|
||||
.previewMarketplacePlugin({
|
||||
marketplaceSourceId: listing.marketplaceSourceId,
|
||||
pluginKey: listing.pluginKey
|
||||
})
|
||||
.then((preview): PluginPagePreview => ({ ok: true, preview }))
|
||||
.catch((cause: unknown): PluginPagePreview => {
|
||||
console.warn('[plugins] plugin page preview failed:', cause)
|
||||
return { ok: false }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const loadMarketplaceData = useCallback(async (): Promise<void> => {
|
||||
const requestId = ++requestRef.current
|
||||
@@ -47,6 +63,7 @@ export function usePluginCatalog({
|
||||
if (mountedRef.current && requestId === requestRef.current) {
|
||||
setSources(nextSources)
|
||||
setListings(nextListings)
|
||||
previewCache.retainListed(nextListings)
|
||||
setError(null)
|
||||
}
|
||||
} catch (cause) {
|
||||
@@ -66,7 +83,7 @@ export function usePluginCatalog({
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [previewCache])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
@@ -124,29 +141,9 @@ export function usePluginCatalog({
|
||||
}
|
||||
}, [previewRequest, listings, loading])
|
||||
|
||||
const previewCacheRef = useRef(new Map<string, Promise<PluginPagePreview>>())
|
||||
const fetchPreview = useCallback(
|
||||
(listing: PluginMarketplaceHostListing): Promise<PluginPagePreview> => {
|
||||
const key = `${listing.marketplaceSourceId}:${listing.pluginKey}:${listing.marketplaceCommit}`
|
||||
let request = previewCacheRef.current.get(key)
|
||||
if (!request) {
|
||||
request = window.api.plugins
|
||||
.previewMarketplacePlugin({
|
||||
marketplaceSourceId: listing.marketplaceSourceId,
|
||||
pluginKey: listing.pluginKey
|
||||
})
|
||||
.then((preview): PluginPagePreview => ({ ok: true, preview }))
|
||||
.catch((cause: unknown): PluginPagePreview => {
|
||||
console.warn('[plugins] plugin page preview failed:', cause)
|
||||
// Why: only successes are cached, so Retry asks again.
|
||||
previewCacheRef.current.delete(key)
|
||||
return { ok: false }
|
||||
})
|
||||
previewCacheRef.current.set(key, request)
|
||||
}
|
||||
return request
|
||||
},
|
||||
[]
|
||||
(listing: PluginMarketplaceHostListing) => previewCache.fetch(listing),
|
||||
[previewCache]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user