Skip to content

Commit ea2000b

Browse files
authored
telemetry: surface program errors from finalized transactions (#4152)
Resolves: malbeclabs/infra#1703 ## Summary of Changes - The Go telemetry SDK executor no longer reports a transaction the program rejected as a success. Finalization only means the cluster agreed on the transaction: an instruction the program refused finalizes too, carrying the rejection in `err`, which the executor never read. It now returns a `*telemetry.ProgramError` holding the ledger's error and the program's log output. - The device telemetry submitter treats that rejection as permanent for the tick — it logs the program's own explanation at `Error` and leaves the tick's remaining attempts unspent, rather than spending them on an instruction the ledger has already refused. New `submitter_program_error` type on the existing errors counter. - `ProgramError.Error()` leads with the program's explanation (`Program log:` lines, minus the runtime's invoke/consumed boilerplate and the instruction-name echo), so a caller that only prints the error still gets the reason. This is the check `smartcontract/sdk/go/serviceability/executor.go` already made; telemetry was the outlier. This is what left chi-dn-dzd4 silent for over an hour. Its `metrics_publisher` had been set to a key the agent did not hold, and the init half of the init→write path skips preflight — so `UnauthorizedAgent` (0x3e9) only arrived on the finalized transaction, which the SDK read as success. The agent looped init → write → `account not found` every few seconds, and the only error it printed named the missing account, not the authorization failure that caused it. ## Diff Breakdown | Category | Files | Lines (+/-) | Net | |--------------|-------|-------------|------| | Tests | 2 | +178 / -0 | +178 | | Core logic | 2 | +86 / -0 | +86 | | Docs | 1 | +3 / -0 | +3 | | Scaffolding | 1 | +4 / -0 | +4 | | **Total** | 6 | +271 / -0 | +271 | Two thirds tests: the fix itself is 86 lines across the SDK executor and one branch in the submitter's retry loop. <details> <summary>Key files (click to expand)</summary> - `smartcontract/sdk/go/telemetry/executor.go` — new `ProgramError` type with the program-log filter; `waitForTransactionFinalized` returns it when the finalized signature status or the transaction meta carries an error, fetching the logs best effort so a node that cannot return the transaction costs the logs rather than replacing the rejection with an RPC error - `controlplane/telemetry/internal/telemetry/submitter.go` — `errors.As` branch in `Tick`'s retry loop: count, log at `Error`, stop the tick's attempts; samples requeue as with any other failure - `controlplane/telemetry/internal/metrics/metrics.go` — `ErrorTypeSubmitterProgramError`, kept distinct from the write/init failure types because those also cover transient RPC trouble </details> ## Testing Verification - `TestSDK_Telemetry_Executor_FinalizedWithProgramError` reproduces the chi-dn-dzd4 transaction — finalized, `Custom: 1001`, with the "not authorized for origin device" program log — across three shapes: the rejection on the signature status, on the transaction meta only (a node that returns a clean status), and with the logs unfetchable. All three must return an error rather than a signature; the error message carries the custom code in every case and the program's explanation whenever the logs were available, with the boilerplate stripped. - `does_not_retry_a_submission_the_program_rejected` drives a full submitter tick with `MaxAttempts: 5` and asserts exactly one write and one init reach the program, `submitter_program_error` increments once, `submitter_retries_exhausted` does not, the reason reaches the log, and the samples are requeued for the next tick. - The two pre-existing finalization edge cases (`Meta == nil`, `GetTransaction` returning nil) keep their original error messages — a clean signature status still falls through to them unchanged. - Full suites pass for `smartcontract/sdk/go/telemetry`, `controlplane/telemetry/...`, and `controlplane/internet-latency-collector/...`. One unrelated pre-existing failure in `controlplane/telemetry/internal/netns` (`TestRunInNamespace_EmptyNameErrors` needs namespace privileges) reproduces identically on a clean `main` checkout. ### Not in scope - `controlplane/internet-latency-collector/internal/exporter/submitter.go` has the same init→write shape and picks up the SDK's real error for free, but keeps retrying a rejection through its attempts. Left for a follow-up rather than widened here. - A write that passes preflight and then finalizes against a full account now surfaces as a generic `ProgramError` instead of the `ErrSamplesAccountFull` sentinel, so it requeues and takes the drop path on the next tick instead of immediately. Previously that race reported success and lost the samples with no signal at all, so this is strictly better; mapping `meta.err` custom codes back onto the sentinels would need a parser in `client.go` and is a separate change.
1 parent 1662e79 commit ea2000b

12 files changed

Lines changed: 673 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ All notable changes to this project will be documented in this file.
1010

1111
- CLI
1212
- `doublezero feed list` gains a `group_codes` column naming the multicast groups the feed holds, alongside the existing `groups` count. A group the ledger no longer carries renders as its raw pubkey. The JSON output gains the field as well. (malbeclabs/infra#2172, #4150)
13+
- SDK
14+
- The Go telemetry SDK no longer reports a transaction the program rejected as a success. Finalization only means the cluster agreed on the transaction: a rejected instruction finalizes too, carrying the rejection in `err`, which the executor never read. It now returns a `*telemetry.ProgramError` holding the ledger's error and the program's log output, and leads the message with the program's own explanation so a caller that just prints the error still gets the reason. This is the check the serviceability executor already made. (malbeclabs/infra#1703, #4152)
15+
- A samples-account-full or missing-account rejection that reaches execution now returns the same `ErrSamplesAccountFull` / `ErrAccountNotFound` the equivalent preflight rejection does, via the new `ProgramError.CustomErrorCode()`. Preflight catches nearly all of these, but a write that simulated cleanly and then failed against the bank it landed on reported its code only through the finalized transaction, so a caller's account-full handling worked on one side of preflight and not the other. (malbeclabs/infra#1703, #4152)
16+
- Collector
17+
- A failed internet-latency submission now retries from the first unwritten sample rather than restarting at the beginning of the flushed partition, and only the unwritten remainder is requeued — the same fix the device telemetry submitter got in #4145. A partition larger than one transaction is written in batches, so any failure part-way through left the earlier batches onchain while the retry and the next tick re-sent them, appending those samples a second time and skewing the latency they feed. Reachable today from an RPC timeout mid-partition; surfacing program rejections adds another way in. (malbeclabs/infra#1703, #4152)
1318
- Device Telemetry
19+
- A submission the telemetry program rejects onchain no longer burns the tick's remaining attempts: the agent logs the rejection with the program's explanation at Error and moves on, counting `submitter_program_error` on the errors counter. Before this, the init half of the init→write path could not be seen to fail — it skips preflight, so the rejection only showed up on the finalized transaction, which the SDK read as success — and the agent looped init→write→`account not found` every few seconds with nothing in the log naming the cause. Observed on chi-dn-dzd4, where the device's `metrics_publisher` had been set to a key the agent did not hold. Samples are requeued as with any other failure, so the next tick retries once the cause is fixed. An init the program rejects because the account already exists is excepted: that leaves the write with what it needed, so the write now runs either way and only a write that still finds nothing there reports the init failure as the reason. (malbeclabs/infra#1703, #4152)
1420
- A ledger RPC outage no longer stops TWAMP probing on the device telemetry agent: the pinger caches the last known epoch and refreshes it off the probe path, instead of fetching it inline and skipping the tick on failure. Probing stops when no epoch has ever been fetched, when the cached one exceeds the new `-max-epoch-staleness` (default 10h, clamped to what the sample buffer holds at `-probe-interval`), or when the cached epoch's projected end has passed. Samples taken against a cached epoch are written to that epoch's account, so a query scoped to a later epoch will not return them — the projected-end bound is what keeps that from spanning a rollover. The refresh cadence follows `-probe-interval` and can be set with the new `-epoch-refresh-interval`. (#4143)
1521
- A peer discovery refresh that fails after reading the ledger no longer wipes the agent's peer list. It cleared the cache before calling `LocalNet.Interfaces()`, so a transient failure there left the pinger iterating zero peers and probing nothing until a later refresh succeeded. The cache is now replaced only once the new list is built, which also shortens the critical section to the assignment. (#4146)
1622
- The telemetry agent now logs and counts samples it discards when a submission fails and the partition buffer is already at capacity; that path previously recycled the batch with no signal at all. New counter `doublezero_device_telemetry_agent_samples_dropped_total` with a `reason` label (`buffer_full`), plus `submitter_buffer_full` on the existing errors counter. Requeue behavior below capacity is unchanged, and neither signal fires in steady state. (#4144)

controlplane/internet-latency-collector/internal/exporter/submitter.go

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,16 @@ func (s *Submitter) Run(ctx context.Context) error {
104104
}
105105
}
106106

107-
func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey, samples []Sample) error {
107+
// SubmitSamples writes samples to the partition's onchain account in batches, and returns how many
108+
// of them were written. That count is the caller's resume point: the batches before it are already
109+
// onchain, so a retry must pass samples[written:] rather than re-sending the whole slice, or those
110+
// samples are appended a second time.
111+
func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey, samples []Sample) (int, error) {
108112
log := s.log.With("partition", partitionKey)
109113

110114
if len(samples) == 0 {
111115
log.Debug("No samples to submit, skipping")
112-
return nil
116+
return 0, nil
113117
}
114118

115119
for i := 0; i < len(samples); i += telemetry.MaxInternetLatencySamplesPerBatch {
@@ -140,7 +144,7 @@ func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey
140144
log.Info("Account not found, initializing new account")
141145
samplingInterval, ok := s.cfg.DataProviderSamplingIntervals[partitionKey.DataProvider]
142146
if !ok {
143-
return fmt.Errorf("no sampling interval found for data provider: %s", partitionKey.DataProvider)
147+
return i, fmt.Errorf("no sampling interval found for data provider: %s", partitionKey.DataProvider)
144148
}
145149
_, _, err = s.cfg.Telemetry.InitializeInternetLatencySamples(ctx, telemetry.InitializeInternetLatencySamplesInstructionConfig{
146150
DataProviderName: string(partitionKey.DataProvider),
@@ -150,33 +154,33 @@ func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey
150154
SamplingIntervalMicroseconds: uint64(samplingInterval.Microseconds()),
151155
})
152156
if err != nil {
153-
return fmt.Errorf("failed to initialize internet latency samples: %w", err)
157+
return i, fmt.Errorf("failed to initialize internet latency samples: %w", err)
154158
}
155159
_, _, err = s.cfg.Telemetry.WriteInternetLatencySamples(ctx, writeConfig)
156160
if err != nil {
157161
if errors.Is(err, telemetry.ErrSamplesAccountFull) {
158-
log.Warn("Partition account is full, dropping samples from buffer and moving on", "droppedSamples", len(samples))
162+
log.Warn("Partition account is full, dropping samples from buffer and moving on", "droppedSamples", len(samples)-i)
159163
metrics.ExporterSubmitterAccountFull.WithLabelValues(string(partitionKey.DataProvider), partitionKey.SourceExchangePK.String(), partitionKey.TargetExchangePK.String(), strconv.FormatUint(partitionKey.Epoch, 10)).Inc()
160164
s.cfg.Buffer.Remove(partitionKey)
161-
return nil
165+
return i, nil
162166
}
163-
return fmt.Errorf("failed to write internet latency samples after init: %w", err)
167+
return i, fmt.Errorf("failed to write internet latency samples after init: %w", err)
164168
}
165169
} else if errors.Is(err, telemetry.ErrSamplesAccountFull) {
166-
log.Warn("Partition account is full, dropping samples from buffer and moving on", "droppedSamples", len(samples))
170+
log.Warn("Partition account is full, dropping samples from buffer and moving on", "droppedSamples", len(samples)-i)
167171
metrics.ExporterSubmitterAccountFull.WithLabelValues(string(partitionKey.DataProvider), partitionKey.SourceExchangePK.String(), partitionKey.TargetExchangePK.String(), strconv.FormatUint(partitionKey.Epoch, 10)).Inc()
168172
s.cfg.Buffer.Remove(partitionKey)
169-
return nil
173+
return i, nil
170174
} else {
171-
return fmt.Errorf("failed to write internet latency samples: %w", err)
175+
return i, fmt.Errorf("failed to write internet latency samples: %w", err)
172176
}
173177
}
174178

175179
metrics.ExporterPartitionedBufferSize.WithLabelValues(string(partitionKey.DataProvider), partitionKey.SourceExchangePK.String(), partitionKey.TargetExchangePK.String()).Set(float64(len(samples)))
176-
log.Debug("Submitted partition samples batch", "count", len(samples), "samples", rtts)
180+
log.Debug("Submitted partition samples batch", "count", len(batch), "samples", rtts)
177181
}
178182

179-
return nil
183+
return len(samples), nil
180184
}
181185

182186
func (s *Submitter) Tick(ctx context.Context) {
@@ -232,14 +236,20 @@ func (s *Submitter) Tick(ctx context.Context) {
232236
return
233237
}
234238

239+
// Samples written so far across attempts. Each retry resumes here rather than at the
240+
// start of tmp, so batches an earlier attempt put onchain are neither re-sent nor
241+
// counted as lost.
242+
written := 0
243+
235244
success := false
236245
for attempt := 1; attempt <= maxAttempts; attempt++ {
237246
// Bound each attempt so a slow/degraded ledger RPC can't leave the submission
238247
// blocked past its blockhash's validity window; the next attempt re-fetches a
239248
// fresh blockhash.
240249
attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
241-
err := s.SubmitSamples(attemptCtx, partitionKey, tmp)
250+
n, err := s.SubmitSamples(attemptCtx, partitionKey, tmp[written:])
242251
cancel()
252+
written += n
243253
if err == nil {
244254
log.Debug("Submitted samples", "count", len(tmp), "attempt", attempt)
245255
success = true
@@ -272,7 +282,7 @@ func (s *Submitter) Tick(ctx context.Context) {
272282
}
273283

274284
if !success {
275-
s.cfg.Buffer.PriorityPrepend(partitionKey, tmp)
285+
s.cfg.Buffer.PriorityPrepend(partitionKey, tmp[written:])
276286
}
277287

278288
// Always recycle the slice for reuse

controlplane/internet-latency-collector/internal/exporter/submitter_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,58 @@ func TestInternetLatency_Submitter(t *testing.T) {
741741
}
742742
})
743743

744+
// A partition too large for one transaction is written in batches, so a failure part-way through
745+
// leaves some of them onchain. Resuming at the first unwritten sample is what keeps the retry
746+
// from appending the earlier batches a second time and skewing the latency data they feed.
747+
t.Run("retries_resume_at_the_first_unwritten_sample", func(t *testing.T) {
748+
t.Parallel()
749+
750+
log := logger.With("test", t.Name())
751+
752+
key := newTestPartitionKey()
753+
total := sdktelemetry.MaxInternetLatencySamplesPerBatch + 17
754+
755+
var writes int32
756+
var submitted []uint32
757+
var mu sync.Mutex
758+
telemetryProgram := &mockTelemetryProgramClient{
759+
WriteInternetLatencySamplesFunc: func(_ context.Context, config sdktelemetry.WriteInternetLatencySamplesInstructionConfig) (solana.Signature, *solanarpc.GetTransactionResult, error) {
760+
// The first batch lands, the second fails, and the retry picks up from there.
761+
if atomic.AddInt32(&writes, 1) == 1 {
762+
mu.Lock()
763+
submitted = append(submitted, config.Samples...)
764+
mu.Unlock()
765+
return solana.Signature{}, nil, nil
766+
}
767+
return solana.Signature{}, nil, errors.New("ledger rpc unreachable")
768+
},
769+
}
770+
771+
buf := buffer.NewMemoryPartitionedBuffer[exporter.PartitionKey, exporter.Sample](4096)
772+
for range total {
773+
buf.Add(key, newTestSample())
774+
}
775+
776+
submitter, err := exporter.NewSubmitter(log, &exporter.SubmitterConfig{
777+
OracleAgentPK: solana.NewWallet().PublicKey(),
778+
Interval: time.Hour,
779+
Buffer: buf,
780+
Telemetry: telemetryProgram,
781+
MaxAttempts: 2,
782+
BackoffFunc: func(_ int) time.Duration { return 0 },
783+
EpochFinder: &mockEpochFinder{ApproximateAtTimeFunc: func(context.Context, time.Time) (uint64, error) { return key.Epoch, nil }},
784+
})
785+
require.NoError(t, err)
786+
787+
submitter.Tick(t.Context())
788+
789+
mu.Lock()
790+
defer mu.Unlock()
791+
assert.Len(t, submitted, sdktelemetry.MaxInternetLatencySamplesPerBatch,
792+
"the batch that landed should be written exactly once, not re-sent by the retry")
793+
assert.Len(t, buf.CopyAndReset(key), 17,
794+
"only the samples never written should be requeued, or the next tick appends them twice")
795+
})
744796
}
745797

746798
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {

controlplane/telemetry/internal/metrics/metrics.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ const (
4343
ErrorTypePingerEpochFetch = "pinger_epoch_fetch"
4444
ErrorTypeSubmitterBufferFull = "submitter_buffer_full"
4545
ErrorTypeSubmitterAccountFull = "submitter_account_full"
46+
// ErrorTypeSubmitterProgramError counts submissions the telemetry program rejected onchain.
47+
// It overlaps the write/init failure types rather than replacing them: those name the operation
48+
// that failed, this one narrows why to a rejection that will recur until something changes
49+
// onchain or in config, as opposed to the transient RPC trouble they also cover. A rejected init
50+
// increments both.
51+
ErrorTypeSubmitterProgramError = "submitter_program_error"
4652

4753
// Sample drop reasons.
4854
DropReasonBufferFull = "buffer_full"

controlplane/telemetry/internal/telemetry/submitter.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey
136136
if err != nil {
137137
if errors.Is(err, telemetry.ErrAccountNotFound) {
138138
log.Info("Account not found, initializing new account")
139-
_, _, err = s.cfg.ProgramClient.InitializeDeviceLatencySamples(ctx, telemetry.InitializeDeviceLatencySamplesInstructionConfig{
139+
_, _, initErr := s.cfg.ProgramClient.InitializeDeviceLatencySamples(ctx, telemetry.InitializeDeviceLatencySamplesInstructionConfig{
140140
AgentPK: s.cfg.MetricsPublisherPK,
141141
OriginDevicePK: partitionKey.OriginDevicePK,
142142
TargetDevicePK: partitionKey.TargetDevicePK,
@@ -146,16 +146,28 @@ func (s *Submitter) SubmitSamples(ctx context.Context, partitionKey PartitionKey
146146
AgentVersion: s.cfg.AgentVersion,
147147
AgentCommit: s.cfg.AgentCommit,
148148
})
149-
if err != nil {
150-
metrics.Errors.WithLabelValues(metrics.ErrorTypeSubmitterFailedToInitializeAccount).Inc()
151-
return i, fmt.Errorf("failed to initialize device latency samples: %w", err)
149+
if initErr != nil {
150+
// Not fatal on its own. An init the program rejects because the account already
151+
// exists has left us with exactly what the write needs, which happens when a
152+
// previous init landed onchain without the agent seeing it succeed. The write
153+
// below is what decides whether the rejection mattered, so it runs either way.
154+
// The error counter waits for that verdict rather than firing on a failure the
155+
// write goes on to absorb.
156+
log.Warn("Failed to initialize account, attempting the write anyway", "error", initErr)
152157
}
153158
_, _, err = s.cfg.ProgramClient.WriteDeviceLatencySamples(ctx, writeConfig)
154159
if err != nil {
155160
if errors.Is(err, telemetry.ErrSamplesAccountFull) {
156161
s.handleAccountFull(log, partitionKey, len(samples)-i)
157162
return i, nil
158163
}
164+
if initErr != nil {
165+
// The account is still not there, so the init failure is the reason the
166+
// write had nothing to write to. Report that rather than the missing
167+
// account it caused.
168+
metrics.Errors.WithLabelValues(metrics.ErrorTypeSubmitterFailedToInitializeAccount).Inc()
169+
return i, fmt.Errorf("failed to initialize device latency samples: %w", initErr)
170+
}
159171
metrics.Errors.WithLabelValues(metrics.ErrorTypeSubmitterFailedToWriteSamples).Inc()
160172
return i, fmt.Errorf("failed to write device latency samples after init: %w", err)
161173
}
@@ -247,6 +259,19 @@ func (s *Submitter) Tick(ctx context.Context) {
247259
break
248260
}
249261

262+
// A rejection by the program is not transient: the ledger executed the instruction
263+
// and refused it, so every attempt this tick would be refused the same way. Report
264+
// it once at Error and leave the rest of the attempts unspent, rather than burying
265+
// the reason under a backoff loop. The samples are requeued as with any other
266+
// failure, so the next tick retries once the operator fixes what was wrong.
267+
var programErr *telemetry.ProgramError
268+
if errors.As(err, &programErr) {
269+
metrics.Errors.WithLabelValues(metrics.ErrorTypeSubmitterProgramError).Inc()
270+
log.Error("Submission rejected by the telemetry program, not retrying this tick",
271+
"attempt", attempt, "samplesCount", len(tmp), "error", err)
272+
break
273+
}
274+
250275
var backoff time.Duration
251276
if s.cfg.BackoffFunc != nil {
252277
backoff = s.cfg.BackoffFunc(attempt)

0 commit comments

Comments
 (0)