Skip to content

SLO: async-query + topic (sync/async) workloads, label-driven, with delivery/ordering validation - #851

Merged
vgvoleg merged 19 commits into
mainfrom
slo-async-query-workload
Jul 2, 2026
Merged

SLO: async-query + topic (sync/async) workloads, label-driven, with delivery/ordering validation#851
vgvoleg merged 19 commits into
mainfrom
slo-async-query-workload

Conversation

@vgvoleg

@vgvoleg vgvoleg commented Jun 30, 2026

Copy link
Copy Markdown
Member

Extends the SLO suite with new label-driven workloads and hardens the topic path.

Workloads (selected by the WORKLOAD_NAME label; async-* runs the ydb.aio path)

  • async-query — query-service workload on ydb.aio (closed-loop, mirrors the sync model for comparable metrics).
  • sync-topic / async-topic — topic workloads that validate end-to-end delivery and per-producer ordering under chaos: partition-pinned writers with a stable ref-scoped producer_id; readers demux by writer_id and track the next expected seqno (shared, so a consumer-group rebalance doesn't report a false gap). A forward gap = lost (fails the run via the *_error* threshold); a backward seqno = duplicate (reconnect redelivery, informational); plus end-to-end latency (write→read, same process). New topic metrics surfaced via tests/slo/metrics-topic.yaml (merged through metrics_yaml_path). See tests/slo/TOPIC_SCENARIO.md.

Hang-proofing (topic)

The SLO caught a real hang: under chaos the async topic writer wedged in the SDK's GZIP encode executor (cannot schedule new futures after shutdown) and, awaited without a timeout, silently stalled the run at ~half duration. Hardened so this can never silently hang: every write_with_ack/receive_message/commit_with_ack is bounded by a timeout, the writer/reader self-heal (recreate on failure, seqno persists), and the codec is RAW (skips the buggy executor path; RAW==GZIP throughput on small messages). The underlying SDK bug is tracked in #852.

Workflow fixes

  • SLO only (re)triggers when the SLO label itself is added — unrelated label changes (e.g. the AI-review bot) no longer spawn a run that cancels the in-progress one.
  • slo-report consumes the label / publishes only on a genuine success/failure, not on cancelled (so a superseded run no longer strips the label mid-flight).

Notes

  • Topic profile set to 200 rps / 8 writers / 8 readers / 8 partitions — a reliable point comfortably below the observed chaos ceiling (async self-balances ~266 rps; at 1000 rps the reader can't keep up).
  • Backward-compatible: only extends tests/slo/, ydb/ (the SDK) is unchanged.

Async mode is now derived from the workload label (async-*); add an
async query job manager and run_async path for the table runner, plus
the async-query entry in the SLO CI matrix.
@codecov-commenter

codecov-commenter commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.29%. Comparing base (cefc3da) to head (b157181).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #851   +/-   ##
=======================================
  Coverage   81.29%   81.29%           
=======================================
  Files          94       94           
  Lines       12109    12109           
  Branches     1184     1184           
=======================================
  Hits         9844     9844           
  Misses       1802     1802           
  Partials      463      463           
Flag Coverage Δ
integration 79.10% <ø> (+0.03%) ⬆️
unit 47.36% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@robot-vibe-db robot-vibe-db Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Verdict: ❌ 1 critical issue(s) found

Critical issues

  • Critical | High: async-table label accepted by docker-entrypoint but crashes at runtime in AsyncTableJobManager.run_tests()tests/slo/docker-entrypoint.sh:23

Other findings

  • Major | Medium: Async metric sender pushes ~1000x too frequently vs sync (pre-existing bug propagated to new base class) — tests/slo/src/jobs/base.py:111
  • Minor | High: asyncio.get_event_loop() deprecated since Python 3.10; should be asyncio.get_running_loop()tests/slo/src/jobs/base.py:116
  • Minor | Medium: README label table is incomplete — missing async-table, sync-topic, async-topic that the entrypoint accepts — tests/slo/README.md:24
  • Nit | Low: --read-threads help text says "write" and --write-threads says "read" (pre-existing, not introduced by this PR) — tests/slo/src/options.py

This review was generated automatically. Critical issues require attention; other findings are advisory.
If this comment was useful, please give it a 👍 — it helps us improve the review bot.

Comment thread tests/slo/docker-entrypoint.sh Outdated
case "${WORKLOAD_NAME:-sync-query}" in
sync-table|sync-query) PREFIX=table ;;
topic) PREFIX=topic ;;
sync-table|sync-query|async-query|async-table) PREFIX=table ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Critical
Confidence: High

The case statement accepts async-table as a valid WORKLOAD_NAME, routing it to PREFIX=table. At runtime, _is_async_workload() detects the async- prefix and calls TableRunner.run_async(), which creates an AsyncTableJobManager. But AsyncTableJobManager.run_tests() explicitly rejects anything other than "async-query":

if self.workload_type != "async-query":
    raise ValueError(f"Unsupported async workload type: {self.workload_type}")

This means WORKLOAD_NAME=async-table passes entrypoint validation but crashes at runtime with a ValueError.

Either remove async-table from the case statement (if async table-service support is not yet implemented), or update AsyncTableJobManager.run_tests() to handle it.

Comment thread tests/slo/src/jobs/base.py Outdated
start_time = time.time()
logger.info("Start push metrics (async)")

limiter = AsyncLimiter(max_rate=10**6 // self.args.report_period, time_period=1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Major
Confidence: Medium

The rate calculation 10**6 // self.args.report_period with the default report_period=1000 (milliseconds) yields max_rate=1000 pushes per second. Compare with the sync version in BaseJobManager._metric_sender() which uses SyncRateLimiter(min_interval_s=report_period_ms / 1000.0) — that gives 1 push per second at the same default.

The async metric sender pushes metrics ~1000x more frequently than the sync one. This is a pre-existing bug (moved from async_topic_jobs.py), but since this PR copies it into the shared base class where it will affect all future async workloads, it is worth fixing now.

Suggested fix:

limiter = AsyncLimiter(max_rate=1, time_period=self.args.report_period / 1000.0)

Comment thread tests/slo/src/jobs/base.py Outdated
while time.time() - start_time < runtime:
async with limiter:
# Call sync metrics.push() in executor to avoid blocking the event loop.
await asyncio.get_event_loop().run_in_executor(None, self.metrics.push)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Minor
Confidence: High

asyncio.get_event_loop() has been deprecated since Python 3.10 and emits a DeprecationWarning in some contexts. Since this code always runs inside an async function (within a running event loop), asyncio.get_running_loop() is the correct replacement — it is guaranteed to return the running loop and never creates a new one.

Suggested fix:

await asyncio.get_running_loop().run_in_executor(None, self.metrics.push)

Comment thread tests/slo/README.md
@robot-vibe-db

robot-vibe-db Bot commented Jun 30, 2026

Copy link
Copy Markdown

Full analysis log

Analysis performed by claude, claude-opus-4-6.

A labeled event for any label (e.g. the AI-review bot toggling
ai_review_in_process / ai_reviewed) spawned a fresh run that cancelled
the in-progress one via cancel-in-progress. Gate the job on the added
label being SLO for labeled events.
@github-actions github-actions Bot removed the SLO label Jun 30, 2026
vgvoleg added 2 commits June 30, 2026 23:15
slo-report fired on any non-skipped SLO workflow_run, so a run cancelled
mid-flight (superseded via cancel-in-progress) still removed the SLO
label and published an empty report. Gate both jobs on a genuine
success/failure conclusion.
Times the local-ydb/debian pulls on the runner (invisible under the
action's --quiet-build) and warms the local store. Remove before merge.
@vgvoleg vgvoleg added SLO and removed SLO labels Jul 1, 2026
vgvoleg added 2 commits July 1, 2026 10:06
Closed-loop with read_threads workers capped throughput at
workers/latency (~500 read rps at 8 workers x 16ms), never reaching the
1000 target. Switch to an open-loop generator: evenly paced submission
at the target rps, each request its own task, in-flight bounded by a
semaphore (backpressure). Throughput now holds the target regardless of
per-request latency; read_threads/write_threads act as on/off gates.
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
vgvoleg added 2 commits July 1, 2026 11:04
Open-loop at MAX_INFLIGHT=256 flooded the single event loop: since the
loop is CPU/throughput-bound (~480 read rps under cpus:2.0), extra
concurrency just queued - read throughput fell 483->340 rps and p50
latency exploded 16->653 ms (Little's law: 256/340). The closed-loop
with ~8 in-flight already sits near the single-loop ceiling at minimal
latency, so restore it.
Rework the async topic workload to validate end-to-end delivery and
per-producer ordering under chaos:
- writers pinned to partitions (partition_id = i % N) with a stable,
  ref-scoped producer_id; each message carries writer_id:seqno:write_ts_ns
- reader demuxes by writer_id and tracks the next expected seqno:
  forward gap = lost (fails via *_error* threshold), backward = duplicate
  (informational; reconnect redelivery), and computes end-to-end latency
  (same process, so the write ts is comparable)
- new topic metrics (e2e latency p50/p99, delivered rps, lost, duplicates)
  emitted via OTLP and surfaced through tests/slo/metrics-topic.yaml
  (merged into the action's metrics via metrics_yaml_path)
- topics are scoped per ref so current/baseline don't share a topic
- async-topic added to the SLO CI matrix
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
Two sources of false 'lost' (first async-topic run showed avg ~81/window
while delivery kept up at 100 rps and availability was 100%):
- per-reader expected-seqno state saw a forward gap whenever a partition
  moved between readers on a consumer-group rebalance (chaos reconnect).
  Share the expected map across readers (safe: single event loop, no await
  in _validate) so a handoff continues the sequence.
- a failed write_with_ack still advanced the local seqno, leaving a gap for
  a message that was never written. Advance seqno only on success.
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
Redelivered (duplicate) messages carry an old write_ts, so recording
their e2e latency inflated p50 (~42ms -> ~210ms once rebalance redelivery
appeared) and their reads spiked delivered_rps. Record e2e and count
delivered only on the first delivery of a seqno; duplicates are counted
separately.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the SLO harness to support label-driven workload selection (including async-* running via ydb.aio) and hardens topic workloads for chaos testing by validating delivery/ordering and bounding all potentially blocking operations with timeouts/recreate loops.

Changes:

  • Add async-query (query service) workload implemented via ydb.aio + async job managers.
  • Harden sync-topic / async-topic workloads with per-producer seqno validation (lost/duplicate detection) and end-to-end latency metrics.
  • Adjust GitHub workflows to (re)trigger SLO only when the SLO label itself is added, and to publish/remove labels only on genuine success/failure (not cancelled).

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/slo/src/runners/table_runner.py Adds async table runner entrypoint (run_async) for query-service workload.
tests/slo/src/root_runner.py Derives async vs sync execution from workload label (async-*) with --async override.
tests/slo/src/options.py Updates topic default partitions to 8 and retains CLI options used by new workloads.
tests/slo/src/jobs/topic_payload.py Adds shared encode/decode helpers for topic payload validation.
tests/slo/src/jobs/topic_jobs.py Hardens sync topic writer/reader loops, adds seqno-based validation + counters/e2e recording.
tests/slo/src/jobs/base.py Introduces AsyncBaseJobManager with async metrics push task.
tests/slo/src/jobs/async_topic_jobs.py Hardens async topic writer/reader loops, adds shared validation/counters/e2e recording.
tests/slo/src/jobs/async_table_jobs.py Adds async query workload job manager using ydb.aio.QuerySessionPool.
tests/slo/src/core/metrics.py Extends metrics with topic e2e gauges + delivered/lost/duplicated counters and recording methods.
tests/slo/README.md Documents label-driven workloads and topic delivery/ordering validation semantics + new metrics.
tests/slo/metrics-topic.yaml Adds PromQL queries for topic e2e/delivery/loss/duplicate metrics to merge into action defaults.
tests/slo/docker-entrypoint.sh Derives per-ref topic path and supports new workload labels in container entrypoint.
.github/workflows/slo.yml Adds matrix entries for async-query + sync/async topic; refines triggering to SLO label addition.
.github/workflows/slo-report.yml Publishes report / removes SLO label only on success/failure (not cancelled/skipped).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/slo/src/jobs/base.py Outdated
Comment on lines +111 to +116
limiter = AsyncLimiter(max_rate=10**6 // self.args.report_period, time_period=1)

while time.time() - start_time < runtime:
async with limiter:
# Call sync metrics.push() in executor to avoid blocking the event loop.
await asyncio.get_event_loop().run_in_executor(None, self.metrics.push)
Comment on lines +124 to +147
ts = self.metrics.start((OP_TYPE_READ,))
try:
msg = reader.receive_message(timeout=read_timeout)
self.metrics.stop((OP_TYPE_READ,), ts)
except TimeoutError as e:
# No message within read_timeout: at steady rate this
# means the reader is starved (outage/stall), so it is
# a read failure (visible), not a silent wait.
self.metrics.stop((OP_TYPE_READ,), ts, error=e)
continue
except Exception as e:
self.metrics.stop((OP_TYPE_READ,), ts, error=e)
logger.error("Read error (recreating reader): %s", e)
break

if msg is None:
continue

self._validate(msg)

try:
reader.commit_with_ack(msg, timeout=read_timeout)
except Exception as e:
logger.error("Commit error: %s", e)
Comment on lines +118 to +141
ts = self.metrics.start((OP_TYPE_READ,))
try:
msg = await asyncio.wait_for(reader.receive_message(), read_timeout)
self.metrics.stop((OP_TYPE_READ,), ts)
except asyncio.TimeoutError as e:
# No message within read_timeout: at steady rate this
# means the reader is starved (outage/stall), so it is
# a read failure (visible), not a silent wait.
self.metrics.stop((OP_TYPE_READ,), ts, error=e)
continue
except Exception as e:
self.metrics.stop((OP_TYPE_READ,), ts, error=e)
logger.error("Read error (recreating reader): %s", e)
break

if msg is None:
continue

self._validate(msg)

try:
await asyncio.wait_for(reader.commit_with_ack(msg), read_timeout)
except Exception as e:
logger.error("Commit error: %s", e)
Comment thread tests/slo/docker-entrypoint.sh Outdated

@robot-vibe-db robot-vibe-db Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Verdict: ❌ 1 critical issue(s) found

Critical issues

  • Major | High: Async metrics push rate is ~1000x too high due to wrong formula — tests/slo/src/jobs/base.py:111

Other findings

  • Minor | Medium: Stale messages from a cancelled previous run can corrupt the _expected seqno tracker, producing false duplicates and invalid e2e latency — tests/slo/src/jobs/async_topic_jobs.py:148
  • Nit | Low: asyncio.get_event_loop() is soft-deprecated since Python 3.10; prefer asyncio.get_running_loop()tests/slo/src/jobs/base.py:116

This review was generated automatically. Critical issues require attention; other findings are advisory.
If this comment was useful, please give it a 👍 — it helps us improve the review bot.

Comment thread tests/slo/src/jobs/base.py Outdated
start_time = time.time()
logger.info("Start push metrics (async)")

limiter = AsyncLimiter(max_rate=10**6 // self.args.report_period, time_period=1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Major
Confidence: High

The rate formula 10**6 // self.args.report_period is off by 1000x. report_period is in milliseconds (default 1000, per options.py), so this computes:

  • 10**6 // 1000 = 1000 pushes per second

The sync counterpart in BaseJobManager._run_metric_job() (line 62) correctly converts:

SyncRateLimiter(min_interval_s=report_period_ms / 1000.0)  # 1000ms → 1.0s → 1 push/sec

The async version should mirror that rate. With the current formula, the async metrics sender pushes ~1000x more frequently than intended, which will waste CPU/network on OTLP exports and may overwhelm the Prometheus endpoint.

Suggested fix:

limiter = AsyncLimiter(max_rate=max(1, 1000 // self.args.report_period), time_period=1)

Or, for non-integer rates (e.g. report_period=3000 → 0.33 pushes/sec):

limiter = AsyncLimiter(max_rate=1, time_period=max(0.001, self.args.report_period / 1000.0))


logger.info("Stop async topic reader %s", reader_id)

def _validate(self, msg) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Minor
Confidence: Medium

If a previous SLO run with the same ref was cancelled mid-flight (cancel-in-progress: true), its uncommitted messages may still be in the topic. When the new run's reader picks them up:

  1. The stale message's seqno (e.g. 100) sets _expected[writer_id] = 101.
  2. All subsequent real messages from the current run (seqno 1, 2, …, 100) are classified as duplicates, since seqno < 101.
  3. The write_ts_ns from the stale message is a monotonic_ns() value from a different process invocation — the resulting e2e latency is meaningless (clamped to 1 µs or 60 s by record_e2e).

The same issue applies to topic_jobs.py:154 (sync variant).

Possible mitigations:

  • Embed a per-run nonce (e.g. PID or UUID) in the payload header so the reader can discard messages from a previous run.
  • Or call topic-cleanup + topic-create in the entrypoint before topic-run to start with a clean topic.

This may not matter in practice if the consumer offset was committed past the old messages, but under cancellation the commit is not guaranteed.

Comment thread tests/slo/src/jobs/base.py Outdated
while time.time() - start_time < runtime:
async with limiter:
# Call sync metrics.push() in executor to avoid blocking the event loop.
await asyncio.get_event_loop().run_in_executor(None, self.metrics.push)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Nit
Confidence: Low

asyncio.get_event_loop() emits a deprecation warning in Python 3.10+ when called outside of an async context. Here it is called inside an async def, so a running loop exists and it works fine, but asyncio.get_running_loop() (available since 3.7) is the recommended replacement and communicates the intent more clearly.

await asyncio.get_running_loop().run_in_executor(None, self.metrics.push)

@robot-vibe-db

robot-vibe-db Bot commented Jul 1, 2026

Copy link
Copy Markdown

Full analysis log

Analysis performed by claude, claude-opus-4-6.

…old commit into read metric

- docker-entrypoint: remove async-table (only async-query is implemented;
  it passed entrypoint validation then crashed at runtime).
- base async metric sender: pushed ~1000x too often (max_rate=1e6//period);
  now one push per report_period (guards period=0), and use get_running_loop().
- topic readers (sync+async): the read op now spans receive+commit, so a
  commit failure / None message is reflected in read metrics instead of only
  being logged; a receive timeout still counts as a starvation error.
- options: fix swapped --read/--write-threads help text.
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
@vgvoleg vgvoleg added the SLO label Jul 1, 2026
@github-actions github-actions Bot removed the SLO label Jul 1, 2026
The generic read_latency for topics is receive_message wait time (not a
read cost) and is noisy, tripping the global *_latency_* regression
threshold on small-number deltas; the real read-side latency is the
custom topic_e2e_latency_*. Add tests/slo/thresholds-topic.yaml
(read_latency_* -> direction: neutral) and wire it per topic scenario via
the init thresholds_yaml_path input (ydb-slo-action#57 per-scenario
thresholds). Ignored on action versions without that feature, so it is
harmless now and self-activates once it lands; write_latency and
table/query keep the strict global thresholds.
@vgvoleg vgvoleg added the SLO label Jul 2, 2026
@github-actions github-actions Bot removed the SLO label Jul 2, 2026
Under chaos the p99 is dominated by node-kill outage spikes (and async
single-event-loop scheduling jitter), uncorrelated between the current
and baseline containers, so the p99 current-vs-baseline delta is noise.
Keep p50/p95 (write ack, e2e delivery) and delivered/lost/availability
gated; only the p99 tails and the receive-wait read_latency are neutral.
@vgvoleg vgvoleg added the SLO label Jul 2, 2026
@github-actions github-actions Bot removed the SLO label Jul 2, 2026
@vgvoleg vgvoleg added the SLO label Jul 2, 2026
@github-actions github-actions Bot removed the SLO label Jul 2, 2026
@vgvoleg vgvoleg added the SLO label Jul 2, 2026
@github-actions github-actions Bot removed the SLO label Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🌋 SLO Test Results

🔴 5 workload(s) tested — 1 workload(s) exceeded failure thresholds

Commit: 1af1530 · View run

Workload Thresholds Duration Report
sync-table 🟢 OK 10m 32s 📄 Report
async-topic 🟢 OK 10m 3s 📄 Report
sync-topic 🔴 Failure 10m 7s 📄 Report
async-query 🟢 OK 10m 3s 📄 Report
sync-query 🟢 OK 10m 11s 📄 Report

Threshold violations:

sync-topic:

  • write_retry_attempts: ▲ 300.0% (≥ 50% fail)

Generated by ydb-slo-action

@vgvoleg
vgvoleg merged commit f7d1243 into main Jul 2, 2026
53 of 54 checks passed
@vgvoleg
vgvoleg deleted the slo-async-query-workload branch July 2, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants