Lightweight, pluggable caching for PowerShell expressions (scriptblocks).
Designed for ease of use — you can cache the results of any expression that outputs data, with minimal configuration. Drop in a { ... }, run once, and let a provider handle freshness, persistence, and lookup automatically.
ExpressionCache provides a stable public API for the 1.x release line. Feedback, issues, provider ideas, and real-world use cases are welcome.
- Install
- Quick start
- Examples
- Public API
- Providers
- Tests
- Project layout
- Design highlights
- Extensibility
- Licensing
- Credits
- Community & Show and Tell
From the PowerShell Gallery:
Install-Module ExpressionCache -Scope CurrentUserFor local development:
# From repo root
Import-Module "$PSScriptRoot/src/ExpressionCache.psd1" -ForceRequires PowerShell 5.1+ (works great on 7.x).
Initialize-ExpressionCache -AppName 'MyApp'
# Cache any scriptblock; the key auto-generates from the script + args. Uses the built-in default local file system cache.
$result = Get-ExpressionCache -ScriptBlock { param($x,$y) $x + $y } -Arguments 1,2
# -> 3Prefer param(...) over ambient variables inside scriptblocks.
If you must capture outer vars, use a closure: { Get-Content $file }.GetNewClosure().
Initialize-ExpressionCache -AppName 'DemoApp'
$results = Get-ExpressionCache -ScriptBlock {
param($path, $pattern)
Get-ChildItem -Path $path -Recurse -Filter $pattern -ErrorAction SilentlyContinue
} -Arguments "C:\Projects", "*.ps1"
$results | Select-Object FullName👉 Run it once, results are cached. Next call is near-instant, even if the directory tree is huge.
$gitOutput = Get-ExpressionCache -ScriptBlock {
git -C "C:\Projects\ExpressionCache" pull
}
$gitOutput$token = "<your-personal-access-token>"
$me = "gmcnickle"
$prs = Get-ExpressionCache -ScriptBlock {
param($user, $token)
Invoke-RestMethod "https://api.github.com/search/issues?q=assignee:$user+is:pr+is:open" `
-Headers @{ Authorization = "Bearer $token"; "User-Agent" = "ExpressionCacheDemo" }
} -Arguments $me, $token
$prs.items | Select-Object number, title, state$key = "user-profile-42"
$userProfile = Get-ExpressionCache -Key $key -ScriptBlock {
param($userId, $timestamp) # timestamp doesn’t affect cache key
Invoke-RestMethod "https://api.example.com/users/$userId"
} -Arguments 42, (Get-Date)
$userProfileCore
Initialize-ExpressionCache -AppName <string> [-Providers <IDictionary>] [-ReplaceProviders]Get-ExpressionCache -ScriptBlock <scriptblock> [-Arguments <object[]>] [-Key <string>] [-ProviderName <string>] [-MaxAge <timespan>]Clear-ExpressionCache [-ProviderName <string>] [-Force]New-ExpressionCacheKey -ScriptBlock <scriptblock> [-Arguments <object[]>]
Provider Management
Add-ExpressionCacheProvider -Provider <hashtable>Get-ExpressionCacheProvider [-ProviderName <string>]Remove-ExpressionCacheProvider -ProviderName <string> [-PassThru]
Provider Authoring
Get-ProviderConfig -Provider <object> [-Raw]Set-ProviderConfig -Provider <object> -NewConfig <hashtable>Get-ProviderStateValue -Provider <object> [-Key <string>] [-Default <object>]Set-ProviderStateValue -Provider <object> -Key <string> -Value <object>Set-ProviderStateValues -Provider <object> -Patch <hashtable> [-NonAtomic]Invoke-ProviderLockedOperation -Provider <object> -Operation <scriptblock>
Commands that accept -ProviderName also accept -Name as an alias. A specifically requested provider that is not registered writes an ObjectNotFound error; use -ErrorAction SilentlyContinue for optional lookup.
A provider in ExpressionCache is represented as a hashtable (or PSCustomObject) with the following shape:
@{
Name = '<unique-provider-name>'
Config = @{
<key> = <value> # Provider-specific configuration values
}
GetOrCreate = 'Get-ProviderCachedValue'
Initialize = 'Initialize-Provider' # optional
ClearCache = 'Clear-ProviderCache' # optional
Teardown = 'Close-Provider' # optional
}- Name: Friendly identifier, unique within your cache session.
- Config: Provider settings passed by name when they match a hook command parameter.
- GetOrCreate: Required command-name string. The command must declare
KeyandScriptBlock. - Initialize, ClearCache, and Teardown: Optional command-name strings.
Hook values must be command names, not scriptblocks. Named commands allow ExpressionCache to inspect parameters, pass only supported values, and report contract errors during registration.
For example:
function Initialize-ExampleProvider {
[CmdletBinding()]
param(
[string]$ProviderName,
[TimeSpan]$DefaultMaxAge,
[string]$Endpoint
)
# initialization logic
}A provider object for this might look like:
@{
Name = 'Example'
Config = @{
ProviderName = 'Example'
DefaultMaxAge = (New-TimeSpan -Hours 1)
Endpoint = 'https://cache.example.test'
}
Initialize = 'Initialize-ExampleProvider'
GetOrCreate = 'Get-ExampleCachedValue'
}When Initialize-ExpressionCache is called, ExpressionCache will invoke:
Initialize-ExampleProvider -ProviderName 'Example' -DefaultMaxAge <timespan> -Endpoint 'https://cache.example.test'The included Redis provider is a fully functional, dependency-free implementation built on raw TCP sockets and the RESP protocol. It serves as both a working provider for single-instance Redis and a reference implementation for building complex providers.
What it includes:
- Native RESP protocol client (no external modules required)
- Lazy, thread-safe client initialization with single-flight gating
- Distributed per-key locking to prevent duplicate cache-miss computation across processes
- TTL-based expiration with sliding expiry support
- Per-key metadata tracking (query description + timestamp)
- Envelope-based serialization (JSON with CliXml fallback, gzip for large payloads)
SCAN-based cache clearing (production-safe, noKEYS *)- Optional
AUTHandSELECTfor password-protected and multi-database setups - Debug logging via
$env:EXPRCACHE_DEBUG_REDIS
What it intentionally omits (and where a production-grade provider might extend):
- Connection pooling or automatic reconnect
- Command pipelining/batching
- Cluster, Sentinel, or replica support
function Initialize-Redis-Cache {
param(
[string]$HostAddress,
[int]$Port,
[string]$Password
)
# initialization logic
}Provider config overrides (merged with defaults):
@{
Name = 'Redis'
Config = @{
HostAddress = '127.0.0.1'
Port = 6379
Database = 2
Password = '' # set $env:EXPRCACHE_REDIS_PASSWORD or pass explicitly
WaitSeconds = 10 # maximum time to wait for another worker computing the same key
LockSeconds = 300 # lock lifetime; increase for computations that may exceed five minutes
}
}WaitSeconds and LockSeconds are optional and use the standard provider configuration pattern. Most users do not need to change them. Redis uses temporary <cache-key>:__lock entries while coordinating a cache miss.
The Redis provider is designed as a reference implementation that demonstrates the full provider contract. It is suitable for development and single-instance Redis deployments. For high-availability or clustered Redis, consider building a provider on top of a dedicated Redis client library.
- Validation: Add
ValidateNotNullOrEmptyor similar attributes to your parameters to ensure Config values are valid. - Defaults: Parameters can have defaults (e.g.,
[int]$Port = 6379). - Extensibility: Additional fields in
Configwill be ignored unless matched by a parameter. - Versioning: When updating a provider, keep parameter names stable to avoid breaking Config contracts.
Providers expose one required hook and up to three optional lifecycle hooks. ExpressionCache wires these up from the provider descriptor and passes matching Config entries as named parameters.
Purpose: Build provider-specific state (clients, prefixes, folders), validate config, and mark the provider ready.
Call pattern: ExpressionCache calls this once per provider when Initialize-ExpressionCache runs.
Parameter binding: Values from Provider.Config are passed as named parameters.
Example — provider initialization
function Initialize-ExampleProvider {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ProviderName,
[TimeSpan]$DefaultMaxAge = (New-TimeSpan -Hours 1),
[string]$Endpoint
)
# Validate configuration and prepare provider resources.
}Purpose: Return a cached value for Key if fresh; otherwise compute via ScriptBlock + Arguments, persist, and return.
Required parameters: Key, ScriptBlock.
Standard optional parameters: ProviderName, Arguments, Policy, and CacheVersion. A provider only needs to declare the optional parameters it uses. Provider-specific parameters may also be populated from Config.
Return: The computed or cached value.
Example — LocalFileSystem
function Get-LocalFileSystem-CachedValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Key,
[Parameter(Mandatory)][string] $ProviderName,
[Parameter(Mandatory)][scriptblock] $ScriptBlock,
[Alias('ArgumentList')][object[]] $Arguments,
[Parameter(Mandatory)][string] $CacheFolder,
[Parameter(Mandatory)] $Policy,
[Parameter(Mandatory)][string] $CacheVersion
)
$response = Get-FromLocalFileSystem -Key $Key -CacheFolder $CacheFolder -CacheVersion $CacheVersion -Policy $Policy
if ($null -eq $response) {
$Arguments = if ($Arguments) { $Arguments } else { @() }
$response = & $ScriptBlock @Arguments
Set-ToLocalFileSystem -Key $Key -Value $response -CacheFolder $CacheFolder -CacheVersion $CacheVersion
}
return $response
}Requirements
- Do not cache exceptions.
- Enforce TTL/version rules.
- Use deterministic keys.
- Choose robust serialization (CLIXML, JSON, etc.).
Purpose: Remove all cached entries owned by the provider. Used by Clear-ExpressionCache.
Example — LocalFileSystem
function Clear-LocalFileSystem-Cache {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$ProviderName,
[switch]$Force
)
if ($PSCmdlet.ShouldProcess("LocalFileSystemCache:$ProviderName","Clear cache")) {
Remove-LocalFS-All -Force:$Force
}
}Purpose: Release provider resources before Remove-ExpressionCacheProvider removes the provider. This hook is optional.
function Close-ExampleProvider {
param([string]$ProviderName)
# Dispose clients, close streams, or release other provider resources.
}Teardown failures are written as verbose diagnostics and do not prevent provider removal.
- Parameter names = Config keys. Keep them stable across versions.
- Thread safety. Ensure provider state is safe across multiple calls.
- Idempotence. Multiple init calls should not corrupt state.
- Key versioning. Use
CacheVersionor key segments to avoid mixing formats.
$provider = [ordered]@{
Name = 'LocalFileSystemCache'
Description = 'Stores cached values in the local file system.'
Version = '1.0.0'
Config = [ordered]@{
ProviderName = 'LocalFileSystemCache'
CacheFolder = "$env:LOCALAPPDATA\ExpressionCache\MyApp"
DefaultMaxAge = (New-TimeSpan -Days 1)
CacheVersion = '1'
JsonDepth = 10
}
Initialize = 'Initialize-LocalFileSystem-Cache'
GetOrCreate = 'Get-LocalFileSystem-CachedValue'
ClearCache = 'Clear-LocalFileSystem-Cache'
}
Add-ExpressionCacheProvider -Provider $providerProviders included today:
- LocalFileSystemCache – file-based persistence with cross-process coordination for same-key cache misses.
- Redis – dependency-free Redis provider with native RESP protocol support, suitable for single-instance deployments and as a reference for building custom remote providers.
What’s an executor?
In ExpressionCache, an executor is the ScriptBlock you pass to Get-ExpressionCache (or a provider’s Get-OrCreate-* function). It is the unit of work that computes a value on a cache miss. The executor should be a pure, parameterized function of its inputs.
- Cache stability — the cache key derives from the executor + arguments.
- 1.x key compatibility — the automatic key algorithm is stable throughout the 1.x release line. An incompatible algorithm change requires a new major version.
- Isolation — avoids closure/scope bugs from
$script:or$global:. - Testability — parameterized executors are easier to reason about.
$executor = {
param([string]$RepoPath,[int]$Limit=100)
Get-ChildItem -LiteralPath $RepoPath -Recurse -File |
Select-Object -First $Limit |
ForEach-Object { $_.FullName }
}
Get-ExpressionCache -Key 'repo:files:v1' -ScriptBlock $executor -Arguments @('C:\src\myrepo',200)# ❌ Capturing outer vars
$repoPath='C:\src\myrepo'
$executor={ Get-ChildItem -LiteralPath $repoPath -Recurse -File }
# ❌ Using global/script scope
$script:RepoPath='C:\src\myrepo'
$executor={ Get-ChildItem -LiteralPath $script:RepoPath -Recurse -File }- Pass time, randomness, or env state as parameters.
- Return simple, serializable objects.
- Throw on real failures.
- Version your keys.
Pass values via parameters for better cache stability:
Get-ExpressionCache -ScriptBlock {
param($userId, $token)
Invoke-RestMethod "https://api.example.com/users/$userId" -Headers @{ Authorization = "Bearer $token" }
} -Arguments 42, $tokenExplicit key control:
$key = 'users/42'
Get-ExpressionCache -Key $key -ScriptBlock { param($id,$state) Get-User $id } -Arguments 42, $state# Run all tests (Pester v5+)
pwsh ./tests/run-tests.ps1Tests run on PowerShell 5.1 and 7.x via GitHub Actions (Windows + Linux).
The suite covers:
- cache hits/misses
- expiry and version invalidation
- error paths and key stability
- thread safety and concurrent access (PS 7+)
- provider lifecycle (add, remove, config, state)
- locking semantics and lock release on error
- provider state management (atomic and non-atomic)
src/
ExpressionCache.psd1
ExpressionCache.psm1
Public/
*.ps1 # Exported functions
Providers/
LocalFileSystem.ps1
RedisCache.ps1
Utilities/
*.ps1
tests/
Add-ExpressionCacheProvider.Tests.ps1
Get-ExpressionCache.Tests.ps1
Get-ExpressionCacheProvider.Tests.ps1
ProviderStateAndConfig.Tests.ps1
Remove-ExpressionCacheProvider.Tests.ps1
Set-ProviderConfig.Tests.ps1
Set-ProviderStateValues.Tests.ps1
Invoke-ProviderLockedOperation.Tests.ps1
PublicContract.Tests.ps1
support/
Common.ps1
run-tests.ps1
- Ease of use: cache results from any expression with minimal config.
- Single source of truth: provider settings live in
Config. - Explicit execution: call sites pass a scriptblock; providers choose how to cache.
- Thread-safe: synchronized provider state and cross-process same-key coordination for built-in persistent providers.
- Safety: avoids
Invoke-Expression; favors parameters over ambient variables. - Extensible: add providers (Redis, S3, memory, …) by implementing
GetOrCreate.
ExpressionCache is designed to be provider-agnostic. You can add new providers for any storage backend with minimal effort.
-
Create a provider object with:
Name,Description,VersionConfig(settings like paths, TTL, version tags)GetOrCreate(required)Initialize(optional)ClearCache(optional but recommended)Teardown(optional)
-
Implement the provider functions (see Provider Function Contracts):
Initialize-<ProviderType>— setup and validate configGetOrCreate— return or compute/persist valuesClear-<ProviderType>-Cache— clear entries safelyClose-<ProviderType>— release resources before removal
-
Register:
Add-ExpressionCacheProvider -Provider $myProvider- Use:
Get-ExpressionCache -ProviderName $myProvider.Name -ScriptBlock { ... }Providers included:
- LocalFileSystemCache – file-based, zero dependencies, with cross-process same-key coordination
- Redis – native RESP protocol, zero dependencies, reference implementation for remote/shared caching
The default Redis prefix includes the module major version. Moving from 0.x to 1.x therefore starts a fresh default Redis cache namespace (v0 to v1) unless you configure Prefix explicitly.
Potential extensions:
- In-memory cache (see
samples/implementing-yourown-provider/) - Cloud-backed (S3, Azure Blob)
- Database-backed (SQL, SQLite)
We’d love to see how you’re using ExpressionCache in your own projects!
Visit our GitHub Discussions and share:
- Interesting caching scenarios you’ve solved
- Creative providers (custom backends)
- Integration into developer workflows (CI/CD, data collection, API batching, etc.)
Your use case might inspire the next feature or best practice.
Contributions are welcome! Whether it’s a bug fix, a new provider, or an idea for improvement:
-
Fork the repo and create your branch from main.
-
Add tests for new features or fixes (we use Pester v5+).
-
Run the test suite locally with ./tests/run-tests.ps1.
-
Submit a pull request with a clear description of the changes.
Please follow the existing coding style and structure (functions in src/, tests in tests/). For larger features, consider opening a Discussion first to align on direction.
- Code: All source code files (e.g., .ps1, .py) in this repository are licensed under the MIT License. If you use these scripts, a shout-out to Gary McNickle and this repository is appreciated but not required.
- Non-Code Content: All documentation, images, and written content (e.g., .md, .jpg, .txt) are licensed under the Creative Commons Attribution 4.0 International Public License. Please attribute as: "© Gary McNickle 2025, licensed under CC BY 4.0 International" with a link to https://creativecommons.org/licenses/by/4.0/.
- Other Files: Any files not explicitly categorized (e.g., .json, .yml) are licensed under CC BY 4.0 unless otherwise noted.
- Contributions: By contributing to this repository, you agree to license your code under the MIT License and non-code contributions under CC BY 4.0.
Primary Author: Gary McNickle (gmcnickle@outlook.com)
Co-Author & Assistant: ChatGPT (OpenAI)
This script was collaboratively designed and developed through interactive sessions with ChatGPT, combining human experience and AI-driven support to solve real-world development challenges.