Skip to content

Commit 43e10bf

Browse files
authored
e2e/qa: stop counting devices that cannot accept users as failures (#4168)
## Summary of Changes Mainnet Beta QA failed 7 times over Aug 8-10; three (infra runs 31248009915, 31266248546, 31286128831) had one cause. `qa_alldevices_unicast_test.go` checks device readiness at five sites, but each increments `FailedTests` *before* that check, so the carve-out suppressed only the log line. `laconic-{dfw,mia,was}-sw01` have been activated at `max_users=0` since Aug 6 and the CLI refuses those connects outright; against `cmh-mn-qa01`'s 13-device pool that read as 21-29% on a 20% per-host gate. Gating that counter is not enough: `Success()` also requires non-zero packet counts, which a device that never connected cannot produce. Not-ready devices are therefore excluded from `ComputeFailureStats` entirely — per-host denominator included — behind a new `Device.Ready()` replacing the condition previously spelled out at six call sites. Exclusion opens a fail-open path worth reviewing closely: the rate is then measured over a shrinking pool, and with *every* device excluded it is `0/0`, where `NaN > threshold` is false. Coverage is now gated on the skipped rate against `-skipped-threshold` (default 0.5), fleet-wide **and** per host — one metro draining is a few percent of the fleet but all of a host's pool, and per-host is the gate that fires in practice. Testing nothing fails independently of that threshold, which `-skipped-threshold=1` would otherwise switch off. The gate uses `t.Errorf`, not `t.Fatalf`, so the publishers still run: the skipped count lands as `devices_skipped` beside `devices_tested` in InfluxDB and ClickHouse, the latter via an idempotent `ADD COLUMN IF NOT EXISTS`, best-effort so a writer without ALTER rights still gets its per-device rows in. `Ready()` is deliberately a subset of `is_device_eligible_for_provisioning`: a device at `users_count + reserved_seats >= max_users` hits the same CLI rejection and still counts as a failure, but fixing that means re-adding the capacity check #3697 removed, so it is a follow-up. Separately, `client_unicast.go` wrapped an always-nil `lastErr`, so every failure this weekend read `failed to ping after 3 retries: %!w(<nil>)`. It now reports packet counts. ## Testing Verification `go test ./e2e/internal/qa/...` and `golangci-lint run --build-tags=qa ./e2e/...` pass. The new subtests fail against the unfixed aggregation: the mainnet shape reports `total=6 failed=3` for `total=3 failed=0`, and a host whose whole pool is drained was absent from `PerHost` rather than visible there as zero coverage. Not verified: the `qa`-tagged tests need live fleet access, so nothing here runs against the real fleet and no run has written a `devices_skipped` row yet. Mainnet has 5 of ~92 non-transit devices unusable today, well under the 0.5 default. Two pre-existing holes on the same axis go to separate PRs: a not-ready device still consumes a batch host slot, which on the two-host devnet run can trip the `fewer than 2 clients connected` fatal, and the Grafana active-device filter fails open on a query error but not on an empty successful response.
1 parent 47e264e commit 43e10bf

9 files changed

Lines changed: 369 additions & 37 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ All notable changes to this project will be documented in this file.
4040
- `doublezero feed create` and `doublezero feed update` now read back every `--exchange` and `--group` argument, so a feed cannot name a metro or a multicast group that the ledger does not carry. A base58 argument used to pass straight through with no read, so `--group 4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T` created a feed whose group nobody can join. A code was always read back, so only the pubkey form changes. (#4172)
4141
- E2E/QA
4242
- `TestQA_MulticastSettlement`'s `validate_instant_allocation_price_matches_chain` no longer names a specific `doublezero_solana_version` in its skip path. Both the comment and the skip message said the pin was `0.5.10-1`; testnet has since moved to `0.5.11-1`, so a reader was told the pin was merely behind when in fact `instant_allocation_price` is in no release yet. They now name what actually gates the field — a doublezero-offchain release carrying doublezero-offchain#405 — and where the pin lives, neither of which goes stale as versions move. Comment and message only, no behaviour change.
43+
- `TestQA_AllDevices_UnicastConnectivity` no longer counts a device that cannot accept users against its failure thresholds. Five sites already checked `activated && max_users > 0` and logged `Ignoring <x> failure for device not ready for users`, but each incremented `FailedTests` before the check, so the carve-out suppressed only the log line while the device still counted as failed — and gating that counter alone would not have been enough, since `Success()` also requires a non-zero packet count a device that never connected cannot produce. Such devices are now excluded from `ComputeFailureStats` entirely, per-host denominator included. This is what failed mainnet-beta QA three times over 2026-08-08/09: `laconic-dfw-sw01`, `laconic-mia-sw01` and `laconic-was-sw01` have been activated at `max_users=0` since 08-06, the client CLI refuses those connects outright, and `cmh-mn-qa01` draws from a 13-device pool, so three unusable devices read as a 21-29% per-host rate against the 20% gate. The excluded codes are now reported in test output, so a skip is distinguishable from a pass. The exclusion is deliberately a subset of the program's `is_device_eligible_for_provisioning`: a device at `users_count + reserved_seats >= max_users` hits the same CLI rejection and still counts as a failure, since narrowing that too would restore the capacity pre-filtering #3697 removed. A run that could not attempt more than half of the devices assigned to it — fleet-wide or on any single host — now fails (`-skipped-threshold`, default 0.5) rather than reporting green over the remnant, and testing nothing at all fails regardless of that threshold, where previously the rate was `0/0` and `NaN > threshold` passed silently. The gate is per host as well because a drained metro is a few percent of the fleet but all of one host's coverage. The count also publishes as `devices_skipped` next to `devices_tested` in InfluxDB and ClickHouse, so a collapse that stays under the threshold shows up on the dashboard and not only in the test log. Separately, a ping that never gets a reply reports its packet counts rather than `failed to ping after 3 retries: %!w(<nil>)`; the wrapped error was always nil, because the retry loop returns early on any real failure. (#4168)
4344

4445
## [v0.34.0](https://github.com/malbeclabs/doublezero/compare/client/v0.33.0...client/v0.34.0) - 2026-08-07
4546

e2e/internal/qa/clickhouse.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ type ClickhouseConfig struct {
2323
// ClickhouseConfigFromEnv reads ClickHouse connection settings from environment variables.
2424
// Returns nil if CLICKHOUSE_ADDR is not set, which disables ClickHouse publishing.
2525
//
26-
// Schema changes to the tables created by PublishToClickhouse require manual ALTER TABLE
27-
// or DROP TABLE on the ClickHouse side — there is no migration tooling (same as the controller).
26+
// There is no migration tooling for the tables created by PublishToClickhouse (same as the
27+
// controller): a new column must be added to the CREATE TABLE *and* to an idempotent ALTER
28+
// in createQATables, or inserts break against deployments that already have the table.
2829
func ClickhouseConfigFromEnv() *ClickhouseConfig {
2930
addr := os.Getenv("CLICKHOUSE_ADDR")
3031
if addr == "" {
@@ -76,7 +77,7 @@ func buildClickhouseOptions(addr, db, user, pass string, disableTLS bool) *click
7677
// PublishToClickhouse writes per-device results and a summary row to ClickHouse.
7778
// Both tables are created automatically on first use (CREATE TABLE IF NOT EXISTS).
7879
// If cfg is nil, publishing is skipped silently.
79-
func PublishToClickhouse(ctx context.Context, log *slog.Logger, cfg *ClickhouseConfig, env string, results []DeviceTestResult, duration time.Duration) error {
80+
func PublishToClickhouse(ctx context.Context, log *slog.Logger, cfg *ClickhouseConfig, env string, results []DeviceTestResult, skippedDevices int, duration time.Duration) error {
8081
if cfg == nil {
8182
log.Debug("ClickHouse publishing skipped: no configuration")
8283
return nil
@@ -101,11 +102,11 @@ func PublishToClickhouse(ctx context.Context, log *slog.Logger, cfg *ClickhouseC
101102
return err
102103
}
103104

104-
if err := insertMetadata(ctx, conn, cfg.DB, env, results, duration); err != nil {
105+
if err := insertMetadata(ctx, conn, cfg.DB, env, results, skippedDevices, duration); err != nil {
105106
return err
106107
}
107108

108-
log.Debug("published QA results to ClickHouse", "devices", len(results))
109+
log.Debug("published QA results to ClickHouse", "devices", len(results), "skipped", skippedDevices)
109110
return nil
110111
}
111112

@@ -130,6 +131,7 @@ func createQATables(ctx context.Context, conn clickhouse.Conn, db string) error
130131
devices_tested UInt32,
131132
devices_success UInt32,
132133
devices_failed UInt32,
134+
devices_skipped UInt32,
133135
duration_s Float64
134136
) ENGINE = MergeTree
135137
PARTITION BY toYYYYMM(timestamp)
@@ -143,6 +145,13 @@ func createQATables(ctx context.Context, conn clickhouse.Conn, db string) error
143145
return fmt.Errorf("failed to create QA table: %w", err)
144146
}
145147
}
148+
149+
// The CREATE above is a no-op where the table predates devices_skipped. Best
150+
// effort so a writer without ALTER rights still gets its per-device results
151+
// in; a column that really is missing surfaces on the metadata insert.
152+
_ = conn.Exec(ctx, fmt.Sprintf(
153+
`ALTER TABLE "%s".qa_alldevices_metadata ADD COLUMN IF NOT EXISTS devices_skipped UInt32 AFTER devices_failed`, db,
154+
))
146155
return nil
147156
}
148157

@@ -172,7 +181,7 @@ func insertResults(ctx context.Context, conn clickhouse.Conn, db, env string, re
172181
return batch.Close()
173182
}
174183

175-
func insertMetadata(ctx context.Context, conn clickhouse.Conn, db, env string, results []DeviceTestResult, duration time.Duration) error {
184+
func insertMetadata(ctx context.Context, conn clickhouse.Conn, db, env string, results []DeviceTestResult, skippedDevices int, duration time.Duration) error {
176185
var successCount, failedCount uint32
177186
for _, r := range results {
178187
if r.Success {
@@ -183,13 +192,13 @@ func insertMetadata(ctx context.Context, conn clickhouse.Conn, db, env string, r
183192
}
184193

185194
batch, err := conn.PrepareBatch(ctx, fmt.Sprintf(
186-
`INSERT INTO "%s".qa_alldevices_metadata (timestamp, env, devices_tested, devices_success, devices_failed, duration_s)`, db,
195+
`INSERT INTO "%s".qa_alldevices_metadata (timestamp, env, devices_tested, devices_success, devices_failed, devices_skipped, duration_s)`, db,
187196
))
188197
if err != nil {
189198
return fmt.Errorf("failed to prepare metadata batch: %w", err)
190199
}
191200

192-
if err := batch.Append(time.Now(), env, uint32(len(results)), successCount, failedCount, duration.Seconds()); err != nil {
201+
if err := batch.Append(time.Now(), env, uint32(len(results)), successCount, failedCount, uint32(skippedDevices), duration.Seconds()); err != nil {
193202
return fmt.Errorf("failed to append metadata row: %w", err)
194203
}
195204

e2e/internal/qa/client.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ type Device struct {
108108
DeviceType serviceability.DeviceDeviceType
109109
}
110110

111+
// Ready reports whether a connect against this device can get far enough to
112+
// tell us anything. MaxUsers == 0 means drained or never enabled; either way the
113+
// CLI's own precheck refuses the connect ("Device is not accepting more users")
114+
// before the onchain qa_allowlist can exempt us, so failures against it say
115+
// nothing about the network and must not count toward QA failure rates.
116+
//
117+
// This is a subset of the program's is_device_eligible_for_provisioning, which
118+
// also requires UsersCount+ReservedSeats < MaxUsers; a device at capacity still
119+
// reports as ready here. MaxUnicastUsers is deliberately not consulted: 0 there
120+
// means "no per-type limit", the inverse of drained.
121+
func (d *Device) Ready() bool {
122+
return d.Status == serviceability.DeviceStatusActivated && d.MaxUsers > 0
123+
}
124+
111125
type Client struct {
112126
log *slog.Logger
113127
grpcClient pb.QAAgentServiceClient

e2e/internal/qa/client_unicast.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,12 @@ func (c *Client) TestUnicastConnectivity(t *testing.T, ctx context.Context, targ
136136
}
137137

138138
var lastResp *pb.PingResult
139-
var lastErr error
140139
for i := range unicastPingMaxRetries {
141140
resp, err := c.pingOnce(ctx, targetIP, sourceIP, iface)
142141
if err != nil {
143142
return nil, fmt.Errorf("failed to ping: %w", err)
144143
}
145144
lastResp = resp
146-
lastErr = err
147145

148146
if resp.PacketsSent == 0 {
149147
c.log.Warn("No packets sent",
@@ -227,7 +225,18 @@ func (c *Client) TestUnicastConnectivity(t *testing.T, ctx context.Context, targ
227225
PacketsReceived: lastResp.PacketsReceived,
228226
}
229227
}
230-
return result, fmt.Errorf("failed to ping after %d retries: %w", unicastPingMaxRetries, lastErr)
228+
return result, pingFailureError(unicastPingMaxRetries, lastResp)
229+
}
230+
231+
// pingFailureError describes a ping that never got a reply. Every attempt's RPC
232+
// succeeded — the loop above returns early otherwise — so there is no error to
233+
// wrap and the packet counts are the only diagnostic available.
234+
func pingFailureError(retries int, lastResp *pb.PingResult) error {
235+
if lastResp == nil {
236+
return fmt.Errorf("failed to ping after %d retries: no ping result returned", retries)
237+
}
238+
return fmt.Errorf("failed to ping after %d retries: %d/%d packets received on the last attempt",
239+
retries, lastResp.PacketsReceived, lastResp.PacketsSent)
231240
}
232241

233242
func (c *Client) pingOnce(ctx context.Context, targetIP string, sourceIP string, iface string) (*pb.PingResult, error) {

e2e/internal/qa/client_unicast_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,38 @@ func TestFindIBRLStatus(t *testing.T) {
322322
})
323323
}
324324
}
325+
326+
func TestPingFailureError(t *testing.T) {
327+
t.Parallel()
328+
329+
tests := []struct {
330+
name string
331+
lastResp *pb.PingResult
332+
want string
333+
}{
334+
{
335+
name: "total loss reports the counts",
336+
lastResp: &pb.PingResult{PacketsSent: 40, PacketsReceived: 0},
337+
want: "failed to ping after 3 retries: 0/40 packets received on the last attempt",
338+
},
339+
{
340+
name: "no packets sent reports zeroes",
341+
lastResp: &pb.PingResult{},
342+
want: "failed to ping after 3 retries: 0/0 packets received on the last attempt",
343+
},
344+
{
345+
name: "no response at all says so",
346+
lastResp: nil,
347+
want: "failed to ping after 3 retries: no ping result returned",
348+
},
349+
}
350+
351+
for _, tt := range tests {
352+
t.Run(tt.name, func(t *testing.T) {
353+
t.Parallel()
354+
err := pingFailureError(3, tt.lastResp)
355+
require.EqualError(t, err, tt.want)
356+
require.NotContains(t, err.Error(), "%!w", "must not format a nil error with %%w")
357+
})
358+
}
359+
}

e2e/internal/qa/device_assignment.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,18 @@ func AssignDevicesToClients(devices []*Device, clients []*Client, clientLatencie
305305
// HostFailureStats aggregates per-host failure information after deduping
306306
// repeated tests of the same (host, device) pair.
307307
type HostFailureStats struct {
308-
Total int // unique devices assigned to this host
308+
Total int // unique testable devices assigned to this host
309309
Failed int // unique devices that never succeeded on this host
310+
Skipped int // unique devices excluded for not accepting users; disjoint from Total
310311
FailedDevices []string // sorted, deduped device codes
311312
}
312313

314+
// SkippedRate is this host's share of assigned devices that could not accept
315+
// users. See FailureStats.SkippedRate for the fleet-wide equivalent.
316+
func (h HostFailureStats) SkippedRate() float64 {
317+
return skippedRate(h.Total, h.Skipped)
318+
}
319+
313320
// DeviceRetest describes a (host, device) pair that was tested more than once.
314321
type DeviceRetest struct {
315322
Host string
@@ -331,25 +338,57 @@ type FailureStats struct {
331338
DeviceResults []DeviceTestResult // one per unique device code
332339
PerHost map[string]HostFailureStats // keyed by host
333340
Retests []DeviceRetest // entries where Attempts > 1
341+
Skipped []string // sorted codes of devices that could not accept users
342+
}
343+
344+
// SkippedRate is the fraction of assigned devices left out of the tallies for
345+
// not accepting users.
346+
func (s FailureStats) SkippedRate() float64 {
347+
return skippedRate(len(s.DeviceResults), len(s.Skipped))
348+
}
349+
350+
// skippedRate is 1 when nothing was testable at all, so a gate on it never
351+
// divides by zero nor reads a NaN as a pass.
352+
func skippedRate(tested, skipped int) float64 {
353+
assigned := tested + skipped
354+
if assigned == 0 {
355+
return 1
356+
}
357+
return float64(skipped) / float64(assigned)
334358
}
335359

336360
// ComputeFailureStats walks batchData once and applies the "any success
337361
// counts as success" rule per device. Repeated tests of the same
338362
// (host, device) collapse into a single result for both the overall device
339-
// list and per-host stats.
363+
// list and per-host stats. Devices that are not Ready are left out of the
364+
// failure tallies and counted as skipped instead, fleet-wide and per host.
340365
func ComputeFailureStats(batchData BatchData) FailureStats {
341366
// hostDeviceAttempts[host][code] = number of attempts
342367
hostDeviceAttempts := make(map[string]map[string]int)
343368
// hostDeviceSuccesses[host][code] = number of successful attempts
344369
hostDeviceSuccesses := make(map[string]map[string]int)
345370
deviceSucceeded := make(map[string]bool)
346371
devicePubkey := make(map[string]string)
372+
skipped := make(map[string]struct{})
373+
// hostSkipped[host] = set of codes skipped on that host
374+
hostSkipped := make(map[string]map[string]struct{})
347375

348376
batchNums := slices.Sorted(maps.Keys(batchData))
349377
for _, batchNum := range batchNums {
350378
hosts := slices.Sorted(maps.Keys(batchData[batchNum]))
351379
for _, host := range hosts {
352380
assignment := batchData[batchNum][host]
381+
// A device that cannot accept users is excluded entirely, not
382+
// counted as a failure: it must not inflate the denominator either.
383+
// Skipped is the caller's audit trail for what was left out.
384+
if !assignment.Device.Ready() {
385+
skipped[assignment.Device.Code] = struct{}{}
386+
if hostSkipped[host] == nil {
387+
hostSkipped[host] = make(map[string]struct{})
388+
}
389+
hostSkipped[host][assignment.Device.Code] = struct{}{}
390+
continue
391+
}
353392
code := assignment.Device.Code
354393
if hostDeviceAttempts[host] == nil {
355394
hostDeviceAttempts[host] = make(map[string]int)
@@ -389,6 +428,14 @@ func ComputeFailureStats(batchData BatchData) FailureStats {
389428
perHost[host] = stats
390429
}
391430

431+
// A host left with nothing testable gets an entry with Total 0: absent from
432+
// the map, its lost coverage would be indistinguishable from a clean run.
433+
for host, codes := range hostSkipped {
434+
hs := perHost[host]
435+
hs.Skipped = len(codes)
436+
perHost[host] = hs
437+
}
438+
392439
var retests []DeviceRetest
393440
hosts := slices.Sorted(maps.Keys(hostDeviceAttempts))
394441
for _, host := range hosts {
@@ -413,5 +460,6 @@ func ComputeFailureStats(batchData BatchData) FailureStats {
413460
DeviceResults: deviceResults,
414461
PerHost: perHost,
415462
Retests: retests,
463+
Skipped: slices.Sorted(maps.Keys(skipped)),
416464
}
417465
}

0 commit comments

Comments
 (0)