Skip to content

ExpressionCache

ExpressionCache Logo

License: MIT PSGallery Version PSGallery Downloads

PowerShell 7 Tests PowerShell 5.1 Tests Linux Tests (PowerShell 7)

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.

Table of Contents

Install

From the PowerShell Gallery:

Install-Module ExpressionCache -Scope CurrentUser

For local development:

# From repo root
Import-Module "$PSScriptRoot/src/ExpressionCache.psd1" -Force

Requires PowerShell 5.1+ (works great on 7.x).

Quick start

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
# -> 3

Prefer param(...) over ambient variables inside scriptblocks.
If you must capture outer vars, use a closure: { Get-Content $file }.GetNewClosure().

💡 Examples

1. Simple — Recursive directory search

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.

2. Developer workflow — git pull

$gitOutput = Get-ExpressionCache -ScriptBlock {
    git -C "C:\Projects\ExpressionCache" pull
}

$gitOutput

3. Developer workflow — GitHub API (all PRs assigned to me)

$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

4. Explicit keying — Stable results, mutable args

$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)

$userProfile

Public API

Core

  • 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.

Providers

Provider Object Shape and Initialization

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 Key and ScriptBlock.
  • 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'

Example: Redis Provider

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, no KEYS *)
  • Optional AUTH and SELECT for 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.

Notes for Provider Authors

  • Validation: Add ValidateNotNullOrEmpty or 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 Config will be ignored unless matched by a parameter.
  • Versioning: When updating a provider, keep parameter names stable to avoid breaking Config contracts.

Provider Function 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.

1) Initialize-<ProviderType>

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.
}

2) GetOrCreate

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.).

3) Clear-<ProviderType>-Cache

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
    }
}

4) Teardown

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.

Best Practices

  • 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 CacheVersion or key segments to avoid mixing formats.

Minimal provider object tying it together

$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 $provider

Providers 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.

Writing Executors

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.

Why parameterize your executor?

  • 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.

Recommended executor

$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)

Anti-patterns (avoid)

# ❌ 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 }

Practical tips

  • 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, $token

Explicit key control:

$key = 'users/42'
Get-ExpressionCache -Key $key -ScriptBlock { param($id,$state) Get-User $id } -Arguments 42, $state

🧪 Tests

# Run all tests (Pester v5+)
pwsh ./tests/run-tests.ps1

Tests 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)

Project layout

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

✨ Design highlights

  • 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.

Extensibility

ExpressionCache is designed to be provider-agnostic. You can add new providers for any storage backend with minimal effort.

  1. Create a provider object with:

    • Name, Description, Version
    • Config (settings like paths, TTL, version tags)
    • GetOrCreate (required)
    • Initialize (optional)
    • ClearCache (optional but recommended)
    • Teardown (optional)
  2. Implement the provider functions (see Provider Function Contracts):

    • Initialize-<ProviderType> — setup and validate config
    • GetOrCreate — return or compute/persist values
    • Clear-<ProviderType>-Cache — clear entries safely
    • Close-<ProviderType> — release resources before removal
  3. Register:

Add-ExpressionCacheProvider -Provider $myProvider
  1. 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)

Community & Show and Tell

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.

Contributing

Contributions are welcome! Whether it’s a bug fix, a new provider, or an idea for improvement:

  1. Fork the repo and create your branch from main.

  2. Add tests for new features or fixes (we use Pester v5+).

  3. Run the test suite locally with ./tests/run-tests.ps1.

  4. 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.

Licensing

  • 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.

Credits

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.

GitHub LinkedIn ORCID RogueCivilian

About

Lightweight, pluggable caching for PowerShell expressions (scriptblocks). You provide a { ... } to run (plus optional args); a storage provider handles lookup, freshness, and persistence.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages