Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
## 1. Implementation

- [ ] 1.1 Define `ICacheProvider` interface in `src/contracts/ICacheProvider.ts` with `get(key)`, `set(key, result, ttl)`, and `delete(key)` methods.
- [ ] 1.2 Implement `MemoryCacheProvider` in `src/utils/MemoryCacheProvider.ts` as the default in-memory cache implementation.
- [ ] 1.3 Update `TaskStep` interface in `src/TaskStep.ts` to include optional `cache` configuration:
- [x] 1.1 Define `ICacheProvider` interface in `src/contracts/ICacheProvider.ts` with `get(key)`, `set(key, result, ttl)`, and `delete(key)` methods.
- [x] 1.2 Implement `MemoryCacheProvider` in `src/utils/MemoryCacheProvider.ts` as the default in-memory cache implementation.
- [x] 1.3 Update `TaskStep` interface in `src/TaskStep.ts` to include optional `cache` configuration:
- `key`: `(context: TContext) => string | Promise<string>`
- `ttl`: `number` (optional, default to infinite)
- `restore`: `(context: TContext, cachedResult: TaskResult) => void | Promise<void>` (optional, to re-apply context side effects)
- [ ] 1.4 Create `CachingExecutionStrategy` in `src/strategies/CachingExecutionStrategy.ts`.
- [x] 1.4 Create `CachingExecutionStrategy` in `src/strategies/CachingExecutionStrategy.ts`.
- It should implement `IExecutionStrategy`.
- It should accept an inner `IExecutionStrategy` and an `ICacheProvider`.
- In `execute`:
Expand All @@ -18,7 +18,7 @@
- Execute inner strategy.
- If successful, store result in cache provider using `ttl`.
- Return result.
- [ ] 1.5 Update `TaskRunner.ts` to support configuring the cache provider and wrapping the execution strategy with `CachingExecutionStrategy` if caching is enabled.
- [ ] 1.6 Add unit tests for `MemoryCacheProvider`.
- [ ] 1.7 Add unit tests for `CachingExecutionStrategy`, verifying cache hits, misses, and restoration of context.
- [ ] 1.8 Add integration tests in `tests/TaskRunnerCaching.test.ts` to verify end-to-end caching behavior with context updates.
- [x] 1.5 Update `TaskRunner.ts` to support configuring the cache provider and wrapping the execution strategy with `CachingExecutionStrategy` if caching is enabled.
- [x] 1.6 Add unit tests for `MemoryCacheProvider`.
- [x] 1.7 Add unit tests for `CachingExecutionStrategy`, verifying cache hits, misses, and restoration of context.
- [x] 1.8 Add integration tests in `tests/TaskRunnerCaching.test.ts` to verify end-to-end caching behavior with context updates.
58 changes: 58 additions & 0 deletions openspec/specs/task-runner/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,61 @@ The system SHALL record timing metrics for each executed task, including start t
#### Scenario: Failed execution
- **WHEN** a task fails
- **THEN** the task result contains the start timestamp, end timestamp, and duration in milliseconds
## ADDED Requirements

### Requirement: Task Caching Configuration

The `TaskStep` interface SHALL support an optional `cache` property of type `TaskCacheConfig`.

#### Scenario: Cache Config Structure

- **GIVEN** a `TaskCacheConfig` object
- **THEN** it SHALL support:
- `key`: A function returning a unique string key based on the context.
- `ttl`: Optional time-to-live in milliseconds.
- `restore`: Optional function to restore context side effects from a cached result.

### Requirement: Caching Execution Strategy

The system SHALL provide a `CachingExecutionStrategy` that implements `IExecutionStrategy` and wraps another `IExecutionStrategy`.

#### Scenario: Cache Miss Execution

- **WHEN** the `CachingExecutionStrategy` executes a task with a cache key that is NOT present in the cache provider
- **THEN** it SHALL execute the task using the inner strategy.
- **AND** it SHALL store the result in the cache provider if execution is successful.
- **AND** it SHALL return the result.

#### Scenario: Cache Hit Execution

- **WHEN** the `CachingExecutionStrategy` executes a task with a cache key that IS present in the cache provider
- **THEN** it SHALL NOT execute the inner strategy.
- **AND** it SHALL invoke the `restore` function (if provided) with the current context and the cached result.
- **AND** it SHALL return the cached result.

#### Scenario: Cache Expiration

- **WHEN** a cached item's TTL has expired
- **THEN** the cache provider SHALL NOT return the item.
- **AND** the strategy SHALL proceed as a cache miss.

### Requirement: Cache Provider Interface

The system SHALL define an `ICacheProvider` interface for pluggable caching backends.

#### Scenario: Interface Methods

- **GIVEN** an `ICacheProvider` implementation
- **THEN** it SHALL support:
- `get(key: string): Promise<TaskResult | undefined>`
- `set(key: string, value: TaskResult, ttl?: number): Promise<void>`
- `delete(key: string): Promise<void>`

### Requirement: Default Memory Cache

The system SHALL provide a `MemoryCacheProvider` as the default implementation of `ICacheProvider`.

#### Scenario: In-Memory Storage

- **WHEN** items are set in `MemoryCacheProvider`
- **THEN** they are stored in memory and retrieved correctly until process termination or expiration.
18 changes: 18 additions & 0 deletions src/TaskRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrateg
import { Plugin } from "./contracts/Plugin.js";
import { PluginManager } from "./PluginManager.js";
import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js";
import { ICacheProvider } from "./contracts/ICacheProvider.js";
import { CachingExecutionStrategy } from "./strategies/CachingExecutionStrategy.js";

const MERMAID_ID_REGEX = /[^a-zA-Z0-9_-]/g;

Expand All @@ -32,6 +34,7 @@ export class TaskRunner<TContext> {
new RetryingExecutionStrategy(new StandardExecutionStrategy());

private readonly pluginManager: PluginManager<TContext>;
private cacheProvider?: ICacheProvider;

/**
* @param context The shared context object to be passed to each task.
Expand Down Expand Up @@ -84,6 +87,16 @@ export class TaskRunner<TContext> {
return this;
}

/**
* Sets the cache provider for task caching.
* @param provider The cache provider.
* @returns The TaskRunner instance for chaining.
*/
public setCacheProvider(provider: ICacheProvider): this {
this.cacheProvider = provider;
return this;
}

/**
* Generates a Mermaid.js graph representation of the task workflow.
* @param steps The list of tasks to visualize.
Expand Down Expand Up @@ -195,6 +208,11 @@ export class TaskRunner<TContext> {
const stateManager = new TaskStateManager(this.eventBus);

let strategy = this.executionStrategy;

if (this.cacheProvider && !config?.dryRun) {
strategy = new CachingExecutionStrategy(strategy, this.cacheProvider);
}

if (config?.dryRun) {
strategy = new DryRunExecutionStrategy<TContext>();
}
Expand Down
6 changes: 6 additions & 0 deletions src/TaskStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { TaskLoopConfig } from "./contracts/TaskLoopConfig.js";
export interface TaskStep<TContext> {
/** A unique identifier for this task. */
name: string;
/** Optional cache configuration. */
cache?: {
key: (context: TContext) => string | Promise<string>;
ttl?: number;
restore?: (context: TContext, cachedResult: TaskResult) => void | Promise<void>;
};
/** An optional list of task names that must complete successfully before this step can run. */
dependencies?: string[];
/** Optional retry configuration for the task. */
Expand Down
27 changes: 27 additions & 0 deletions src/contracts/ICacheProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { TaskResult } from "../TaskResult.js";

/**
* Interface for cache providers used by the CachingExecutionStrategy.
*/
export interface ICacheProvider {
/**
* Retrieves a cached result by its key.
* @param key The cache key.
* @returns The cached TaskResult or undefined if not found.
*/
get(key: string): Promise<TaskResult | undefined> | TaskResult | undefined;

/**
* Stores a result in the cache.
* @param key The cache key.
* @param result The task result to cache.
* @param ttl Optional time-to-live in milliseconds.
*/
set(key: string, result: TaskResult, ttl?: number): Promise<void> | void;

/**
* Deletes a cached result by its key.
* @param key The cache key.
*/
delete(key: string): Promise<void> | void;
}
47 changes: 47 additions & 0 deletions src/strategies/CachingExecutionStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { IExecutionStrategy } from "./IExecutionStrategy.js";
import { TaskStep } from "../TaskStep.js";
import { TaskResult } from "../TaskResult.js";
import { ICacheProvider } from "../contracts/ICacheProvider.js";

/**
* Execution strategy that wraps another strategy and adds caching capabilities.
*/
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) {
if (step.cache.restore) {
await step.cache.restore(context, cachedResult);
}

return {
...cachedResult,
status: "skipped",
message: cachedResult.message ? `${cachedResult.message} (cached)` : "Task skipped (cached)",
};
}

const result = await this.innerStrategy.execute(step, context, signal);

if (result.status === "success") {
await this.cacheProvider.set(cacheKey, result, step.cache.ttl);
}

return result;
}
}
44 changes: 44 additions & 0 deletions src/utils/MemoryCacheProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ICacheProvider } from "../contracts/ICacheProvider.js";
import { TaskResult } from "../TaskResult.js";

interface CacheEntry {
result: TaskResult;
expiresAt?: number;
}

/**
* A simple in-memory implementation of ICacheProvider.
*/
export class MemoryCacheProvider implements ICacheProvider {
private readonly cache = new Map<string, CacheEntry>();

get(key: string): TaskResult | undefined {
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}

if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Date.now() directly in the logic makes the code harder to test and potentially inconsistent. Consider injecting a clock or using a time provider to improve testability and maintainability.

this.cache.delete(key);
return undefined;
}

return entry.result;
}

set(key: string, result: TaskResult, ttl?: number): void {
const entry: CacheEntry = {
result,
};

if (ttl !== undefined) {
entry.expiresAt = Date.now() + ttl;
}

this.cache.set(key, entry);
}

delete(key: string): void {
this.cache.delete(key);
}
}
Loading
Loading