mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-19 16:41:18 +00:00
Merge pull request #35 from kennethnym/feat/feed-engine-cache
feat: add caching to FeedEngine
This commit is contained in:
@@ -638,4 +638,290 @@ describe("FeedEngine", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lastFeed", () => {
|
||||
test("returns null before any refresh", () => {
|
||||
const engine = new FeedEngine()
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("returns cached result after refresh", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
const weather = createWeatherSource()
|
||||
const engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const refreshResult = await engine.refresh()
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items).toEqual(refreshResult.items)
|
||||
expect(cached!.context).toEqual(refreshResult.context)
|
||||
})
|
||||
|
||||
test("returns null after TTL expires", async () => {
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 })
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("defaults to 5 minute TTL", async () => {
|
||||
const engine = new FeedEngine()
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
// Should still be cached immediately
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
})
|
||||
|
||||
test("refresh always fetches from sources", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(source)
|
||||
|
||||
await engine.refresh()
|
||||
await engine.refresh()
|
||||
await engine.refresh()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
})
|
||||
|
||||
test("reactive context update refreshes cache", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// Simulate location update which triggers reactive refresh
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
// Wait for async reactive refresh to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items.length).toBeGreaterThan(0)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("reactive item update refreshes cache", async () => {
|
||||
let itemUpdateCallback: (() => void) | null = null
|
||||
|
||||
const source: FeedSource = {
|
||||
id: "reactive-items",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
},
|
||||
]
|
||||
},
|
||||
onItemsUpdate(callback) {
|
||||
itemUpdateCallback = callback
|
||||
return () => {
|
||||
itemUpdateCallback = null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(source)
|
||||
engine.start()
|
||||
|
||||
// Trigger item update
|
||||
itemUpdateCallback!()
|
||||
|
||||
// Wait for async refresh
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items).toHaveLength(1)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("TTL resets after reactive update", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// Initial reactive update
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
// Wait 70ms (total 120ms from first update, past original TTL)
|
||||
// but trigger another update at 50ms to reset TTL
|
||||
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Should still be cached because TTL was reset by second update
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("cacheTtlMs is configurable", async () => {
|
||||
const engine = new FeedEngine({ cacheTtlMs: 30 })
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("auto-refreshes on TTL interval after start", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return [
|
||||
{
|
||||
id: `item-${fetchCount}`,
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||
engine.start()
|
||||
|
||||
// Wait for two TTL intervals to elapse
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
|
||||
// Should have auto-refreshed at least twice
|
||||
expect(fetchCount).toBeGreaterThanOrEqual(2)
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("stop cancels periodic refresh", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||
engine.start()
|
||||
engine.stop()
|
||||
|
||||
const countAfterStop = fetchCount
|
||||
|
||||
// Wait past TTL
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
// No additional fetches after stop
|
||||
expect(fetchCount).toBe(countAfterStop)
|
||||
})
|
||||
|
||||
test("reactive update resets periodic refresh timer", async () => {
|
||||
let fetchCount = 0
|
||||
const location = createLocationSource()
|
||||
const countingWeather: FeedSource<WeatherFeedItem> = {
|
||||
id: "weather",
|
||||
dependencies: ["location"],
|
||||
...noActions,
|
||||
async fetchContext(ctx) {
|
||||
fetchCount++
|
||||
const loc = contextValue(ctx, LocationKey)
|
||||
if (!loc) return null
|
||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||
},
|
||||
async fetchItems(ctx) {
|
||||
const weather = contextValue(ctx, WeatherKey)
|
||||
if (!weather) return []
|
||||
return [
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: { temperature: weather.temperature, condition: weather.condition },
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 100 })
|
||||
.register(location)
|
||||
.register(countingWeather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// At 40ms, push a reactive update — this resets the timer
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
const countBeforeUpdate = fetchCount
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
// Reactive update triggered a fetch
|
||||
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
|
||||
const countAfterUpdate = fetchCount
|
||||
|
||||
// At 100ms from start (60ms after reactive update), the original
|
||||
// timer would have fired, but it was reset. No extra fetch yet.
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchCount).toBe(countAfterUpdate)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
||||
|
||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
|
||||
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
|
||||
|
||||
export interface FeedEngineConfig {
|
||||
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
|
||||
cacheTtlMs?: number
|
||||
}
|
||||
|
||||
interface SourceGraph {
|
||||
sources: Map<string, FeedSource>
|
||||
sorted: FeedSource[]
|
||||
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
private cleanups: Array<() => void> = []
|
||||
private started = false
|
||||
|
||||
private readonly cacheTtlMs: number
|
||||
private cachedResult: FeedResult<TItems> | null = null
|
||||
private cachedAt: number | null = null
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(config?: FeedEngineConfig) {
|
||||
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached FeedResult if available and not expired.
|
||||
* Returns null if no refresh has completed or the cache TTL has elapsed.
|
||||
*/
|
||||
lastFeed(): FeedResult<TItems> | null {
|
||||
if (this.cachedResult === null || this.cachedAt === null) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
|
||||
return null
|
||||
}
|
||||
return this.cachedResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a FeedSource. Invalidates the cached graph.
|
||||
*/
|
||||
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
|
||||
this.context = context
|
||||
|
||||
return { context, items: items as TItems[], errors }
|
||||
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
||||
this.updateCache(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +172,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reactive subscriptions on all sources.
|
||||
* Starts reactive subscriptions on all sources and begins periodic refresh.
|
||||
* Sources with onContextUpdate will trigger re-computation of dependents.
|
||||
*/
|
||||
start(): void {
|
||||
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all reactive subscriptions.
|
||||
* Stops all reactive subscriptions and the periodic refresh timer.
|
||||
*/
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.cancelScheduledRefresh()
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.notifySubscribers({
|
||||
const result: FeedResult<TItems> = {
|
||||
context: this.context,
|
||||
items: items as TItems[],
|
||||
errors,
|
||||
})
|
||||
}
|
||||
this.updateCache(result)
|
||||
|
||||
this.notifySubscribers(result)
|
||||
}
|
||||
|
||||
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
||||
@@ -307,11 +347,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
|
||||
}
|
||||
|
||||
private updateCache(result: FeedResult<TItems>): void {
|
||||
this.cachedResult = result
|
||||
this.cachedAt = Date.now()
|
||||
if (this.started) {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextRefresh(): void {
|
||||
this.cancelScheduledRefresh()
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refresh()
|
||||
.then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.catch(() => {
|
||||
// Periodic refresh errors are non-fatal; schedule next attempt
|
||||
if (this.started) {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
})
|
||||
}, this.cacheTtlMs)
|
||||
}
|
||||
|
||||
private cancelScheduledRefresh(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
// Simple immediate refresh for now - could add debouncing later
|
||||
this.refresh().then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
this.refresh()
|
||||
.then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.catch(() => {
|
||||
// Reactive refresh errors are non-fatal
|
||||
})
|
||||
}
|
||||
|
||||
private notifySubscribers(result: FeedResult<TItems>): void {
|
||||
|
||||
@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
|
||||
export type { FeedSource } from "./feed-source"
|
||||
|
||||
// Feed Engine
|
||||
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export { FeedEngine } from "./feed-engine"
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user