Repository navigation
Conversation
Add ScrapeTarget, ScrapeConfig, SchedulingMode, reusable BodyBufferPool and DecoderPool scratch pools with per-buffer retained capacity caps, and ReqwestFetcher with Prometheus Accept/timeout headers and bounded streaming gzip decompression. ReqwestFetcher delays leasing a PooledBodyBuffer until HTTP 2xx headers arrive so slow targets stalled on TCP connect or server-side metric generation hold zero response payload memory. Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Add TargetSchedule and ScrapeManager to schedule targets across deterministic per-target FNV-1a phase offsets while decoupling max_in_flight HTTP header waits from max_buffered_bodies and max_decoders. Keep both cycle-tick and jittered sequential modes available for single-stream pipelines, and avoid tokio::spawn so thread-per-core !Send LocalSet runtimes can pass non-Send sinks directly. Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Add a benchfmt.rs benchmark suite comparing sequential_tick against bounded concurrent_k8_b2_d1 and concurrent_k8_b8_d4 scrape passes with and without per-target HTTP header wait latency, and document the TargetSchedule and pool-admission contracts in specs/SPEC.md and crate READMEs. Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
| match cmd { | ||
| Some(ScrapeCommand::UpdateTargets(new_targets)) => { | ||
| targets = new_targets.into_iter().map(Arc::new).collect(); | ||
| break; |
There was a problem hiding this comment.
issue: Target update mid-pass skips the rest of the CycleTick pass
manager.rs:670 drops the in-flight exec_one and breaks out of the loop when UpdateTargets arrives, which means every target after the current one waits a full interval for its next scrape. Jittered mode keeps in-flight scrapes across an update at manager.rs:750, could CycleTick apply the new list after the pass finishes instead?
| } | ||
|
|
||
| let (body, wire_bytes) = tokio::time::timeout(remaining, async { | ||
| let mut body = pool.acquire(cap_hint).await; |
There was a problem hiding this comment.
issue: Waiting for a body buffer counts against scrape_timeout
http.rs:291 acquires the body permit inside the timeout(remaining, ...) block, and the permit stays held through decode and every sink.on_batch until manager.rs:358, so a slow sink makes healthy targets fail with TimedOut and up=false. In a local test with max_buffered_bodies=1, a 300ms timeout and a sink that sleeps 600ms, 3 of 4 instant targets timed out, while config.rs:259 documents scrape_timeout as the HTTP and body read timeout.
| if !seen.insert(key.clone()) { | ||
| continue; | ||
| } | ||
| if self.index_by_key.contains_key(&key) { |
There was a problem hiding this comment.
issue: Re-adding a target that is still in flight allows overlap
schedule.rs:168 only checks index_by_key, which no longer holds the removed slot that is still scraping, so the re-add builds a fresh slot with in_flight: false and pop_due starts a second scrape of the same target while the first runs. SPEC.md:174 says an in-flight target skips its next tick, and a pod that flaps across two SD updates takes this path.
| if !records.is_empty() { | ||
| batches_emitted += 1; | ||
| if sink | ||
| .on_batch(target, fetched.scrape_timestamp_ms, records) |
There was a problem hiding this comment.
question: What should the sink do with batches from a failed scrape?
manager.rs:522 sends each chunk to on_batch as soon as it decodes, and a later decode error reports up=false, batches_emitted=0, so the sink already holds part of a scrape for a target marked down. Go rolls back the appender in this case (https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L1614-L1631), do you want a rollback signal on the sink, or a doc on on_scrape_complete saying sinks must drop those batches?
| pub fn build_accept_header(enable_open_metrics: bool, escaping: EscapingScheme) -> String { | ||
| let esc_param = match escaping { | ||
| EscapingScheme::EscapeUnderscores => String::new(), | ||
| other => format!(";escaping={}", other.as_str()), |
There was a problem hiding this comment.
issue: Accept header adds escaping to the 0.0.1 and 0.0.4 formats
http.rs:116 appends ;escaping= to every media type, while Go acceptHeader adds it only to the 1.0.0 formats because escaping is not valid for the older ones (https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L799-L802), which means with AllowUtf8 we send text/plain;version=0.0.4;escaping=allow-utf-8 even though SPEC.md:177 says this header matches targetScraper.scrape.
| .map_err(|_| ScrapeError::TimedOut { timeout })??; | ||
|
|
||
| let status = response.status(); | ||
| if !status.is_success() { |
There was a problem hiding this comment.
question: Accept any 2xx, or only 200 like Go?
http.rs:247 accepts any is_success() status, while Go readResponse fails anything other than 200 (https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L850), so a 204 or 206 passes here and fails in Prometheus. SPEC.md:175 says 2xx on purpose, should this go in the PR body's list of intentional differences?
| pub escaping_scheme: EscapingScheme, | ||
| /// Negotiate and transparently decompress `Content-Encoding: gzip` responses when `true`. | ||
| pub enable_compression: bool, | ||
| /// Preserve explicit sample timestamps when `true`; override with scrape time when `false`. |
There was a problem hiding this comment.
issue: honor_timestamps=false is silently ignored
config.rs:273 says false replaces sample timestamps with the scrape time, but nothing reads the field yet (TODO at manager.rs:460), so a caller who sets it still gets the exposed timestamps with no error, could the doc say it is not wired yet, or the field wait until with_honor_timestamps lands?
| // `self.body_pool` before `on_scrape_complete` awaits downstream work. | ||
| drop(fetched); | ||
|
|
||
| let (exit_early, report) = match decode_outcome { |
There was a problem hiding this comment.
nitpick: exit_early is always false
manager.rs:360 sets exit_early to false in both arms, so the exit_early || check at manager.rs:399 never fires and the tuple can go.
| fetched: &FetchedScrape, | ||
| sink: &S, | ||
| ) -> ControlFlow<(), Result<(u64, usize), ScrapeError>> { | ||
| let mut decoder = self.decoder_pool.acquire().await; |
There was a problem hiding this comment.
issue: Decoder lease held across sink awaits caps default concurrency
manager.rs:452 takes the decoder before decode and holds it through every sink.on_batch(...).await at manager.rs:521, and concurrent() defaults to one decoder at config.rs:183, which means with a sink that sleeps 1ms per batch concurrent_k8_b2_d1 took 132.9ms/op against 133.2ms/op for sequential_tick (p=0.353) while b8_d8 took 21ms/op, and BlackBoxSink in the PR's bench never awaits so the PR's numbers can't show it, is one decoder the right default for sinks that await?
benchstat, darwin/arm64 at https://github.com/prometheus/scrape.rs/commit/aac82206fe8d695743cd2444b2a40e13399a5e88
│ sequential_tick │ concurrent_k8_b2_d1 │ concurrent_k8_b8_d4 │ concurrent_k8_b8_d8 │
│ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │
SlowSink/targets=16/sink_wait_per_batch=0-2 7.453m ± 1% 7.514m ± 3% +0.82% (p=0.011 n=10) 7.411m ± 1% -0.57% (p=0.000 n=10) 7.368m ± 0% -1.13% (p=0.000 n=10)
SlowSink/targets=16/sink_wait_per_batch=1ms-2 133.20m ± 2% 132.92m ± 2% ~ (p=0.353 n=10) 38.87m ± 3% -70.82% (p=0.000 n=10) 20.97m ± 3% -84.25% (p=0.000 n=10)
| } | ||
| } | ||
| BodyEncoding::Gzip => { | ||
| let mut decoder = GzDecoder::new(BoundedVecWriter { |
There was a problem hiding this comment.
suggestion: Reuse the gzip decoder across scrapes
http.rs:307 builds a new GzDecoder for every response and compression is on by default, so every scrape allocates 74 KiB in 3 allocations that a reset decoder avoids, which Go already does by resetting one gzip.Reader per target (https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L860-L874), could the inflate state live next to the pooled body buffer?
benchstat, darwin/arm64 at https://github.com/prometheus/scrape.rs/commit/aac82206fe8d695743cd2444b2a40e13399a5e88
│ new_decoder_per_scrape │ reset_reused_decoder_scratch32k │
│ B/op │ B/op vs base │
Gzip/body=alltypes.237mfs-2 74.29Ki ± 0% 0.00Ki ± 0% -100.00% (p=0.000 n=10)
│ new_decoder_per_scrape │ reset_reused_decoder_scratch32k │
│ allocs/op │ allocs/op vs base │
Gzip/body=alltypes.237mfs-2 3.000 ± 0% 0.000 ± 0% -100.00% (p=0.000 n=10)
| let mut seen = HashSet::with_capacity(targets.len()); | ||
|
|
||
| for target in targets { | ||
| let key = TargetKey::from_target(&target); |
There was a problem hiding this comment.
suggestion: sync_targets blocks the scheduler for about 26ms at 10k targets
schedule.rs:164 clones each TargetKey twice and hashes its URL and labels on every update even when nothing changed, and an unchanged re-sync of 10k targets with 8 labels took 30.2ms against 4.6ms for cloning the input alone, which means every SD update stops timers and in-flight scrapes on that thread for about 26ms. Hashing Arc<ScrapeTarget> directly would drop the clones.
benchstat, darwin/arm64 at https://github.com/prometheus/scrape.rs/commit/aac82206fe8d695743cd2444b2a40e13399a5e88
│ input_clone_only │ unchanged_resync │
│ sec/op │ sec/op vs base │
SyncTargets/targets=1000/labels=8-2 444.9µ ± 0% 2071.1µ ± 0% +365.50% (p=0.000 n=10)
SyncTargets/targets=10000/labels=8-2 4.578m ± 2% 30.241m ± 4% +560.56% (p=0.000 n=10)
| #[cfg(feature = "arrow")] | ||
| impl Drop for PooledDecoder { | ||
| fn drop(&mut self) { | ||
| self.pool.active_leases.fetch_sub(1, Ordering::Relaxed); |
There was a problem hiding this comment.
question: Should DecoderPool cap retained memory like BodyBufferPool?
pool.rs:302 returns each decoder with all its scratch capacity while BodyBufferPool shrinks to max_retained_body_bytes at pool.rs:169, so in a local test retained_decoder_bytes went from 130 KB to 489 KB after one 1.4 MB scrape and stayed there after 50 small ones, which means the largest target ever scraped sets decoder memory for the life of the manager, do you want a retention cap here too?
| /// Total target scrapes that failed HTTP fetch, size limits, or decoding. | ||
| pub scrapes_failed: u64, | ||
| /// Missed interval ticks caused by slow cycles or targets still in flight at their next tick. | ||
| pub scrapes_overrun: u64, |
There was a problem hiding this comment.
question: Can stats show when a pool limit is saturated?
ManagerStats at manager.rs:111 counts only scrapes_overrun, which needs a full missed interval, and the pools report peaks only, so in a paused-time test with max_in_flight=1 at 93% utilization scrapes started up to 3s late (p50 1.6s) while scrapes_overrun stayed at 0, would a wait count or start-lag counter fit here?
|
|
||
| let mut suite = Suite::from_env("prometheus-scrape-manager").with_alloc(&ALLOC); | ||
|
|
||
| for (wait_label, header_wait) in [("none", Duration::ZERO), ("1ms", Duration::from_millis(1))] { |
There was a problem hiding this comment.
issue: header_wait=1ms rows measure about 2.3ms per fetch
benches/scrape.rs:143 sleeps Duration::from_millis(1) and tokio's sleep has millisecond granularity, so each fetch waits about 2.3ms, which the PR's own table shows as (55.85 - 19.22) / 16 = 2.29ms per target and means the -60% headline in the PR body is for a 2.3ms wait, could the label say 2ms?
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…plete Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…dule heap Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…al counts Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…to 2 decoders Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
… tests Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…lSet loops Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Follows up on prometheus/scrape.rs#48. Part of prometheus/scrape.rs#36.
Adds
prometheus_scrape_manager::scrape::ScrapeManagerwith bounded concurrent jittered target scheduling alongside sequential modes. InSchedulingMode::Concurrent, all targets sit in a single min-heap ordered by deterministic 64-bit FNV-1a phase offsets moduloscrape_interval, while three separate limits decouple network wait concurrency from payload memory:max_in_flightbounds simultaneous HTTP requests waiting on TCP connect and response headers with zero payload buffers allocated,max_buffered_bodiesbounds reusableVec<u8>buffers (and paired resettable gzip inflate states) inBodyBufferPoolacquired only after HTTP200 OKheaders arrive, andmax_decodersbounds cooperativeArrowDecoderinstances inDecoderPool.SchedulingMode::Sequentialremains available with both a job-wide cycle tick viaSequentialSchedule::CycleTickand per-target jittered offsets viaSequentialSchedule::Jittered.Initial commits in reading order (followed by incremental review-fix commits):
ScrapeConfig,BodyBufferPool,DecoderPool, andReqwestFetcherwith Prometheus scrape headers and streaming gzip size caps.TargetScheduleandScrapeManagerdriving!Send-compatibleFuturesUnorderedconcurrent and sequential loops.benchfmt.rsscrape cycle benchmarks and document scheduling and pool contracts inspecs/SPEC.md.Benchmarks via benchstat
Base commit 159418e on
mainhas an emptyprometheus-scrape-managercrate, sobenchstat -col /modeat 6906480 comparesmode=sequential_tickas the baseline column againstmode=concurrent_k8_b2_d1(max_in_flight=8,max_buffered_bodies=2,max_decoders=1) andmode=concurrent_k8_b8_d4(max_in_flight=8,max_buffered_bodies=8,max_decoders=4) across 16 targets servingalltypes.237mfs.prom.txton a single-threaded Tokio runtime.With
~2ms(header_wait=2ms) per-target HTTP header wait,concurrent_k8_b2_d1cuts cycle time by60.32%and raises throughput by152.02%while matchingconcurrent_k8_b8_d4within3%using a single pooled decoder and at most two body buffers. The+7.34%delta inB/opand+0.22%delta inallocs/op(~7.7 KiBand1.1allocations per target scrape) come from the per-scrapeFuturesUnorderedtask frame (includingDecodeCursorstate soPooledDecoderandPooledBodyBufferrelease before finalon_batchawaits) andOwnedSemaphorePermitlease rather than per-sample allocations.Comparison on
header_wait=nonebetween pre-review commit aac8220 (before.txt) and 6906480 (after.txt,+2.84%B/opis+3.1 KiBon theFuturesUnorderedtask state machine from holdingDecodeCursordirectly with0extraallocs/op):Intentional differences from Go Prometheus
scrapeCache, andgzip.Readerper target inscrapePool.sync, and allocates response buffers from an uncappedsync.PoolinscrapeLoop.scrapeAndReportheld untilapp.Commit()finishes.ScrapeManagerinstead keeps all target timers in a singleBinaryHeapand bounds simultaneous body buffers, resettable gzip decoders, andArrowDecoders via semaphore-gated pools, shrinking buffers abovemax_retained_body_bytesand resetting decoders abovemax_retained_decoder_byteson drop so working-set memory stays bounded regardless of target count.Target.offsettotime.Now().UnixNano()modulointervalusing xxhash64 over target labels.target_offsethashesjitter_seed, target URL, and sorted target labels with 64-bit FNV-1a relative to a monotonictokio::time::Instantbase so deterministic virtual-time tests and live reloads preserve exact intervals across wall-clock adjustments.gogoroutines requiring thread-safe appenders, whereasScrapeManagerusesFuturesUnorderedwithouttokio::spawnso!SendLocalSetruntimes inotel-arrowcan passRc<RefCell<...>>sinks without synchronization overhead.