Skip to content

Commit f7d1243

Browse files
authored
SLO: async-query + topic (sync/async) workloads, label-driven, with delivery/ordering validation (#851)
1 parent 660bc17 commit f7d1243

15 files changed

Lines changed: 754 additions & 208 deletions

.github/workflows/slo-report.yml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ on:
1212

1313
jobs:
1414
publish-slo-report:
15-
# SLO workflow runs on every PR event, but the job inside is gated
16-
# on the "SLO" label. Without the label the matrix is skipped and the
17-
# workflow conclusion is "skipped" — in that case there is nothing
18-
# to publish and no label to remove.
19-
if: github.event.workflow_run.conclusion != 'skipped'
15+
# Only act on runs that actually executed to completion. A "skipped"
16+
# conclusion means the matrix was gated out (no "SLO" label) — nothing to
17+
# publish. A "cancelled" conclusion means the run was superseded mid-flight
18+
# (e.g. by `cancel-in-progress` when a new run started) — there is no real
19+
# report and the label must NOT be stripped, otherwise it gets removed long
20+
# before the actual workload finishes. Success/failure are genuine endings.
21+
if: >-
22+
github.event.workflow_run.conclusion == 'success' ||
23+
github.event.workflow_run.conclusion == 'failure'
2024
name: Publish YDB SLO Report
2125
runs-on: ubuntu-latest
2226
permissions:
@@ -31,7 +35,12 @@ jobs:
3135
github_run_id: ${{ github.event.workflow_run.id }}
3236

3337
remove-slo-label:
34-
if: github.event.workflow_run.conclusion != 'skipped'
38+
# Same gating as the report job: only consume the "SLO" label once a run has
39+
# genuinely finished (success/failure). Removing it on a "cancelled" run
40+
# would strip the label mid-flight when a run is superseded.
41+
if: >-
42+
github.event.workflow_run.conclusion == 'success' ||
43+
github.event.workflow_run.conclusion == 'failure'
3544
name: Remove SLO Label
3645
runs-on: ubuntu-latest
3746
permissions:

.github/workflows/slo.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ permissions:
1111

1212
jobs:
1313
ydb-slo-action:
14-
if: contains(github.event.pull_request.labels.*.name, 'SLO')
14+
# On `labeled` events run only when the `SLO` label itself was just added —
15+
# otherwise unrelated label changes (e.g. the AI-review bot toggling
16+
# `ai_review_in_process` / `ai_reviewed`) would spawn a fresh run that
17+
# cancels the in-progress one via `cancel-in-progress`. For the other
18+
# trigger types keep gating on the `SLO` label being present.
19+
if: >-
20+
(github.event.action == 'labeled' && github.event.label.name == 'SLO') ||
21+
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'SLO'))
1522
1623
name: Run YDB SLO Tests
1724
runs-on: "large-runner-python-sdk"
@@ -24,6 +31,16 @@ jobs:
2431
command: "--read-rps 1000 --write-rps 100"
2532
- name: sync-query
2633
command: "--read-rps 1000 --write-rps 100"
34+
- name: async-query
35+
command: "--read-rps 1000 --write-rps 100"
36+
- name: sync-topic
37+
command: "--write-rps 200 --write-threads 8 --read-threads 8"
38+
metrics_yaml_path: sdk-current/tests/slo/metrics-topic.yaml
39+
thresholds_yaml_path: sdk-current/tests/slo/thresholds-topic.yaml
40+
- name: async-topic
41+
command: "--write-rps 200 --write-threads 8 --read-threads 8"
42+
metrics_yaml_path: sdk-current/tests/slo/metrics-topic.yaml
43+
thresholds_yaml_path: sdk-current/tests/slo/thresholds-topic.yaml
2744

2845
concurrency:
2946
group: slo-${{ github.ref }}-${{ matrix.sdk.name }}
@@ -125,3 +142,10 @@ jobs:
125142
workload_baseline_ref: ${{ steps.baseline.outputs.ref }}
126143
workload_baseline_image: ydb-app-baseline
127144
workload_baseline_command: ${{ matrix.sdk.command }}
145+
# Custom metrics (e.g. topic e2e latency / loss) merged on top of the
146+
# action defaults; empty for workloads that don't set it.
147+
metrics_yaml_path: ${{ matrix.sdk.metrics_yaml_path }}
148+
# Per-scenario threshold overrides (topics make read_latency neutral).
149+
# Ignored by action versions without ydb-slo-action#57; self-activates
150+
# once per-scenario thresholds land in the consumed tag.
151+
thresholds_yaml_path: ${{ matrix.sdk.thresholds_yaml_path }}

tests/slo/README.md

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,23 @@ There are two workload types:
1010
- **Table SLO** - tests table operations (read/write)
1111
- **Topic SLO** - tests topic operations (publish/consume)
1212

13-
### Implementations:
13+
### Implementations / labels:
1414

15-
- `sync`
16-
- `async` (now unimplemented)
15+
The workload is selected by a single label (`WORKLOAD_NAME` env var, also the
16+
`sdk.name` matrix value in `.github/workflows/slo.yml`). The sync/async
17+
execution mode is derived from the label itself — an `async-*` label runs the
18+
async (`ydb.aio`) path:
19+
20+
| Label | Service | Mode |
21+
|----------------|---------------|-------|
22+
| `sync-table` | Table service | sync |
23+
| `sync-query` | Query service | sync |
24+
| `async-query` | Query service | async |
25+
| `sync-topic` | Topic service | sync |
26+
| `async-topic` | Topic service | async |
27+
28+
> The `--async` CLI flag is kept as a manual override for `*-run` commands.
29+
> The bare `topic` label is still accepted as an alias for `sync-topic`.
1730
1831
### Usage:
1932

@@ -246,16 +259,15 @@ Table have these fields:
246259
Primary key: `("object_hash", "object_id")`
247260

248261
### Topic workload
249-
When running `topic-run` command, the program creates three jobs: `readJob`, `writeJob`, `metricsJob`.
262+
When running `topic-run` (`sync-topic` / `async-topic`), the program creates `readJob`, `writeJob` and `metricsJob`, and additionally **validates end-to-end delivery and per-producer ordering** under chaos.
250263

251-
- `readJob` reads messages from topic using TopicReader and commits offsets
252-
- `writeJob` generates and publishes messages to topic using TopicWriter
253-
- `metricsJob` periodically sends metrics to Prometheus
264+
- `writeJob` — each writer is pinned to a partition (`partition_id = i % partitions`) with a stable, ref-scoped `producer_id`, and publishes with `write_with_ack`. The seqno advances only on a successful ack (a failed write leaves no gap).
265+
- `readJob` — reads with a consumer, commits offsets, and demultiplexes messages by `writer_id`, tracking the next expected seqno per producer (shared across readers, since a partition can move between them on rebalance):
266+
- a **forward gap** (a seqno past the expected one) is counted as **lost** — partition order is server-guaranteed, so a gap is real loss (fails the run via the `*_error*` threshold);
267+
- a **backward** seqno (already seen) is a **duplicate** — reconnect redelivery; with producer-id dedup it should stay near zero (informational);
268+
- **end-to-end latency** is `read_ts − write_ts` for the first delivery of each message (writer and reader share the process, so the timestamps are comparable).
254269

255-
Messages contain:
256-
- Sequential message ID
257-
- Thread identifier
258-
- Configurable payload size (padded with 'x' characters)
270+
Each message carries `writer_id:seqno:write_ts_ns:` followed by padding to the configured size. Topics are scoped per ref so the current and baseline containers (same cluster, run in parallel) don't share a topic.
259271

260272
## Collected metrics
261273
- `oks` - amount of OK requests
@@ -266,6 +278,12 @@ Messages contain:
266278

267279
Metrics are collected for both table operations (`read`, `write`) and topic operations (`read`, `write`).
268280

281+
Topic workloads additionally emit (surfaced through `tests/slo/metrics-topic.yaml`, merged into the action metrics via `metrics_yaml_path`):
282+
- `topic_e2e_latency_p50_ms` / `_p99_ms` — write → read latency of a delivered message (the meaningful topic latency; the generic `read_latency` mostly reflects `receive_message` wait time, not read cost, so it is kept **informational** for topics via `tests/slo/thresholds-topic.yaml``direction: neutral` — while `write_latency` stays gated)
283+
- `topic_delivered_rps` — unique messages read back per second
284+
- `topic_lost_errors` — messages detected as lost (must stay 0)
285+
- `topic_duplicates` — redelivered messages (informational)
286+
269287
> Note: with Prometheus OTLP receiver (no Pushgateway) counters/histograms are cumulative and cannot be reset to `0`.
270288
> If you need clean separation between runs, use distinct `REF`/`WORKLOAD` (and/or `SLO_INSTANCE_ID`) so each run writes into separate time series.
271289

tests/slo/docker-entrypoint.sh

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,23 @@
55
# in parallel; both must be able to schema-prepare and then run.
66
#
77
# Inputs come from the env vars injected by the action:
8-
# WORKLOAD_NAME sync-table | sync-query | topic
8+
# WORKLOAD_NAME sync-table | sync-query | async-query | sync-topic | async-topic
99
# WORKLOAD_DURATION run duration in seconds
1010
# YDB_ENDPOINT grpc://ydb:2136
1111
# YDB_DATABASE /Root/testdb
1212
#
13+
# The sync/async execution mode is derived from the WORKLOAD_NAME label itself
14+
# (an `async-*` label runs the async path); no extra flag is needed here.
15+
#
1316
# Anything passed after the script name is appended to the `*-run` command —
1417
# this is how tuning flags from `workload_current_command` (e.g. --read-rps)
1518
# reach the workload.
1619

1720
set -e
1821

1922
case "${WORKLOAD_NAME:-sync-query}" in
20-
sync-table|sync-query) PREFIX=table ;;
21-
topic) PREFIX=topic ;;
23+
sync-table|sync-query|async-query) PREFIX=table ;;
24+
topic|sync-topic|async-topic) PREFIX=topic ;;
2225
*)
2326
echo "Unknown WORKLOAD_NAME: ${WORKLOAD_NAME}" >&2
2427
exit 1
@@ -29,12 +32,25 @@ ENDPOINT="${YDB_ENDPOINT:-grpc://localhost:2136}"
2932
DATABASE="${YDB_DATABASE:-/local}"
3033
DURATION="${WORKLOAD_DURATION:-600}"
3134

35+
# Topic paths must live under the database; derive one from $DATABASE so the
36+
# same image works against both local (/local) and CI (/Root/testdb) databases.
37+
# Scope the topic by ref so the current and baseline containers (same cluster,
38+
# run in parallel) don't share a topic — otherwise their readers/producers would
39+
# cross-contaminate delivery/ordering validation.
40+
EXTRA_ARGS=""
41+
if [ "$PREFIX" = "topic" ]; then
42+
REF_RAW="${WORKLOAD_REF:-${REF:-main}}"
43+
SAFE_REF=$(printf '%s' "$REF_RAW" | tr -c 'a-zA-Z0-9_' '_')
44+
EXTRA_ARGS="--path ${DATABASE%/}/slo_topic_${SAFE_REF}"
45+
fi
46+
3247
# Schema prep is idempotent at the SDK level for topics; for tables, a parallel
3348
# baseline container may race and fail with "already exists" — tolerate it.
34-
python ./tests/slo/src "${PREFIX}-create" "$ENDPOINT" "$DATABASE" \
49+
python ./tests/slo/src "${PREFIX}-create" "$ENDPOINT" "$DATABASE" $EXTRA_ARGS \
3550
|| echo "WARN: ${PREFIX}-create exited non-zero (treated as already-prepared)" >&2
3651

3752
exec python ./tests/slo/src \
3853
"${PREFIX}-run" "$ENDPOINT" "$DATABASE" \
3954
--time "$DURATION" \
55+
$EXTRA_ARGS \
4056
"$@"

tests/slo/metrics-topic.yaml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Custom topic-workload metrics, merged on top of the action's default
2+
# deploy/metrics.yaml (see ydb-slo-action shared/metrics.ts mergeMetricConfigs).
3+
# Passed to the action via `metrics_yaml_path` for topic workloads only.
4+
#
5+
# Metric names are chosen to match the default threshold patterns
6+
# (deploy/thresholds.yaml):
7+
# *_latency_* -> lower_is_better
8+
# *_rps -> higher_is_better
9+
# *_error* -> warning_max 0.1 / critical_max 1.0 (fails the run if > 0)
10+
11+
metrics:
12+
- name: topic_e2e_latency_p50_ms
13+
unit: ms
14+
query: |
15+
1000 * max by(ref) (
16+
sdk_topic_e2e_latency_p50_seconds
17+
)
18+
19+
- name: topic_e2e_latency_p99_ms
20+
unit: ms
21+
query: |
22+
1000 * max by(ref) (
23+
sdk_topic_e2e_latency_p99_seconds
24+
)
25+
26+
- name: topic_delivered_rps
27+
unit: msgs/s
28+
query: |
29+
sum by(ref) (
30+
rate(sdk_topic_messages_delivered_total[5s])
31+
)
32+
33+
# Lost messages must stay at zero even under chaos (at-least-once delivery).
34+
# Named *_error* so the default threshold fails the run on any loss.
35+
- name: topic_lost_errors
36+
unit: msgs
37+
query: |
38+
sum by(ref) (
39+
increase(sdk_topic_messages_lost_total[5s])
40+
)
41+
round: 1
42+
43+
# Duplicates are informational: reconnect redelivery can produce them; with
44+
# producer-id dedup they should stay near zero.
45+
- name: topic_duplicates
46+
unit: msgs
47+
query: |
48+
sum by(ref) (
49+
increase(sdk_topic_messages_duplicated_total[5s])
50+
)
51+
round: 1

tests/slo/src/core/metrics.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ def measure(self, labels):
6868
finally:
6969
self.stop(labels, start_ts, error=error)
7070

71+
# --- Topic workload extensions (no-ops unless overridden) ---
72+
def record_e2e(self, seconds: float) -> None:
73+
"""Record an end-to-end (write -> read) message latency, in seconds."""
74+
return None
75+
76+
def inc_delivered(self, n: int = 1) -> None:
77+
"""Count messages successfully read back."""
78+
return None
79+
80+
def inc_lost(self, n: int = 1) -> None:
81+
"""Count messages detected as lost (a forward gap in a producer's seqno)."""
82+
return None
83+
84+
def inc_duplicated(self, n: int = 1) -> None:
85+
"""Count messages detected as duplicates (redelivery of an already-seen seqno)."""
86+
return None
87+
7188

7289
class DummyMetrics(BaseMetrics):
7390
def start(self, labels) -> float:
@@ -164,8 +181,31 @@ def __init__(self, otlp_metrics_endpoint: str):
164181
for name, _ in self._PERCENTILES
165182
}
166183

184+
# Topic-workload metrics: end-to-end (write -> read) latency + delivery counters.
185+
self._topic_e2e_gauges = {
186+
name: self._meter.create_gauge(
187+
name=f"sdk.topic.e2e.latency.{name}.seconds",
188+
unit="s",
189+
description=f"Topic end-to-end latency {name} computed over the last push window.",
190+
)
191+
for name, _ in self._PERCENTILES
192+
}
193+
self._topic_delivered = self._meter.create_counter(
194+
name="sdk.topic.messages.delivered.total",
195+
description="Total number of messages successfully read back from the topic.",
196+
)
197+
self._topic_lost = self._meter.create_counter(
198+
name="sdk.topic.messages.lost.total",
199+
description="Total number of messages detected as lost (forward gap in a producer's seqno).",
200+
)
201+
self._topic_duplicated = self._meter.create_counter(
202+
name="sdk.topic.messages.duplicated.total",
203+
description="Total number of messages detected as duplicates (redelivered seqno).",
204+
)
205+
167206
self._lock = threading.Lock()
168207
self._hdr: dict = {}
208+
self._e2e_hdr = self._HdrHistogram(self._HDR_MIN_US, self._HDR_MAX_US, self._HDR_SIG_FIGS)
169209

170210
def _get_hdr(self, op_type: str, op_status: str):
171211
key = (op_type, op_status)
@@ -223,14 +263,36 @@ def push(self) -> None:
223263
self._latency_gauges[name].set(value_s, attributes=attrs)
224264
for hist in self._hdr.values():
225265
hist.reset()
266+
267+
if self._e2e_hdr.get_total_count() > 0:
268+
attrs = {"ref": REF}
269+
for name, percentile in self._PERCENTILES:
270+
value_s = self._e2e_hdr.get_value_at_percentile(percentile) / 1_000_000
271+
self._topic_e2e_gauges[name].set(value_s, attributes=attrs)
272+
self._e2e_hdr.reset()
226273
self._provider.force_flush()
227274

228275
def reset(self) -> None:
229276
with self._lock:
230277
for hist in self._hdr.values():
231278
hist.reset()
279+
self._e2e_hdr.reset()
232280
self._provider.force_flush()
233281

282+
def record_e2e(self, seconds: float) -> None:
283+
duration_us = min(max(int(seconds * 1_000_000), self._HDR_MIN_US), self._HDR_MAX_US)
284+
with self._lock:
285+
self._e2e_hdr.record_value(duration_us)
286+
287+
def inc_delivered(self, n: int = 1) -> None:
288+
self._topic_delivered.add(int(n), attributes={"ref": REF})
289+
290+
def inc_lost(self, n: int = 1) -> None:
291+
self._topic_lost.add(int(n), attributes={"ref": REF})
292+
293+
def inc_duplicated(self, n: int = 1) -> None:
294+
self._topic_duplicated.add(int(n), attributes={"ref": REF})
295+
234296

235297
def _resolve_metrics_endpoint(cli_endpoint: Optional[str]) -> str:
236298
"""

0 commit comments

Comments
 (0)