Providers/RedisCache.ps1
# Providers/RedisCacheProvider.Light.ps1 using namespace System.Net.Sockets using namespace System.Text $Global:debugRedis = $true $Global:RedisDebugLog = "$env:LOCALAPPDATA\redis_debug.log" function Write-RedisLog { param([string]$msg) if (-not $Global:debugRedis) { return } $timestamp = (Get-Date).ToString("HH:mm:ss.fff") Add-Content -Path $Global:RedisDebugLog -Value "$timestamp - $msg" } # -- Client constructor ------------------------------------------------------- function New-RedisClient { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory)] $Provider, [Parameter(Mandatory)] [string]$HostAddress, [Parameter(Mandatory)] [ValidateRange(1, 65535)] [int]$Port, [ValidateRange(0, [int]::MaxValue)] [int]$Database = 0, [string]$Prefix = 'ExpressionCache:v1', [string]$Password ) if ([string]::IsNullOrWhiteSpace($HostAddress)) { throw "HostAddress must be a non-empty string." } $target = "$($HostAddress):$Port (DB=$Database, Prefix='$Prefix')" if (-not $PSCmdlet.ShouldProcess($target, "Open Redis connection")) { return $null # honor -WhatIf / declined -Confirm } $client = $null $stream = $null try { $client = [System.Net.Sockets.TcpClient]::new() $client.NoDelay = $true $client.Connect($HostAddress, $Port) $stream = $client.GetStream() $ctx = [ordered]@{ Client = $client Stream = $stream Prefix = $Prefix Db = $Database Host = $HostAddress Port = $Port } if ($Password) { Invoke-RedisRaw -Context $ctx -Arguments @('AUTH', $Password) -Provider $Provider | Out-Null } if ($Database -gt 0) { Invoke-RedisRaw -Context $ctx -Arguments @('SELECT', $Database.ToString()) -Provider $Provider | Out-Null } $pong = Invoke-RedisRaw -Context $ctx -Arguments @('PING') -Provider $Provider if ($pong -ne 'PONG') { throw "Redis PING failed: $pong" } # success -> return the live context return [pscustomobject]$ctx } catch { # ensure cleanup on failure try { if ($stream) { $stream.Dispose() } } catch {} try { if ($client) { $client.Dispose() } } catch {} throw } } # -- Public commands used by provider ---------------------------------------- function Initialize-Redis-Cache { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ProviderName, [Parameter(Mandatory)] [timespan]$DefaultMaxAge, [string]$HostAddress = '127.0.0.1', [int]$Port = 6379, [int]$Database = 2, [string]$Prefix = 'ExpressionCache:v1', [string]$Password = "", [bool]$DeferClientCreation = $true ) $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName if (-not $provider) { throw "Provider '$ProviderName' not found." } if ($provider.State.Initialized) { # Write-Warning "RedisCache: Provider already initialized." return } $state = [PSCustomObject]@{ Client = $null SyncRoot = [object]::new() } $null = $provider | Set-ECProperty -Name 'State' -Value $state -DontEnforceType if (-not $DeferClientCreation) { $client = New-RedisClient -Provider $provider -HostAddress $HostAddress -Port $Port -Database $Database -Password $Password -Prefix $Prefix $provider.State.Client = $client } } function Resolve-RedisClient { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ProviderName ) $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName if (-not $provider) { throw "Provider '$ProviderName' not found." } $state = $provider.State $client = $state.Client $config = $provider.Config # Optional: global/env override to disable redis (useful in CI) if ($env:EC_DISABLE_REDIS) { return $null, $provider } if ($client) { return $client, $provider } $defer = ($config -and $config.ContainsKey('DeferClientCreation') -and [bool]$config['DeferClientCreation']) $strict = ($config -and $config.ContainsKey('Strict') -and [bool]$config['Strict']) if (-not $defer) { throw "No Redis client initialized for provider '$ProviderName'." } # Provider-scoped sync object to avoid double-create if (-not $state.Sync) { $state | Set-ECProperty -Name 'Sync' -Value (New-Object object) -DontEnforceType } lock ($state.Sync) { if ($state.Client) { return $state.Client, $provider } # someone else won the race if (-not $state.ClientCreationAttempted) { $state | Set-ECProperty -Name 'ClientCreationAttempted' -Value $true -DontEnforceType try { $newClient = New-RedisClient ` -Host $config['Host'] ` -Port $config['Port'] ` -Database $config['Database'] ` -Prefix $config['Prefix'] ` -Password $config['Password'] $state | Set-ECProperty -Name 'Client' -Value $newClient -DontEnforceType $state | Set-ECProperty -Name 'Initialized' -Value $true -DontEnforceType $state | Set-ECProperty -Name 'LastError' -Value $null -DontEnforceType } catch { $state | Set-ECProperty -Name 'LastError' -Value $_ -DontEnforceType $state | Set-ECProperty -Name 'Initialized' -Value $false -DontEnforceType if ($strict) { throw } } } return $state.Client, $provider } } function Get-Redis-CachedValue { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProviderName, [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][scriptblock]$ScriptBlock, [object[]]$Arguments, [Parameter(Mandatory)][CachePolicy]$Policy ) try { Write-RedisLog "=== [ENTRY] Get-Redis-CachedValue ===" $client, $provider = Resolve-RedisClient -ProviderName $ProviderName $rkey = Join-RedisKey -Client $client -Key $Key $raw = Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('GET', $rkey)) # write-host "Redis GET $rkey" if ($null -ne $raw) { if ($Policy.Sliding) { Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('EXPIRE', $rkey, [string]$Policy.TtlSeconds)) | Out-Null Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('EXPIRE', "$rkey:meta", [string]$Policy.TtlSeconds)) | Out-Null } Write-RedisLog "[CACHE HIT] Key: $rkey" return (Read-CacheValue $raw) } Write-RedisLog "[CACHE MISS] Key: $rkey — computing value" if ($null -eq $Arguments) { $Arguments = @() } $result = & $ScriptBlock @Arguments if ($null -eq $result) { return $null } $payload = Write-CacheValue -Value $result Invoke-RedisRaw -Provider $provider -Context $client ` -Arguments ([object[]]@('SET', $rkey, $payload, 'EX', [string]$Policy.TtlSeconds)) | Out-Null $desc = ($ScriptBlock.ToString() -split "`r?`n" | ForEach-Object { $_.Trim() }) -join ' ' Invoke-RedisRaw -Provider $provider -Context $client ` -Arguments ([object[]]@('HSET', "$rkey:meta", 'q', $desc, 'ts', (Get-Date).ToString('o'))) | Out-Null Invoke-RedisRaw -Provider $provider -Context $client ` -Arguments ([object[]]@('EXPIRE', "$rkey:meta", [string]$Policy.TtlSeconds)) | Out-Null Write-RedisLog "[CACHE STORE] Key: $rkey" return $result } finally { Write-RedisLog "=== [EXIT] Get-Redis-CachedValue ===" } } function Clear-Redis-Cache { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProviderName, [switch]$Force ) try { Write-RedisLog "=== [ENTRY] Clear-Redis-Cache ===" $client, $provider = Resolve-RedisClient -ProviderName $ProviderName $pattern = "$($client.Prefix)*" $cursor = '0' $iterationLimit = 100 $iteration = 0 do { $resp = Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('SCAN', $cursor, 'MATCH', $pattern, 'COUNT', '1000')) if ($resp[0] -is [array]) { $nextCursor = [string]$resp[0][0] } else { $nextCursor = [string]$resp[0] } $keys = $resp[1] if ($keys -isnot [array]) { $keys = @($keys) } if ($keys.Count -eq 1 -and $keys[0] -is [array]) { $keys = $keys[0] } $keys = $keys | Where-Object { $_ -ne '0' } Write-RedisLog "[SCAN] Cursor=$cursor -> $nextCursor, KeysReturned=$($keys.Count)" if ($keys.Count -gt 0) { $chunk = $keys -join ', ' Write-RedisLog "[UNLINK] Keys: $chunk" $cmd = @('UNLINK') + $keys Invoke-RedisRaw -Provider $provider -Context $client -Arguments $cmd | Out-Null } if ($cursor -eq $nextCursor) { Write-RedisLog "[WARN] Redis SCAN returned same cursor ($cursor), breaking." break } $cursor = $nextCursor $iteration++ } while ($cursor -ne '0' -and $iteration -lt $iterationLimit) if ($iteration -ge $iterationLimit) { Write-RedisLog "[ERROR] SCAN exceeded iteration limit ($iterationLimit)." } } finally { Write-RedisLog "=== [EXIT] Clear-Redis-Cache ===" } } # -- Helpers ----------------------------------------------------------------- function Join-RedisKey { param( [Parameter(Mandatory)] [string]$Key, [Parameter(Mandatory)] [object]$Client ) if (-not $client -or [string]::IsNullOrWhiteSpace($client.Prefix)) { return $Key } return "$($client.Prefix):$Key" } function Invoke-RedisRaw { [CmdletBinding(PositionalBinding = $false)] param( [Parameter(Mandatory)] $Context, [Parameter(Mandatory)] [object[]]$Arguments, [Parameter(Mandatory)] $Provider ) if (-not $Arguments -or $Arguments.Count -eq 0) { throw "Invoke-RedisRaw: -Arguments empty." } $cmdString = ($Arguments | ForEach-Object { "'$_'" }) -join ' ' Write-RedisLog "→ Redis CMD: $cmdString" $stream = $Context.Stream if ($stream.ReadTimeout -eq 0) { $stream.ReadTimeout = 10000 } if ($stream.WriteTimeout -eq 0) { $stream.WriteTimeout = 10000 } $lockObj = $Provider.State.SyncRoot [System.Threading.Monitor]::Enter($lockObj) try { $ascii = [Text.Encoding]::ASCII $utf8 = [Text.Encoding]::UTF8 $crlf = $ascii.GetBytes("`r`n") $arrHdr = $ascii.GetBytes("*$($Arguments.Count)") $stream.Write($arrHdr, 0, $arrHdr.Length); $stream.Write($crlf, 0, 2) foreach ($it in [object[]]$Arguments) { $s = [string]$it $b = $utf8.GetBytes($s) $len = $ascii.GetBytes("`$$($b.Length)") $stream.Write($len, 0, $len.Length); $stream.Write($crlf, 0, 2) $stream.Write($b, 0, $b.Length); $stream.Write($crlf, 0, 2) } $stream.Flush() $response = Read-RedisRESP -Stream $stream Write-RedisLog "← Redis RESP: $($response | Out-String)" return $response } finally { [System.Threading.Monitor]::Exit($lockObj) } } function Read-Full { [CmdletBinding()] param( [Parameter(Mandatory)] [System.IO.Stream]$Stream, [Parameter(Mandatory)] [byte[]]$Buffer, [Parameter(Mandatory)] [ValidateRange(1, [int]::MaxValue)] [int]$Count ) if ($null -eq $Stream) { throw "Stream cannot be null." } if (-not $Stream.CanRead) { throw "Stream is not readable." } if ($Buffer.Length -lt $Count) { throw "Buffer is smaller than Count (buffer=$($Buffer.Length), count=$Count)." } # Prefer .NET's ReadExactly if available (PowerShell 7+ on .NET 6+) $readExactly = [System.IO.Stream].GetMethod('ReadExactly', [Type[]]@([byte[]], [int], [int])) if ($readExactly) { try { $Stream.ReadExactly($Buffer, 0, $Count) return $Count } catch [System.IO.EndOfStreamException] { throw "Unexpected EOF while reading $Count bytes (received fewer)." } } # Fallback loop $offset = 0 while ($offset -lt $Count) { $n = $Stream.Read($Buffer, $offset, $Count - $offset) if ($n -le 0) { throw "Unexpected EOF while reading $Count bytes (got $offset)." } $offset += $n } return $Count } function Read-RedisLine { param([System.IO.Stream]$Stream) $bytes = New-Object System.Collections.Generic.List[byte] while ($true) { $b = $Stream.ReadByte() if ($b -eq -1) { throw "Unexpected EOF while reading line." } if ($b -eq 13) { # CR $lf = $Stream.ReadByte() if ($lf -ne 10) { throw "Protocol error: expected LF after CR." } break } $bytes.Add([byte]$b) } return [Text.Encoding]::UTF8.GetString($bytes.ToArray()) } function Read-RedisRESP { param([Parameter(Mandatory)][System.IO.Stream]$Stream) $type = $Stream.ReadByte() if ($type -lt 0) { Write-RedisLog "ERROR: Disconnected (no type byte)." throw "Disconnected from Redis (no type byte)." } switch ([char]$type) { '+' { $val = Read-RedisLine -Stream $Stream Write-RedisLog "Simple string: +$val" return $val } '-' { $err = Read-RedisLine -Stream $Stream Write-RedisLog "Error: -$err" throw "Redis error: $err" } ':' { $val = [int64](Read-RedisLine -Stream $Stream) Write-RedisLog "Integer: :$val" return $val } '$' { # Bulk string $lenStr = Read-RedisLine -Stream $Stream $len = [int]$lenStr if ($len -lt 0) { Write-RedisLog "Bulk string: \$-1 (null)" return $null } $buf = New-Object byte[] $len $null = Read-Full -Stream $Stream -Buffer $buf -Count $len # discard count, we don't need it here # consume trailing CRLF $cr = $Stream.ReadByte(); $lf = $Stream.ReadByte() if ($cr -ne 13 -or $lf -ne 10) { throw "Protocol error: expected CRLF after bulk payload." } $val = [Text.Encoding]::UTF8.GetString($buf) Write-RedisLog "Bulk string: \$$len => '$val'" return $val } '*' { $cnt = [int](Read-RedisLine -Stream $Stream) if ($cnt -lt 0) { Write-RedisLog "Array: *-1 (null)" return $null } if ($cnt -eq 0) { Write-RedisLog "Array: *0 (empty)" return @() } Write-RedisLog "Array: *$cnt (start)" $arr = @() for ($i = 0; $i -lt $cnt; $i++) { $item = Read-RedisRESP -Stream $Stream Write-RedisLog " [$i] Type: $($item.GetType().Name) - Value: $item" $arr += , $item } Write-RedisLog "Array: *$cnt (end)" return $arr } default { Write-RedisLog "Unknown RESP type byte: $type ('$([char]$type)')" throw "Unknown RESP type byte: $type ('$([char]$type)')" } } } function Write-CacheValue { [CmdletBinding()] param( [Parameter(Mandatory)] $Value, [int]$JsonDepth = 100, [int]$CliXmlDepth = 5, [int]$CompressOverBytes = 4096 ) if ($null -eq $Value) { throw "Write-CacheValue called with `$null. Caller should skip caching nulls." } $typeName = $Value.GetType().AssemblyQualifiedName $fmt = 'json' try { $data = ConvertTo-Json -InputObject $Value -Compress -Depth $JsonDepth } catch { $fmt = 'clixml' $data = [System.Management.Automation.PSSerializer]::Serialize($Value, $CliXmlDepth) } $enc = 'utf8' if ([Text.Encoding]::UTF8.GetByteCount($data) -ge $CompressOverBytes) { $ms = New-Object System.IO.MemoryStream $gzip = New-Object System.IO.Compression.GZipStream($ms, [IO.Compression.CompressionLevel]::SmallestSize) $bytes = [Text.Encoding]::UTF8.GetBytes($data) $gzip.Write($bytes, 0, $bytes.Length); $gzip.Dispose() $data = [Convert]::ToBase64String($ms.ToArray()); $enc = 'gzip+base64' $ms.Dispose() } $envelope = [ordered]@{ v = 1; fmt = $fmt; enc = $enc; type = $typeName; data = $data } ConvertTo-Json $envelope -Compress -Depth 10 } function Read-CacheValue { [CmdletBinding()] param([Parameter(Mandatory)][string]$Payload) $env = $null try { $env = $Payload | ConvertFrom-Json -ErrorAction Stop } catch { } if ($null -eq $env -or $null -eq $env.v -or $null -eq $env.fmt) { return $Payload } $data = $env.data if ($env.enc -eq 'gzip+base64') { $bytes = [Convert]::FromBase64String($data) $msIn = [System.IO.MemoryStream]::new($bytes, $false) $gzip = [System.IO.Compression.GZipStream]::new($msIn, [IO.Compression.CompressionMode]::Decompress) $msOut = [System.IO.MemoryStream]::new() $buf = New-Object byte[] 8192; while (($n = $gzip.Read($buf, 0, $buf.Length)) -gt 0) { $msOut.Write($buf, 0, $n) } $gzip.Dispose(); $msIn.Dispose() $data = [Text.Encoding]::UTF8.GetString($msOut.ToArray()); $msOut.Dispose() } switch ($env.fmt) { 'json' { $data | ConvertFrom-Json } 'clixml' { [System.Management.Automation.PSSerializer]::Deserialize($data) } default { $data } } } |