Providers/RedisCache.ps1
# Providers/RedisCacheProvider.Light.ps1 using namespace System.Net.Sockets using namespace System.Text # -- 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, [string]$HostAddress = '127.0.0.1', [int]$Port = 6379, [int]$Database = 2, [string]$Prefix = 'ExpressionCache:v1', [string]$Password = "" ) $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName if (-not $provider) { throw "Provider '$ProviderName' not found." } if ($provider.State.Initialized) { # Write-Warning "RedisCache: Provider already initialized." return } $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName $state = $provider.State if (-not $state) { $state = [PSCustomObject]@{ Client = $null Initialized = $true SyncRoot = [object]::new() } } $null = $provider | Set-ECProperty -Name 'State' -Value $state -DontEnforceType $client = New-RedisClient -Provider $provider -HostAddress $HostAddress -Port $Port -Database $Database -Password $Password -Prefix $Prefix $provider.State.Client = $client } function Resolve-RedisClient { param( [Parameter(Mandatory)] [string]$ProviderName ) $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName if (-not $provider) { throw "Provider '$ProviderName' not found." } $client = $provider.State.Client if (-not $client) { throw "No client found for '$ProviderName'." } return $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 ) $client, $provider = Resolve-RedisClient -ProviderName $ProviderName $rkey = Join-RedisKey -Client $client -Key $Key # READ $raw = Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('GET', $rkey)) if ($null -ne $raw) { # Sliding: refresh TTLs on hit if ($Policy.Sliding) { [void](Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('EXPIRE', $rkey, [string]$Policy.TtlSeconds))) [void](Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('EXPIRE', "${rkey}:meta", [string]$Policy.TtlSeconds))) } return (Read-CacheValue $raw) } # MISS → compute if ($null -eq $Arguments) { $Arguments = @() } $result = & $ScriptBlock @Arguments if ($null -eq $result) { return $null } $payload = Write-CacheValue -Value $result # Write value with TTL [void](Invoke-RedisRaw -Provider $provider -Context $client ` -Arguments ([object[]]@('SET', $rkey, $payload, 'EX', [string]$Policy.TtlSeconds))) # optional metadata (query + timestamp) $desc = ($ScriptBlock.ToString() -split "`r?`n" | ForEach-Object { $_.Trim() }) -join ' ' [void](Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('HSET', "${rkey}:meta", 'q', $desc, 'ts', (Get-Date).ToString('o')))) [void](Invoke-RedisRaw -Provider $provider -Context $client -Arguments ([object[]]@('EXPIRE', "${rkey}:meta", [string]$Policy.TtlSeconds))) return $result } function Clear-Redis-Cache { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProviderName, [switch]$Force ) $client, $provider = Resolve-RedisClient -ProviderName $ProviderName $pattern = "$($client.Prefix)*" # must match Join-RedisKey behavior $cursor = '0' do { $resp = Invoke-RedisRaw -Provider $provider -Context $client ` -Arguments ([object[]]@('SCAN', $cursor, 'MATCH', $pattern, 'COUNT', '1000')) $cursor = [string]$resp[0] $keys = @($resp[1]) if ($keys.Count -gt 0) { $batchSize = 1000 for ($i = 0; $i -lt $keys.Count; $i += $batchSize) { $end = [Math]::Min($i + $batchSize - 1, $keys.Count - 1) $chunk = $keys[$i..$end] $cmd = @('UNLINK') + $chunk [void](Invoke-RedisRaw -Provider $provider -Context $client -Arguments $cmd) } } } while ($cursor -ne '0') } # -- 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." } $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() return Read-RedisRESP -Stream $stream } 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([Parameter(Mandatory)][System.IO.Stream]$Stream) # Optional: avoid hangs forever if ($Stream.ReadTimeout -eq 0) { $Stream.ReadTimeout = 10000 } # 10s $ms = New-Object System.IO.MemoryStream while ($true) { $b = $Stream.ReadByte() if ($b -lt 0) { throw "Disconnected while reading line." } if ($b -eq 13) { # CR $lf = $Stream.ReadByte() if ($lf -ne 10) { throw "Protocol error: expected LF after CR, got $lf." } break } $ms.WriteByte([byte]$b) } return [Text.Encoding]::UTF8.GetString($ms.ToArray()) } function Read-RedisRESP { param([Parameter(Mandatory)][System.IO.Stream]$Stream) $type = $Stream.ReadByte() if ($type -lt 0) { throw "Disconnected from Redis." } switch ([char]$type) { '+' { return Read-RedisLine -Stream $Stream } # Simple String '-' { $e = Read-RedisLine -Stream $Stream; throw "Redis error: $e" } # Error ':' { return [int64](Read-RedisLine -Stream $Stream) } # Integer '$' { # Bulk String $len = [int](Read-RedisLine -Stream $Stream) if ($len -lt 0) { return $null } # Null bulk $buf = New-Object byte[] $len Read-Full -Stream $Stream -Buffer $buf -Count $len # Require CRLF after bulk payload $cr = $Stream.ReadByte(); $lf = $Stream.ReadByte() if ($cr -ne 13 -or $lf -ne 10) { throw "Protocol error: expected CRLF after bulk payload." } return [Text.Encoding]::UTF8.GetString($buf) } '*' { # Array $cnt = [int](Read-RedisLine -Stream $Stream) if ($cnt -lt 0) { return $null } # Null array $arr = New-Object object[] $cnt for ($i = 0; $i -lt $cnt; $i++) { $arr[$i] = Read-RedisRESP -Stream $Stream } return $arr } default { throw "Unknown RESP type byte: $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 } } } |