Skip to content

feat: parallel OID walks with per-module walk_concurrency config#1652

Open
amuntean-godaddy wants to merge 9 commits into
prometheus:mainfrom
amuntean-godaddy:pr/parallel-walk-concurrency
Open

feat: parallel OID walks with per-module walk_concurrency config#1652
amuntean-godaddy wants to merge 9 commits into
prometheus:mainfrom
amuntean-godaddy:pr/parallel-walk-concurrency

Conversation

@amuntean-godaddy

@amuntean-godaddy amuntean-godaddy commented Jul 4, 2026

Copy link
Copy Markdown

Summary

Sequential walking of N OID subtrees in a module takes sum(N) seconds. On devices with large interface tables (e.g. a 48-port switch with hundreds of sub-interfaces, 19-OID if-mib at 45ms RTT), the walk can exceed the configured scrape timeout.

This PR adds parallel OID walks within a single module, controlled by a new walk_concurrency field in the module config. The default is 1 — identical to the current sequential behaviour — so existing configs are completely unaffected.

Changes

New config field: walk_concurrency

modules:
  if_mib:
    walk_concurrency: 4   # walk 4 OID subtrees in parallel; default 1 (sequential)
    max_repetitions: 10
    timeout: 5s
    walk:
      - ifAdminStatus
      - ifOperStatus
      # ...

Propagates through generator.ymlsnmp.yml like max_repetitions and timeout.

Implementation

Fixed worker pool (collector/collector.go): ScrapeTarget now accepts ctx context.Context as its first parameter. When walk_concurrency > 1, a fixed pool of min(walk_concurrency, len(walk)) workers is created, each with its own cloned SNMP connection. Workers pull OID subtrees from a pre-filled buffered channel. errgroup.WithContext(ctx) cancels all workers on the first error and g.Wait() guarantees ScrapeTarget never returns with live goroutines.

Clone() on SNMPScraper (scraper/): Each worker gets its own connection via Clone(). The implementation does a field-by-field copy of GoSNMP and explicitly:

  • Zeroes callback fields (OnSent, OnRecv, OnRetry, OnFinish, PreSend) — they capture per-scrape counters by reference
  • Strips the port from LocalAddr via hostOnly() — prevents bind: address already in use with --snmp.source-address=host:port
  • Deep-copies SecurityParameters via .Copy() — avoids shared AES/DES IV state across connections
  • Zeroes Conn — connections are not shared

Metrics: packets/retries counters in collect() use sync/atomic types for correctness. Walk-phase packets are not counted when walk_concurrency > 1 (clone callbacks are intentionally nil); the metric help strings document this.

Testing

go test ./... -race

All packages pass. New tests:

  • TestScrapeTargetParallelWalkwalk_concurrency=3, 3 subtrees × 2 PDUs, verifies all 6 PDUs present
  • TestScrapeTargetParallelWalkConnectError — clone connect failure propagates cleanly
  • TestHostOnly, TestCloneStripsLocalAddrPort, TestMockCloneCopiesConnectErrorClone() correctness

@SuperQ @bastischubert @RichiH

@lukeod

lukeod commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Not a maintainer, but took a look at the PR as it's a feature i've considered doing a PoC for but never got around to it.

I think there's some significant issues with the PR as it stands:

  • Nothing in the test suite ever sets walk_concurrency > 1, so the new code path is never executed by any test. So the go test ./... -race result doesn't mean much.
  • testMetrics() was added but has no callers. The mock's Clone() also doesn't copy ConnectError, so the clone-connect-failure branch can't be tested as-is.
  • There's a race: ScrapeTarget needs to wait for all its walk goroutines before returning, but on the first walk error it returns immediately (wg.Wait() only happens in the channel-closer goroutine) - the leftover goroutines then Clone() the shared client while the worker reconfigures it via SetOptions for the next module.
  • Clone() copies LocalAddr verbatim, so with a fixed source port (e.g. --snmp.source-address=10.0.0.5:16100) every clone's Connect() fails with bind: address already in use
  • Clones drop OnSent/OnRecv/OnRetry, which are the only source of snmp_packets_total, snmp_retries_total, snmp_duration_seconds and snmp_scrape_packets_sent/_retried. With concurrency on, walk traffic (i.e. nearly all of it) goes uncounted.
  • Connections are made per-subtree, not per-worker: N walk entries = N connect/close cycles per scrape regardless of the concurrency setting, and nothing cancels in-flight clones after an early error.

Suggestion: a fixed pool of concurrency workers, each doing Clone()+Connect() once and pulling subtrees off a channel (same pattern as the existing worker pool in Collect()), wrapped in errgroup.WithContext so the first error cancels the siblings and the call waits for them before returning. That bounds connections to concurrency and fixes the race and orphan-walk issues in one go; golang.org/x/sync is already an indirect dep.

@amuntean-godaddy

Copy link
Copy Markdown
Author

Thank you @lukeod @SuperQ
I am going to re-work on the issues you have pointed out and run the real test in my environment.
I am not very proficient in go, however I really need the concurrency support and using AI here :) .

amuntean-godaddy and others added 7 commits July 7, 2026 12:28
Sequential walking of N OID subtrees takes sum(N) seconds. On devices
with large interface tables (e.g. 347 interfaces at 45ms RTT), a
19-OID if-mib walk can exceed the scrape timeout. This change walks
OID subtrees concurrently within a single module scrape.

Implementation:
- Add Clone() to SNMPScraper so each goroutine gets its own SNMP
  connection (gosnmp.GoSNMP is not goroutine-safe). Clone does a
  field-by-field copy of GoSNMP, omitting the embedded sync.Mutex,
  the OnSent/OnRecv/OnRetry callbacks (which capture per-scrape
  counters by reference), and deep-copies SecurityParameters to
  avoid shared AES/DES IV state across connections.
- Replace the sequential walk loop in ScrapeTarget with a goroutine
  fan-out bounded by a semaphore channel. Goroutines are spawned for
  all subtrees; the semaphore limits live connections to
  WalkParams.WalkConcurrency. walkConcurrency <= 1 keeps the original
  single-connection sequential path.
- Add WalkConcurrency int (yaml: walk_concurrency) to WalkParams with
  default 1. Existing configs get sequential walks unchanged. Modules
  with large tables can opt in via generator.yml or snmp.yml:

    if_mib:
      walk_concurrency: 4  # walk 4 OID subtrees in parallel

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
When --snmp.source-address=host:port is set, Clone() previously copied
the port verbatim. Every clone's Connect() then fails with 'bind: address
already in use' because only one socket can hold that port.

Strip the port so clones use the host address with an ephemeral port.
Also copy ConnectError to mockSNMPScraper.Clone() so tests can simulate
connect failure on clones.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
Fixes all concurrency issues raised in review:

1. Race: the old design returned ScrapeTarget on first error while
   goroutines were still running and could Clone() the shared client
   mid-SetOptions. errgroup.WithContext + g.Wait() ensures no goroutine
   outlives ScrapeTarget.

2. Orphan connections: old design opened one connection per subtree (N).
   New design opens exactly walk_concurrency connections regardless of
   the number of subtrees.

3. Cancellation: on first walk error errgroup cancels ctx which is
   propagated to all worker connections via SetContext, making in-flight
   BulkWalkAll calls abort promptly.

4. Metrics: packets/retries counters are now atomic.Uint64 so the
   callbacks are safe to call from goroutines if they are ever set on
   clones in future.

Promote golang.org/x/sync to direct dependency.

Also fix pre-existing go vet warnings in collector_test.go: unkeyed
config.Regexp struct literals now use the Regexp field name.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
…ang.org/x/sync to direct

- In ScrapeTarget parallel walk path, replace shared mu+append with
  workerResults[i] per-goroutine slices to avoid contention and
  unnecessary reallocation under the lock. Slices are merged after
  g.Wait() with no concurrency.
- Capture loop variable i for closure correctness.
- Remove now-unused var mu sync.Mutex.
- Run go mod tidy: golang.org/x/sync is imported directly via
  errgroup and no longer carries // indirect.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
Add TestScrapeTargetParallelWalk (walk_concurrency=3, verifies all 6
PDUs present and correct) and TestScrapeTargetParallelWalkConnectError
(clone connect failure propagates without hang).

The race detector now exercises the actual concurrent code path.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
…ing metric help

Fix 1 (Critical): GoSNMPWrapper.Clone() was silently dropping ExponentialTimeout,
Control, and TrapSecurityParametersTable. Add all missing fields. Document why a
raw struct copy cannot be used (GoSNMP embeds sync.Mutex, which go vet copylocks
rejects). Add explicit comments listing fields intentionally omitted (Conn,
OnSent/OnRecv/OnRetry/OnFinish/PreSend).

Fix 2 (Critical): ScrapeTarget lacked a context.Context parameter, so the child
context created by Collect() (ctx, cancel := context.WithCancel(c.ctx)) could not
be threaded to the errgroup inside ScrapeTarget. Add ctx as first param to
ScrapeTarget and collect(); have Collect() pass the child ctx (not c.ctx) to
collect(), and collect() pass it to ScrapeTarget. Replace snmp.Context() /
worker.SetContext() inside ScrapeTarget with the explicit gCtx derived from the
passed ctx. Update the three ScrapeTarget call sites in collector_test.go.

Fix 3 (Important): snmp_scrape_packets_sent and snmp_scrape_packets_retried had
help strings claiming walk packets were counted. When walk_concurrency > 1,
OnSent/OnRetry are not set on clone workers, so only GET-phase packets are counted.
Update help strings to document this limitation accurately.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
Context() and SetContext() on SNMPScraper have zero call sites — they
were re-introduced by a previous fix subagent after commit 5a347df
had already removed them cleanly. Drop them from the interface and
their implementations in GoSNMPWrapper and mockSNMPScraper; also
remove the now-unused "context" import from scraper.go and mock.go.

go build ./... and go test ./... -race both pass.

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@amuntean-godaddy amuntean-godaddy force-pushed the pr/parallel-walk-concurrency branch from f06da2c to e5ebc0e Compare July 7, 2026 19:31
@amuntean-godaddy

Copy link
Copy Markdown
Author

@SuperQ could you please review the current changes ?

@SuperQ

SuperQ commented Jul 10, 2026

Copy link
Copy Markdown
Member

Looks like some CI issues.

- Fix gci import grouping in collector.go, collector_test.go, scraper/gosnmp.go
- Use errors.Is() in TestMockCloneCopiesConnectError (errorlint)
- Remove deprecated NonRepeaters field from Clone() (staticcheck)

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
@amuntean-godaddy

Copy link
Copy Markdown
Author

Hi @SuperQ @bastischubert @RichiH — addressed all the lint issues raised by CI:

  • gci: fixed import grouping in collector/collector.go, collector/collector_test.go, and scraper/gosnmp.go
  • errorlint: replaced err != wantErr with errors.Is(err, wantErr) in scraper/gosnmp_test.go
  • staticcheck: removed the deprecated NonRepeaters field from Clone() in scraper/gosnmp.go

The Build generator failure is an infrastructure flake (503 fetching Dell MIBs from an external URL — unrelated to this PR).

The branch is ready for re-review. Thanks for your time!

- gofumpt: reformat collector/collector.go and scraper/mock.go
- README: remove sentence about partial-metric behaviour that
  was added for a separate abandoned PR

Signed-off-by: Alex Muntean <amuntean@godaddy.com>
@amuntean-godaddy amuntean-godaddy force-pushed the pr/parallel-walk-concurrency branch from 457682e to a5e6556 Compare July 11, 2026 19:06
@SuperQ

SuperQ commented Jul 12, 2026

Copy link
Copy Markdown
Member

@lukeod Want to give this another pass?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants