Make the e2e write phase work live across all five clients

Running the matrix against a live gateway surfaced several issues:

- The write phase is now opt-in (-VerifyWrite, was -SkipWrite). It runs
  right after register so only a small event backlog precedes the write,
  and asserts the reliable OnWriteComplete signal (the written value is
  not echoed back by a provider-driven attribute like TestChangingInt, so
  the value compare is best-effort).
- Java was launched as bare "gradle", which .NET's Process.Start cannot
  exec (it is gradle.bat) — resolve the launcher and run it via cmd.exe.
- The Java client's MxEventStream queue capacity was 16, which overflows
  on any active session's backlog-replay burst; raised to 1024.
- The Rust stream-events CLI now renders the event family as the proto
  enum name, matching the protobuf-JSON the other four clients emit.

Update docs/GatewayTesting.md for the reworked write phase.

Verified live: the full five-client matrix passes with -VerifyWrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-19 14:45:47 -04:00
parent 06030dd1ef
commit 758aca2355
4 changed files with 168 additions and 95 deletions
+127 -76
View File
@@ -33,8 +33,10 @@ param(
[int]$BulkTagCount = 6,
[switch]$SkipStream,
[switch]$SkipBulk,
# Write round-trip + value assertion.
[switch]$SkipWrite,
# Write round-trip. Opt-in because it mutates live tag state: it writes a
# sentinel value to -WriteAttribute and asserts an OnWriteComplete event
# confirms the write reached the MXAccess provider.
[switch]$VerifyWrite,
[string]$WriteAttribute = "TestChangingInt",
[string]$WriteType = "int32",
[int]$WriteValueBase = 424200,
@@ -335,6 +337,14 @@ function Get-EventItemHandle {
return [int]$handle
}
# Extracts the event family as a string. All five CLIs render it as the
# protobuf enum name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE).
function Get-EventFamily {
param([object]$Event)
return [string](Get-PropertyValue -Object $Event -Names @("family"))
}
# Extracts the scalar payload from a streamed event's MxValue as a string.
# The MxValue oneof renders to one protobuf-JSON `*Value` key; all five
# CLIs (after the Rust stream-events extension) emit the same key names.
@@ -595,7 +605,20 @@ function Get-ClientCommand {
$cliArgs += @("--session-id", $Values.sessionId)
}
$arguments = @("--quiet", ":mxgateway-cli:run", "--args=$($cliArgs -join ' ')")
return [pscustomobject]@{ file = "gradle"; args = $arguments; cwd = (Join-Path $repoRoot "clients/java"); env = @{} }
# Gradle ships as gradle.bat on Windows; .NET's Process.Start
# (UseShellExecute=false) cannot launch a batch file directly, so
# resolve the launcher and run it through cmd.exe.
$gradleCommand = Get-Command "gradle.bat", "gradle.cmd", "gradle.exe", "gradle" `
-ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $gradleCommand) {
throw "The 'gradle' command was not found on PATH; the Java client e2e flow requires Gradle."
}
return [pscustomobject]@{
file = "cmd.exe"
args = @("/c", $gradleCommand.Source) + $arguments
cwd = (Join-Path $repoRoot "clients/java")
env = @{}
}
}
}
}
@@ -627,23 +650,30 @@ function Get-DryRunReply {
return [pscustomobject]@{ unsubscribeBulk = [pscustomobject]@{ results = $results }; results = $results }
}
"stream-events" {
# Echo the requested write value back so the write round-trip
# assertion passes under -DryRun. The reply is shaped per client:
# Go and Java emit one event object per line (Read-JsonObject
# collapses NDJSON to a bare array), the others aggregate the
# events under an `events` property.
# Synthesize an OnDataChange (carrying the written value) and an
# OnWriteComplete so the write round-trip assertion passes under
# -DryRun. The reply is shaped per client: Go and Java emit one
# event object per line (Read-JsonObject collapses NDJSON to a
# bare array), the others aggregate the events under `events`.
$itemHandle = if ($Values.ContainsKey("echoItemHandle")) { [int]$Values.echoItemHandle } else { 1 }
$echoValue = if ($Values.ContainsKey("echoValue")) { $Values.echoValue } else { 1 }
$event = [pscustomobject]@{
$dataEvent = [pscustomobject]@{
workerSequence = 1
family = "MX_EVENT_FAMILY_ON_DATA_CHANGE"
itemHandle = $itemHandle
value = [pscustomobject]@{ int32Value = $echoValue }
}
$writeCompleteEvent = [pscustomobject]@{
workerSequence = 2
family = "MX_EVENT_FAMILY_ON_WRITE_COMPLETE"
itemHandle = $itemHandle
}
$events = @($dataEvent, $writeCompleteEvent)
switch ($Client) {
"go" { return ,@($event) }
"java" { return ,@($event) }
"rust" { return [pscustomobject]@{ eventCount = 1; events = @($event) } }
default { return [pscustomobject]@{ events = @($event) } }
"go" { return ,$events }
"java" { return ,$events }
"rust" { return [pscustomobject]@{ eventCount = $events.Count; events = $events } }
default { return [pscustomobject]@{ events = $events } }
}
}
default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } }
@@ -735,6 +765,83 @@ function Invoke-ClientFlow {
$serverHandle = Get-ServerHandle -Client $Client -Json $registerJson
$clientResult.serverHandle = $serverHandle
# --- Write round-trip + value assertion ---------------------------
# Runs right after register, before the bulk and add-item phases, so
# only a small backlog of events precedes the write. The gateway
# replays the per-session event buffer from the start, so the
# post-write OnWriteComplete must be reachable within the bounded
# -WriteEchoMaxEvents window.
if ($VerifyWrite) {
$writeTag = @($Tags | Where-Object {
$_.attributeName -eq $WriteAttribute
}) | Select-Object -First 1
if ($null -eq $writeTag) {
Write-Warning "$Client write phase skipped: no discovered tag has attribute '$WriteAttribute'."
} else {
$writeAddJson = Invoke-ClientOperation -Client $Client -Operation "add-item" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
item = $writeTag.fullTagReference
}
$writeItemHandle = Get-ItemHandle -Client $Client -Json $writeAddJson
Invoke-ClientOperation -Client $Client -Operation "advise" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $writeItemHandle
} | Out-Null
$sentinelValue = "$($WriteValueBase + $script:clientFlowIndex)"
Invoke-ClientOperation -Client $Client -Operation "write" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $writeItemHandle
valueType = $WriteType
value = $sentinelValue
} | Out-Null
$writeStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{
sessionId = $sessionId
maxEvents = $WriteEchoMaxEvents
echoItemHandle = $writeItemHandle
echoValue = $sentinelValue
}
$writeEvents = @(Get-StreamEvents -Client $Client -Json $writeStreamJson)
$writeItemEvents = @($writeEvents | Where-Object {
(Get-EventItemHandle -Event $_) -eq $writeItemHandle
})
# The reliable write round-trip signal: MXAccess fires
# OnWriteComplete once the write reaches the provider. The
# value echo is best-effort — a provider-driven attribute
# (e.g. a simulated counter) accepts the write but does not
# hold the value, so no OnDataChange carries it back.
$writeCompleteEvent = $writeItemEvents | Where-Object {
(Get-EventFamily -Event $_) -match "WRITE_COMPLETE"
} | Select-Object -First 1
$echoEvent = $writeItemEvents | Where-Object {
Test-ValueEquals -Expected $sentinelValue -Observed (Get-EventScalar -Event $_)
} | Select-Object -First 1
if ($null -eq $writeCompleteEvent) {
throw ("$Client write round-trip failed: wrote $WriteType=$sentinelValue to " +
"'$($writeTag.fullTagReference)' (item handle $writeItemHandle) but no " +
"OnWriteComplete event was observed in $($writeEvents.Count) streamed event(s). " +
"Increase -WriteEchoMaxEvents, or drop -VerifyWrite.")
}
$clientResult.write = [ordered]@{
attributeName = $WriteAttribute
fullTagReference = $writeTag.fullTagReference
itemHandle = $writeItemHandle
valueType = $WriteType
value = $sentinelValue
writeCompleteObserved = $true
echoObserved = ($null -ne $echoEvent)
}
}
}
if (-not $SkipBulk) {
$bulkTags = @($Tags | Select-Object -First ([Math]::Min($BulkTagCount, $Tags.Count)))
$bulkItems = ($bulkTags | ForEach-Object { $_.fullTagReference }) -join ","
@@ -788,71 +895,15 @@ function Invoke-ClientFlow {
}
}
# --- Write round-trip + value assertion ---------------------------
# Write a per-client sentinel value to a configured writable
# attribute, then assert it is echoed back through the event stream.
$writeTarget = $null
if (-not $SkipWrite) {
$writeTarget = @($clientResult.addedItems | Where-Object {
$_.attributeName -eq $WriteAttribute
}) | Select-Object -First 1
}
$doWrite = $null -ne $writeTarget
$sentinelValue = $null
if ($doWrite) {
$sentinelValue = "$($WriteValueBase + $script:clientFlowIndex)"
Invoke-ClientOperation -Client $Client -Operation "write" -Values @{
# --- Event streaming ----------------------------------------------
if (-not $SkipStream) {
$streamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $writeTarget.itemHandle
valueType = $WriteType
value = $sentinelValue
} | Out-Null
} elseif (-not $SkipWrite) {
Write-Warning "$Client write phase skipped: no discovered tag has attribute '$WriteAttribute'."
}
# --- Event streaming (also serves the write echo assertion) -------
$captureEvents = (-not $SkipStream) -or $doWrite
if ($captureEvents) {
$streamValues = @{ sessionId = $sessionId }
if ($doWrite) {
$streamValues.maxEvents = $WriteEchoMaxEvents
$streamValues.echoItemHandle = $writeTarget.itemHandle
$streamValues.echoValue = $sentinelValue
}
$streamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values $streamValues
$events = @(Get-StreamEvents -Client $Client -Json $streamJson)
$clientResult.eventCount = Get-StreamEventCount -Client $Client -Json $streamJson
if (-not $SkipStream -and $clientResult.eventCount -lt 1) {
if ($clientResult.eventCount -lt 1) {
throw "The $Client stream-events command returned no events."
}
if ($doWrite) {
$echoEvent = $events | Where-Object {
(Get-EventItemHandle -Event $_) -eq $writeTarget.itemHandle -and
(Test-ValueEquals -Expected $sentinelValue -Observed (Get-EventScalar -Event $_))
} | Select-Object -First 1
if ($null -eq $echoEvent) {
throw ("$Client write round-trip failed: wrote $WriteType=$sentinelValue to " +
"'$($writeTarget.fullTagReference)' (item handle $($writeTarget.itemHandle)) " +
"but no matching value was observed in $($events.Count) streamed event(s). " +
"Increase -WriteEchoMaxEvents, point -WriteAttribute at a writable attribute, or pass -SkipWrite.")
}
$clientResult.write = [ordered]@{
attributeName = $WriteAttribute
fullTagReference = $writeTarget.fullTagReference
itemHandle = $writeTarget.itemHandle
valueType = $WriteType
value = $sentinelValue
echoObserved = $true
echoWorkerSequence = (Get-PropertyValue -Object $echoEvent -Names @("workerSequence", "worker_sequence"))
}
}
}
# --- Error-path (parity) checks -----------------------------------
@@ -965,7 +1016,7 @@ function Get-ChildArgumentList {
}
if ($SkipStream) { $childArgs += "-SkipStream" }
if ($SkipBulk) { $childArgs += "-SkipBulk" }
if ($SkipWrite) { $childArgs += "-SkipWrite" }
if ($VerifyWrite) { $childArgs += "-VerifyWrite" }
if ($SkipParity) { $childArgs += "-SkipParity" }
if ($SkipAuth) { $childArgs += "-SkipAuth" }
if ($DryRun) { $childArgs += "-DryRun" }
@@ -1043,7 +1094,7 @@ if ($Parallel -and $Clients.Count -gt 1) {
bulkTagCount = $BulkTagCount
skipStream = [bool]$SkipStream
skipBulk = [bool]$SkipBulk
skipWrite = [bool]$SkipWrite
verifyWrite = [bool]$VerifyWrite
skipParity = [bool]$SkipParity
skipAuth = [bool]$SkipAuth
writeAttribute = $WriteAttribute
@@ -1101,7 +1152,7 @@ $run = [ordered]@{
bulkTagCount = $BulkTagCount
skipStream = [bool]$SkipStream
skipBulk = [bool]$SkipBulk
skipWrite = [bool]$SkipWrite
verifyWrite = [bool]$VerifyWrite
skipParity = [bool]$SkipParity
skipAuth = [bool]$SkipAuth
writeAttribute = $WriteAttribute