TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Reliable State Caching in PowerShell with Export-Clixml (TTL, Stable Shapes, and Fast Reuse)

Caching small, immutable results is a simple way to make PowerShell automation faster, safer, and more predictable. By serializing typed objects with Export-Clixml, you can persist state across runs without re-fetching data, hammering APIs, or recomputing expensive queries. Add a time-to-live (TTL) and a stable object shape, and you get reliable state caching that is easy to reason about and trivial to reuse in CI/CD, scheduled tasks, and containerized jobs.

This post shows you how to implement a robust cache pattern with Export-Clixml, keep your data model stable with PSCustomObject, and refresh on a predictable cadence using TTL. You will also learn practical techniques for invalidation, atomic writes, and pipeline integration.

Quick Start: Cache Objects with Export-Clixml + TTL

Here is a minimal, effective pattern you can paste into any script. It stores the collected data alongside a timestamp (When) and refreshes it after a TTL expires.

$cache = Join-Path -Path (Get-Location) -ChildPath 'inventory.clixml'
$ttl   = New-TimeSpan -Hours 12

$state = if (Test-Path $cache) { Import-Clixml -Path $cache } else { $null }

if ($state -and ((Get-Date) - $state.When) -lt $ttl) {
  $data = $state.Data
  Write-Host 'Loaded from cache.'
} else {
  $data = Get-ChildItem -Path (Join-Path $env:WINDIR 'System32') -File |
    Select-Object -First 5 Name, Length
  $state = [pscustomobject]@{ When = Get-Date; Data = $data }
  $state | Export-Clixml -Path $cache
  Write-Host 'Refreshed cache.'
}

$data | Sort-Object Name | Format-Table -AutoSize

What you get:

  • Faster reruns
  • Fewer network or filesystem calls
  • Typed, reproducible state
  • Simpler scripts that are easier to test

Designing a Resilient Cache

Keep the Shape Stable with PSCustomObject

CLIXML preserves types, but your cache will be easier to evolve if you control the shape. Wrap your cached data in a PSCustomObject that includes metadata. For example:

$state = [pscustomobject]@{
  Version = 1          # bump this if the schema changes
  When    = Get-Date
  Data    = $data      # a simple, flattened payload
}

When you add properties, bump Version and handle old versions gracefully. That way, scripts remain predictable even as requirements grow.

Add Predictable Refresh with TTL

TTL-based invalidation is simple and works well for small, low-volatility datasets. Choose a TTL aligned to the freshness you need (e.g., 1–24 hours). For data tied to fast-changing systems, reduce TTL or add extra invalidation triggers (like input hashes).

Choose a Good Cache Path

  • Local development: $pwd or a .cache folder near your script
  • CI/CD: runner/workspace temp like $env:RUNNER_TEMP or $env:TEMP
  • Per-user caches: $env:LOCALAPPDATA or $env:TMPDIR on Linux/macOS
function Get-CachePath {
  param(
    [Parameter(Mandatory)] [string] $Name
  )
  $root = $env:RUNNER_TEMP, $env:TEMP, (Get-Location).Path | Where-Object { $_ } | Select-Object -First 1
  $dir  = Join-Path $root 'ps-cache'
  if (-not (Test-Path $dir)) { $null = New-Item -ItemType Directory -Path $dir }
  return (Join-Path $dir $Name)
}

Invalidate Reliably

  • TTL expiry
  • Version bump when the shape changes
  • Hash of inputs that influence the result (e.g., parameters, file contents)
function Get-CacheKeyHash {
  param([Parameter(Mandatory)] $InputObject)
  $json = $InputObject | ConvertTo-Json -Depth 10
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
  $sha   = [System.Security.Cryptography.SHA256]::Create()
  return ([System.BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
}

# Example: cache filename includes a hash of important inputs
$inputs = [pscustomobject]@{ Path = (Join-Path $env:WINDIR 'System32'); FirstN = 5 }
$key    = Get-CacheKeyHash -InputObject $inputs
$cache  = Get-CachePath -Name "inventory_$key.clixml"

Atomic Writes and Locking

Prevent partially written files by writing to a temp file and renaming. A simple lock file avoids concurrent writers in CI or parallel tasks.

function Save-ClixmlAtomic {
  param(
    [Parameter(Mandatory)] $Object,
    [Parameter(Mandatory)] [string] $Path
  )
  $temp = "$Path.tmp"
  try {
    $Object | Export-Clixml -Path $temp -Depth 5
    if (Test-Path $Path) { Remove-Item -Path $Path -Force }
    Move-Item -Path $temp -Destination $Path -Force
  } finally {
    if (Test-Path $temp) { Remove-Item $temp -Force }
  }
}

function Use-FileLock {
  param([Parameter(Mandatory)] [string] $Path, [scriptblock] $Script)
  $lock = "$Path.lock"
  while (Test-Path $lock) { Start-Sleep -Milliseconds 200 }
  New-Item -Path $lock -ItemType File -Force | Out-Null
  try { & $Script } finally { Remove-Item $lock -Force -ErrorAction SilentlyContinue }
}

Reusable Helper: Get-Cached

Turn the pattern into a function so you can cache anything with a one-liner.

function Get-Cached {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Name,
    [Parameter(Mandatory)] [TimeSpan] $Ttl,
    [Parameter(Mandatory)] [scriptblock] $Populate,
    [int] $Version = 1,
    [object] $KeyInputs
  )

  $suffix = if ($KeyInputs) { "_$(Get-CacheKeyHash -InputObject $KeyInputs)" } else { '' }
  $path   = Get-CachePath -Name ("$Name$suffix.clixml")

  $load = {
    if (Test-Path $path) {
      try { Import-Clixml -Path $path } catch { $null }
    }
  }

  $state = & $load
  $fresh = $false
  if ($state -and $state.Version -eq $Version -and ((Get-Date) - $state.When) -lt $Ttl) {
    return $state.Data
  }

  Use-FileLock -Path $path -Script {
    # Double-check inside lock
    $state = & $load
    if ($state -and $state.Version -eq $Version -and ((Get-Date) - $state.When) -lt $Ttl) {
      return $state.Data
    }

    $data = & $Populate
    $wrapped = [pscustomobject]@{ Version = $Version; When = Get-Date; Data = $data }
    Save-ClixmlAtomic -Object $wrapped -Path $path
    $script:fresh = $true
    return $data
  }

  if ($fresh) { Write-Verbose "Cache refreshed: $path" } else { Write-Verbose "Cache hit: $path" }
}

Using it:

$files = Get-Cached -Name 'sys32-top5' -Ttl (New-TimeSpan -Hours 12) -Version 1 -KeyInputs @{ Path = (Join-Path $env:WINDIR 'System32'); FirstN = 5 } -Populate {
  Get-ChildItem -Path (Join-Path $env:WINDIR 'System32') -File |
    Select-Object -First 5 Name, Length
}
$files | Sort-Object Name | Format-Table -AutoSize

DevOps and CI/CD: Where This Shines

Speeding Up Pipelines

In build agents and containers, recomputing inventories, dependency graphs, or metadata can waste minutes per run. Cache small, immutable results between steps or jobs:

  • API lookups: service endpoints, configuration catalogs, feature flags
  • Filesystem scans: tool discovery, module lists, signed file inventories
  • Build meta'svc-hosts' -Ttl $ttl -Version 1 -KeyInputs @{ Url = 'https://api.example.com/hosts?env=prod' } -Populate { Invoke-RestMethod -Uri 'https://api.example.com/hosts?env=prod' -Method Get } $svcHosts | Select-Object -First 10 Name, Ip

    Because CLIXML preserves types, you keep rich objects and avoid string parsing. In ephemeral containers, point the cache to a bind-mounted volume to persist across runs.

    Cross-Platform and Team-Friendly

    Export-Clixml works on Windows, Linux, and macOS. Store caches under a shared convention like ps-cache in the runner temp. When inputs change (e.g., different parameters or environment), the key hash gives each scenario its own file to avoid cross-talk.

    Performance, Reliability, and Security Tips

    Performance and Size Control

    • Limit depth to avoid bloated files: Export-Clixml -Depth 3..5
    • Cache only what you need: use Select-Object to project to essential fields
    • Prefer small, immutable, or slow-to-compute datasets
    • Compress externally if needed; CLIXML is plain XML but typically small for simple shapes

    Error Handling and Self-Healing

    • Wrap Import-Clixml in try/catch. If deserialization fails, treat as a cache miss and rebuild
    • Use atomic writes to avoid half-written files after crashes
    • Include Version and validate it before trusting Data
    • Implement a lightweight lock for concurrent writers

    Security Considerations

    • Do not store secrets, tokens, or PII in CLIXML caches
    • SecureString can round-trip in CLIXML with DPAPI on Windows, but it is tied to user/machine and not portable. In CI or cross-machine scenarios, use a secret store (Microsoft.PowerShell.SecretManagement) instead
    • Set permissions on cache directories when needed (e.g., icacls on Windows, chmod on Linux)
    • Treat caches as untrusted input in security-sensitive contexts; validate before use

    Schema Evolution Without Pain

    • Encapsulate your cache format in a function
    • Include a Version field and migrate or invalidate older versions automatically
    • Avoid storing complex, non-serializable types; project them into simple PSCustomObject payloads

    Putting It All Together

    Reliable state caching in PowerShell comes down to three principles:

    1. Use Export-Clixml to persist typed, stable objects
    2. Wrap data in a consistent PSCustomObject and version it
    3. Apply a TTL (and optionally input hashing) for predictable refresh

    With a few helper functions, you can add caching to any script in minutes and realize faster reruns, fewer external calls, and safer state across local development, CI/CD pipelines, and containerized jobs.

    Make caching a habit. Your future builds—and your teammates—will thank you.

    ← All Posts Home →