-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement task output caching strategy #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { TaskResult } from "../TaskResult.js"; | ||
|
|
||
| /** | ||
| * Interface for a cache provider used to store and retrieve task execution results. | ||
| */ | ||
| export interface ICacheProvider { | ||
| /** | ||
| * Retrieves a cached result by its key. | ||
| * @param key The unique cache key. | ||
| * @returns A promise resolving to the cached TaskResult, or undefined if not found or expired. | ||
| */ | ||
| get(key: string): Promise<TaskResult | undefined>; | ||
|
|
||
| /** | ||
| * Stores a task result in the cache. | ||
| * @param key The unique cache key. | ||
| * @param result The task result to store. | ||
| * @param ttl Optional time-to-live in milliseconds. If omitted, the cache may be indefinite. | ||
| * @returns A promise that resolves when the store operation completes. | ||
| */ | ||
| set(key: string, result: TaskResult, ttl?: number): Promise<void>; | ||
|
|
||
| /** | ||
| * Deletes a cached result by its key. | ||
| * @param key The unique cache key. | ||
| * @returns A promise that resolves when the delete operation completes. | ||
| */ | ||
| delete(key: string): Promise<void>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { IExecutionStrategy } from "./IExecutionStrategy.js"; | ||
| import { TaskStep } from "../TaskStep.js"; | ||
| import { TaskResult } from "../TaskResult.js"; | ||
| import { ICacheProvider } from "../contracts/ICacheProvider.js"; | ||
|
|
||
| /** | ||
| * An execution strategy that wraps another strategy to provide caching capabilities. | ||
| * @template TContext The shape of the shared context object. | ||
| */ | ||
| export class CachingExecutionStrategy<TContext> implements IExecutionStrategy<TContext> { | ||
| constructor( | ||
| private readonly innerStrategy: IExecutionStrategy<TContext>, | ||
| private readonly cacheProvider: ICacheProvider | ||
| ) {} | ||
|
|
||
| async execute( | ||
| step: TaskStep<TContext>, | ||
| context: TContext, | ||
| signal?: AbortSignal | ||
| ): Promise<TaskResult> { | ||
| if (!step.cache) { | ||
| return this.innerStrategy.execute(step, context, signal); | ||
| } | ||
|
|
||
| const cacheKey = await step.cache.key(context); | ||
| const cachedResult = await this.cacheProvider.get(cacheKey); | ||
|
|
||
| if (cachedResult !== undefined) { | ||
| if (step.cache.restore) { | ||
| await step.cache.restore(context, cachedResult); | ||
| } | ||
| return { | ||
| ...cachedResult, | ||
| status: "skipped", // or "cached" if we add it to TaskStatus | ||
| }; | ||
|
Comment on lines
+32
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changing the status to If you want to distinguish cache hits, consider adding a return {
...cachedResult,
}; |
||
| } | ||
|
|
||
| const result = await this.innerStrategy.execute(step, context, signal); | ||
|
|
||
| if (result.status === "success") { | ||
| await this.cacheProvider.set(cacheKey, result, step.cache.ttl); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { ICacheProvider } from "../contracts/ICacheProvider.js"; | ||
| import { TaskResult } from "../TaskResult.js"; | ||
|
|
||
| /** | ||
| * An in-memory implementation of ICacheProvider. | ||
| */ | ||
| export class MemoryCacheProvider implements ICacheProvider { | ||
| private readonly cache = new Map<string, { value: TaskResult; expiresAt?: number }>(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider implementing a periodic cleanup (e.g., using |
||
|
|
||
| async get(key: string): Promise<TaskResult | undefined> { | ||
| const entry = this.cache.get(key); | ||
| if (!entry) { | ||
| return undefined; | ||
| } | ||
|
|
||
| if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) { | ||
| this.cache.delete(key); | ||
| return undefined; | ||
| } | ||
|
|
||
| return entry.value; | ||
| } | ||
|
|
||
| async set(key: string, result: TaskResult, ttl?: number): Promise<void> { | ||
| const expiresAt = ttl !== undefined ? Date.now() + ttl : undefined; | ||
|
Check warning on line 25 in src/utils/MemoryCacheProvider.ts
|
||
| this.cache.set(key, { value: result, expiresAt }); | ||
| } | ||
|
|
||
| async delete(key: string): Promise<void> { | ||
| this.cache.delete(key); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { TaskRunner } from "../src/TaskRunner.js"; | ||
| import { MemoryCacheProvider } from "../src/utils/MemoryCacheProvider.js"; | ||
| import { TaskStep } from "../src/TaskStep.js"; | ||
|
|
||
| describe("TaskRunner Caching Integration", () => { | ||
| it("should cache successful task execution and restore context", async () => { | ||
| type TestContext = { | ||
| counter: number; | ||
| restored: boolean; | ||
| }; | ||
|
|
||
| const context: TestContext = { counter: 0, restored: false }; | ||
| const runner = new TaskRunner(context); | ||
| const cacheProvider = new MemoryCacheProvider(); | ||
| runner.setCacheProvider(cacheProvider); | ||
|
|
||
| const runMock = vi.fn().mockImplementation(async (ctx: TestContext) => { | ||
| ctx.counter++; | ||
| return { status: "success", data: "computed" }; | ||
| }); | ||
|
|
||
| const step: TaskStep<TestContext> = { | ||
| name: "cached-task", | ||
| run: runMock, | ||
| cache: { | ||
| key: () => "static-key", | ||
| restore: (ctx) => { | ||
| ctx.restored = true; | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| // First execution: cache miss | ||
| const results1 = await runner.execute([step]); | ||
| expect(results1.get("cached-task")?.status).toBe("success"); | ||
| expect(results1.get("cached-task")?.data).toBe("computed"); | ||
| expect(runMock).toHaveBeenCalledTimes(1); | ||
| expect(context.counter).toBe(1); | ||
| expect(context.restored).toBe(false); | ||
|
|
||
| // Reset restored flag just in case | ||
| context.restored = false; | ||
|
|
||
| // Second execution: cache hit | ||
| const results2 = await runner.execute([step]); | ||
| expect(results2.get("cached-task")?.status).toBe("skipped"); // Or cached | ||
| expect(results2.get("cached-task")?.data).toBe("computed"); | ||
| expect(runMock).toHaveBeenCalledTimes(1); // Not called again | ||
| expect(context.counter).toBe(1); // Not incremented | ||
| expect(context.restored).toBe(true); // Restored was called | ||
| }); | ||
|
|
||
| it("should not cache if step has no cache configuration", async () => { | ||
| type TestContext = { counter: number }; | ||
| const context: TestContext = { counter: 0 }; | ||
| const runner = new TaskRunner(context); | ||
| const cacheProvider = new MemoryCacheProvider(); | ||
| runner.setCacheProvider(cacheProvider); | ||
|
|
||
| const runMock = vi.fn().mockImplementation(async (ctx: TestContext) => { | ||
| ctx.counter++; | ||
| return { status: "success", data: "computed" }; | ||
| }); | ||
|
|
||
| const step: TaskStep<TestContext> = { | ||
| name: "uncached-task", | ||
| run: runMock, | ||
| }; | ||
|
|
||
| await runner.execute([step]); | ||
| await runner.execute([step]); | ||
|
|
||
| expect(runMock).toHaveBeenCalledTimes(2); | ||
| expect(context.counter).toBe(2); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure better type safety and avoid potential issues with property access narrowing in TypeScript, it's recommended to capture
this.cacheProviderin a local constant before checking and using it. This avoids the need for non-null assertions and improves robustness against future changes, aligning with repository standards for handling potentially null or undefined values.References