mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-15 16:32:34 +00:00
chore: backport selected changes for 1.3.0-beta.2 (#9529)
* ci(query-regression): bump RUNNER_IMAGE_EPOCH to 7 (#9395) ci(query-regression): bump RUNNER_IMAGE_EPOCH to 7 for image m-0xihbfzm5xpbxolybo6i Signed-off-by: greptimedb-ci <greptimedb-ci@greptime.com> Co-authored-by: greptimedb-ci <greptimedb-ci@greptime.com> (cherry picked from commit886e02f0dc) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): apply offset to subquery evaluation window (#9364) * fix(promql): apply offset to subquery evaluation window `prom_subquery_expr_to_plan` destructured `SubqueryExpr` without reading `offset`, so `<subquery>[range:step] offset <d>` planned exactly the same window as the un-offset form and silently returned data for the wrong time range. The plain vector/matrix-selector paths already threaded the offset through `selector_to_series_normalize_plan` and `RangeManipulate`. Shift the inner evaluation window back by the offset and pass the offset to the subquery's `RangeManipulate`, which maps the inner samples forward onto the evaluation timeline before bucketing them into ranges. This matches Prometheus, whose `evaluator.subqueryTimeRange` evaluates the inner expression over `(start - offset - range, end - offset]` and whose `evalSubquery` then hands the samples to the outer range-vector function as a `MatrixSelector` that still carries the subquery offset. An offset on the inner selector composes additively, as `subqueryTimes` documents. `RangeManipulate`'s protobuf message has no offset field and recovers it on decode from an immediately underlying `SeriesNormalize`. Since `RangeManipulate` is commutative in `dist_plan` and can be pushed below a `MergeScan`, insert that carrier node so the offset survives distributed planning instead of decoding as zero. Known divergence, unchanged by this commit: Prometheus anchors subquery step points on absolute epoch multiples of the step, while GreptimeDB anchors them on the evaluation start. The two agree whenever the offset is a multiple of the subquery step; the added sqlness cases stay within that range. Closes #9330 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Aarav <aaravsjadav@gmail.com> * docs(promql): correct the subquery-offset rationale and pin the histogram shift Follow-up toe132c267, which stated two things inaccurately and left one path untested. No behaviour change beyond a conditional clone. The `SeriesNormalize` carrier was described as existing "so the offset survives distributed planning", implying standalone is unaffected. That is wrong. `local_offset` is read on the substrait decode path (`range_manipulate.rs`, `instant_manipulate.rs`), and GreptimeDB routes PromQL plans through `MergeScan`/substrait in standalone too -- the standalone results `promql/encode_substrait.result` and `precisions.result` both show `MergeScan [is_placeholder=false, remote_input=[...]]`. Deleting the carrier therefore empties subquery-with-offset results in standalone as well, while `count_over_time_subquery_with_offset` keeps passing because it asserts the pre-serialization plan. Say so at the node, so the next reader does not remove it believing standalone is safe. The carrier is also not semantically inert: `SeriesNormalizeStream::normalize` shifts native histogram `start_timestamp` payloads by `offset`. On the subquery path that runs on a computed inner result, on top of any offset the inner selector already applied, and nothing pinned it. It is consistent -- `RangeManipulate` moves the millisecond time index forward by the same `offset_ms` -- so the distance from a histogram's start timestamp to the sample carrying it, which reset/rate detection reads, is invariant. Add `subquery_offset_shifts_histogram_start_and_time_index_together`, which executes the subquery's node stack over native histogram samples for a zero and a non-zero inner offset. It cannot be a sqlness case: native histograms are a struct column with no SQL type or literal (`sql_data_type_to_concrete_data_type` rejects structs) and the sqlness runner speaks only MySQL/Postgres, so such rows only arrive over gRPC/remote-write v2. The divergence note blamed sub-step offsets. Measured, that condition is too narrow: an unaligned evaluation timestamp alone diverges, with no offset at all -- `sum_over_time(fine[20s:10s])` at t=57 samples 47s and 57s here against Prometheus's 40s and 50s. The real condition is that `(start - offset)` is not a multiple of the subquery step, and it is pre-existing: the committed `tql eval (359, 359, '1s') sum_over_time(metric_total[60s:10s])` case is already an instance of it (`359 - 60 + 10 = 309`). Reword the comment and the sqlness header accordingly. A sub-step offset stays accepted: `foo[20s:10s] offset 5s` is valid PromQL, erroring on it would be a regression and would not close the gap anyway. This repo has no PromQL-compatibility documentation page, so there is nowhere else to record it. Also stop cloning the series key columns when there is no offset, and stop the `offset 0s` sqlness comment implying its parser error is evidence about this fix -- the same message comes from a `u32` overflow in `promql-parser`'s shared duration check (`offset 9999999999d`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Aarav <aaravsjadav@gmail.com> * refactor(promql): trim subquery-offset comments and tests per review Shorten the planner and sqlness comments to the semantic contract, build `SeriesDivide` with a clone instead of an `Option`, and drop the normalize.rs histogram test and the `offset 0s` parser case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvab9gpXYjKyLN8Mo6JWWL Signed-off-by: Aarav <aaravsjadav@gmail.com> * fix(promql): record the subquery offset as the range fold offset The subquery's `RangeManipulate` now shifts the payload timestamps by the offset, so `predict_linear` over an offset subquery must recover the evaluation time with that offset instead of 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvab9gpXYjKyLN8Mo6JWWL Signed-off-by: Aarav <aaravsjadav@gmail.com> --------- Signed-off-by: Aarav <aaravsjadav@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commitd6474b961c) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: retry failed nightly release on following weekdays (#9394) Previously the nightly release was scheduled only on Mondays, so a failed run left users without nightly builds for a whole week. Now the schedule triggers every weekday at 00:00 UTC, but the release only proceeds when the latest published nightly release is older than NIGHTLY_RELEASE_MAX_AGE_DAYS (5) days. The check runs in allocate-runners before any EC2 runner is allocated, and gates all schedule-driven jobs (including the Slack notification, so a skipped nightly is silent). Tag pushes and manual dispatches are unaffected. Signed-off-by: Ning Sun <sunning@greptime.com> (cherry picked from commita7f99b3425) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(client): yield Flight batches and affected rows without waiting for next message (#8918) * fix(client): avoid Flight metrics lookahead stalls Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * refactor(client): extract trailing Flight metrics task Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(client): synchronize trailing metrics and bound cancellation Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commitabf1396c28) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): resolve dotted column names as unqualified columns (#9391) * fix(promql): resolve dotted column names as unqualified columns col(), From<&str>/From<String> for Column and string join keys go through Column::from_qualified_name, which splits `service.name` into relation `service` and column `name` and lowercases unquoted identifiers. Build PromQL column references with Column::from_name / ident() instead. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(servers): resolve remote read matcher labels as unqualified columns Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover same-name columns differing in case and without() Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit218000e21b) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): align timestamp(), label_join and label_replace with Prometheus semantics (#9385) * fix(promql): align timestamp() and label_join with Prometheus semantics - timestamp() over a selector reports the selected sample's timestamp, without adding the offset. - timestamp() over any other expression reports the evaluation time instead of the input value. - label_join that overwrites an existing label rejects duplicate label sets at runtime. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): align label_replace and label_join edge cases with Prometheus - label_replace leaves non-matching series unchanged instead of copying the source value into the destination label. - label_replace may overwrite an existing label; duplicate label sets are rejected at runtime like label_join. - label_join with no source labels removes the destination label, and an empty source label name is rejected. - Empty label values produced by these functions are NULL, the same as an absent label. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): join absent labels as empty strings in label_join concat_ws skips NULL arguments together with their separator, so a label that is absent (NULL) dropped its separator, e.g. label_join(label_join(vector(1), "a", "", "missing"), "b", "-", "a", "a") lost b="-". Read every source label as coalesce(label, '') like label_replace does. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): skip rows without a sample in timestamp() timestamp() replaced the input fields with the timestamp before the empty-value filter ran, so a row whose fields were all NULL got a timestamp, e.g. timestamp(-m) for a NULL sample. Filter such rows before the projection, for both the selector and the evaluation-time paths. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit78a7b93292) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito2): truncate parquet column index min/max for SST writes (#9420) * fix(mito2): truncate parquet column index min/max for SST writes The SST writer and the bulk part encoder disabled column index truncation together with statistics truncation in #6977. Mito never reads the column index, but every page of a large string or binary column still stored its full min and max values, uncompressed. Truncate the column index to the parquet default of 64 bytes and keep chunk statistics untruncated, since primary key pruning decodes them. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * docs(mito2): note that the column index must not be used to decode primary keys Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commita314ac4281) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: upgrade EC2 runner action to restore runner registration (#9430) ci: update EC2 runner action for minimum runner version enforcement Signed-off-by: evenyag <realevenyag@gmail.com> (cherry picked from commit0d502e1fc1) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(catalog): skip backend batch_get on full cache hits (#9431) fix(catalog): return cached batch values without an empty backend call Count backend batch_get invocations in regression tests and cover empty input. Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commitacebd6b35b) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: make query regression non-blocking for scheduled nightly releases (#9434) * ci: make query regression non-blocking for scheduled nightly releases Scheduled nightly releases no longer gate publishing on the query regression release test. The test still runs (validation policy stays 'all' for automatic releases) and uploads its report, but any result - failure, timeout, cancellation, or skip - no longer blocks image publishing or the GitHub release for schedule-triggered runs. Since reusable-workflow caller jobs cannot use continue-on-error, the gate is relaxed at the consumers instead: the if conditions of release-images-to-dockerhub and publish-github-release now also pass when github.event_name == 'schedule' regardless of the query-regression result. All other gates (runner allocation, artifact builds, prepare-release-validation) are unchanged. Tag-push and manual-dispatch releases remain fully blocking unless a skip policy is explicitly chosen via the release_validation input. Signed-off-by: Ning Sun <sunning@greptime.com> * ci: preserve downstream nightly release jobs after regression failures Signed-off-by: evenyag <realevenyag@gmail.com> --------- Signed-off-by: Ning Sun <sunning@greptime.com> Signed-off-by: evenyag <realevenyag@gmail.com> Co-authored-by: evenyag <realevenyag@gmail.com> (cherry picked from commita9ce6e2d01) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: coalesce equal histogram quantile bucket boundaries (#9451) * fix: coalesce equal histogram quantile bucket boundaries Merge numerically equivalent bucket boundaries before monotonicity repair, preserving the allocation-free path for unique boundaries. Share coalescing with histogram_fraction and cover duplicate boundaries with unit and SQL regressions. Fixes #9443 Signed-off-by: evenyag <realevenyag@gmail.com> * fix: keep duplicate infinity buckets in one histogram group Confirm classic histogram completion at a series/timestamp boundary or EOF instead of the first positive infinity. Retain lookahead across batches and reject incomplete optimistic groups so equivalent infinity labels reach numeric coalescing together. Add stream and SQL regressions for duplicate infinity labels, including established bucket counts and EOF. Correct older fixtures to use distinct keys for distinct histogram groups. Signed-off-by: evenyag <realevenyag@gmail.com> * fix: avoid buffering histogram groups with invalid trailing bounds Discover candidate bucket counts at the first group boundary and let optimistic validation fall back to safe mode for malformed bounds. Document discovery and cover trailing invalid and null labels with a focused streaming regression. Signed-off-by: evenyag <realevenyag@gmail.com> --------- Signed-off-by: evenyag <realevenyag@gmail.com> (cherry picked from commit97913b4b3a) Signed-off-by: WenyXu <wenymedia@gmail.com> * test(sqlness): wait for mirrored inserts before flushing streaming flows (#9424) Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitb78dc4b33b) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: accept a bool value for the CLI --auto-create-schema flag (#9450) * fix: accept a value for the CLI --auto-create-schema flag clap derives ArgAction::SetTrue for bool fields, so --auto-create-schema took no value and could only set true, which is already the default. Users could not turn automatic schema creation off from the CLI, unlike metasrv's auto_create_schema = false. Set action = clap::ArgAction::Set so the flag accepts an explicit true/false value while keeping true as the default. Signed-off-by: fengyue-xve <1759519137@qq.com> * chore: enhance CLI argument for auto_create_schema option Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: fengyue-xve <1759519137@qq.com> Co-authored-by: dennis zhuang <killme2008@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> (cherry picked from commit329edc88ec) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(otlp): coerce empty and "-" trace attribute strings to null (#9461) * fix(otlp): coerce empty and "-" trace attribute strings to null Instrumentations fill numeric attributes from absent headers with an empty string (PSR-7 getHeaderLine returns ""). Coercing that into an existing numeric column failed and rejected the whole span. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: trim redundant row from empty-string rewrite test Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitd640ab8bc5) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(meta-client): bound heartbeat handshake timeout (#9438) * fix(meta-client): bound heartbeat handshake timeout Signed-off-by: Aetherance <inkToAether@gmail.com> * chore(meta-client): fix test license header Signed-off-by: Aetherance <inkToAether@gmail.com> * test(meta-client): prefix heartbeat tests with test Signed-off-by: Aetherance <inkToAether@gmail.com> --------- Signed-off-by: Aetherance <inkToAether@gmail.com> (cherry picked from commit9c69e381ae) Signed-off-by: WenyXu <wenymedia@gmail.com> * docs(config): align example configs and config.md with actual defaults (#9460) * docs(config): align example configs and config.md with actual defaults Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(cmd): compare example defaults inside optional sections Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commite2fd8c5695) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(partition): preserve region mapping during repartition (#9458) Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit90f117b957) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): restore exact filtering for nullable label matchers (#9418) * fix(promql): restore exact filtering for nullable label matchers Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: verify compound filter semantics and last-row selection Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: consolidate label filter regression coverage Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor: separate filter evaluation and timestamp adaptation Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor: inline predicate and timestamp tests Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor: keep direct IN predicates outside simple filters Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix: coerce partition bounds before simple filtering Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: cover partition bound overflow after timestamp widening Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit526dd48bda) Signed-off-by: WenyXu <wenymedia@gmail.com> * chore: update rust toolchain to 1.98.1 (#9429) (cherry picked from commit7f0f592b6b) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: update dev-builder image tag (#9469) Signed-off-by: greptimedb-ci <greptimedb-ci@greptime.com> Co-authored-by: greptimedb-ci <greptimedb-ci@greptime.com> (cherry picked from commit5affb50bb5) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(metasrv): retry failed heartbeat address updates (#9437) * fix(metasrv): retry failed heartbeat address updates Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(metasrv): move shared heartbeat helpers to utils Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit65b9a35692) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): bound FlowMirrorTask pending rows to prevent frontend OOM (#9421) * fix(operator): bound FlowMirrorTask pending rows to prevent frontend OOM When flownode is slow or unreachable, FlowMirrorTask::detach spawns unbounded tasks that each pin a full insert batch for ~10s (gRPC timeout). With continuous writes, in-flight memory grows unboundedly (throughput x 10s x peers), causing frontend-write OOMKill and a million-per-hour ERROR storm. Add a MAX_MIRROR_PENDING_ROWS cap (1M rows) on Inserter: detach fetch_adds the batch's row count before spawning; if the cap is exceeded the reservation is rolled back, the batch is dropped, a DIST_MIRROR_DROPPED_ROW_COUNT metric is bumped, and a warn is logged instead of spawning. On normal completion the reservation is released in both success and error paths. This keeps core writes alive while degrading flow mirror writes to best-effort, which is the intended trade-off: protect the primary insert path at the cost of possible flow aggregation gaps. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: address review - balanced reservation, gauge race, log rate limit P1: Compute pending_rows() from actual per-peer payloads in self.requests instead of the pre-fanout num_rows field. This fixes under-reservation for same-table multi-region batches and over-release in multi-flownode fanout. Each peer task now releases exactly its own share so reservations and releases balance. P2: Replace load+set gauge publication with add/sub symmetry. reserve_mirror_pending_rows does fetch_add + gauge.add, release does fetch_update + gauge.sub, eliminating the stale-value race. P3: Rate-limit the dropped-batch warning to at most once per 10s via MirrorDropLog (atomic compare_exchange), accumulating batch/row counts between reports. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: fix race in dual-peer test - wait for gauge release before unlocking The dual-peer test previously only waited for the pending budget to return to its start value before releasing MIRROR_METRIC_TEST_LOCK. But release_mirror_pending_rows moves the budget before the gauge, so a worker's gauge.sub could still be in flight when the next test acquires the lock and records its baseline, polluting the result. Now waits for both pending == start AND gauge == gauge_before. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(operator): preserve instant writes and limit mirror failure logs Reject saturated requests containing instant-TTL rows with the existing retryable write-admission error before mirror or datanode dispatch. Keep normal source-table inserts independent of best-effort mirror drops. Reuse a smaller process-wide event limiter for both dropped batches and failed peer tasks, reporting at most once per 10 seconds with failure count, peer context, and error chain. Cover actual mirror construction, mixed and instant admission, fast/slow peer shares, failed-task release, and log concurrency. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(operator): reject oversized instant mirror batches permanently Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit198d92407c) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): align subqueries to the absolute evaluation grid (#9400) * fix(promql): align subqueries to the absolute evaluation grid Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * docs(promql): streamline subquery comments Remove redundant arithmetic narration and restated test comments. Keep grid, fallback, validation and overflow contracts, and preserve all regression tests and assertions. Regenerate SQLness output only for comment echoes. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit0d2cb1aa7e) Signed-off-by: WenyXu <wenymedia@gmail.com> * build(deps): update opensrv-mysql to fix MariaDB 12.3 USE information_schema hang (#9357) Update opensrv-mysql from f3135e5 to 1f3d88c, which replies a proper EOF packet to COM_FIELD_LIST instead of a malformed OK packet that MariaDB 12.3 misparses and hangs on USE information_schema. Fixes #9158 Signed-off-by: tian1220A <1573612521@qq.com> (cherry picked from commit21536a092b) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): keep vector labels when a computed scalar is on the right (#9478) * fix(promql): keep vector labels when a computed scalar is on the right `metric + time()` or `metric * scalar(other)` planned the join with the scalar side's context, which has no tags, so every series collapsed into one unlabeled series. Use the vector side's context when the right operand is scalar-typed. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): update results for labels kept with a computed scalar on the right v * pi() and v + time() now keep the vector's labels and time index name, as in Prometheus. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): qualify columns of an unnamed operand in vector joins A comparison filter's projection has no table name, so after the join its time index resolved unqualified and was ambiguous when the other operand had the same column. Also keep the empty-metric branch unchanged for scalar left operands. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover comparison filters joined with a scalar Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit6da5352c53) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(otlp): convert boolean metric attributes into label values (#9468) Signed-off-by: fengyue-xve <1759519137@qq.com> (cherry picked from commit2ebb03fbf6) Signed-off-by: WenyXu <wenymedia@gmail.com> * perf(catalog): optimize single-table lookups in information_schema.partitions (#9473) * perf(catalog): optimize single-table lookups in information_schema.partitions Signed-off-by: 风月 <1759519137@qq.com> * style: apply rustfmt formatting to partitions tests Signed-off-by: 风月 <1759519137@qq.com> --------- Signed-off-by: 风月 <1759519137@qq.com> (cherry picked from commit4ade13d9b6) Signed-off-by: WenyXu <wenymedia@gmail.com> * test(promql): cover label_replace normalization with distinct series (#9485) The duplicate-label-set rejection must not fire when a label rewrite keeps every series distinct through another label. Normalizing mixed histogram boundary representations (`10.0` -> `10`) while `instance` still differs is the false positive reported in #9444. The check already accounts for it since it became a per-timestamp row count instead of a planning-time rejection, but no fixture covered the positive case. Add one, together with the aggregation variant that produces a genuine duplicate and must still fail. Signed-off-by: fengyue-xve <1759519137@qq.com> (cherry picked from commit764ab8bc1a) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): return no point for zero-interval irate/idelta (#9455) fix(promql): irate/idelta return no point for a zero-ms sample gap Signed-off-by: Niladri Adhikary <niladrix719@gmail.com> (cherry picked from commit0dde3a7b70) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(metasrv): stop parking the heartbeat response on the region supervisor queue (#9474) * fix(meta-srv): stop parking the heartbeat response on the region supervisor queue Signed-off-by: lczllx <2181719471@qq.com> * test(meta-srv): cover the heartbeat response path at the handler level Signed-off-by: lczllx <2181719471@qq.com> --------- Signed-off-by: lczllx <2181719471@qq.com> (cherry picked from commitd1151b0ed6) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): keep metric name for comparison operators (#9332) * fix(promql): keep metric name for comparison operators `collect_metric_names` dropped the response `__name__` for every binary operator except `and` / `or` / `unless`, so comparisons such as `node_load1 > 5` came back without the metric name. Grafana rendered them as `{instance="..."}` and alert templates reading `$labels.__name__` got nothing. Follow Prometheus' `shouldDropMetricName` / `resultMetric`: arithmetic operators drop the name, comparisons keep the left-hand name, except when the comparison returns a bool or is a one-to-one `on(...)` match. A `group_left` / `group_right` modifier does not reduce the labels, so the name is kept there. Both the HTTP PromQL API and the gRPC gateway share `retrieve_metric_name_and_result_type`, so one fix covers both. Signed-off-by: Aumnertic <hznuzc@gmail.com> * docs: document binary_keeps_metric_name Signed-off-by: Aumnertic <hznuzc@gmail.com> * fix(promql): handle on(__name__), scalar lhs and group_right Signed-off-by: Aumnertic <hznuzc@gmail.com> * chore(promql): correct result-side docs and drop unreachable String arm Signed-off-by: Aumnertic <hznuzc@gmail.com> * fix(promql): drop metric name when explicitly ignored Handle ignoring(__name__) for one-to-one comparisons and cover comparison, group_left, group_right and unless behavior. Signed-off-by: Aumnertic <hznuzc@gmail.com> --------- Signed-off-by: Aumnertic <hznuzc@gmail.com> (cherry picked from commitc33a6cfa56) Signed-off-by: WenyXu <wenymedia@gmail.com> * perf(promql): reduce timestamp payload allocations and use narrow cursor scans (#9417) * perf(promql): zero-copy timestamp payload for millisecond zero-offset ranges RangeManipulate rebuilt the timestamp range payload per sample through wide-precision i128 conversion even for millisecond input with zero offset, where the input is already millisecond and nothing changes. That turned a previously zero-copy path into a per-sample loop plus a Vec allocation, regressing range-function queries. Reuse the input timestamp array directly when the time index is already millisecond and the offset is zero. Other units and non-zero offsets keep the wide-precision conversion (required so ns/us data near the native type limits stays correct under large offsets). Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> * perf(promql): vectorize timestamp payload with plain i64 RangeManipulate rebuilt the timestamp range payload per sample through wide-precision i128 conversion, turning a hot path into a scalar loop with a Vec allocation. Realistic timestamps and offsets stay far inside i64, so the wide path is unnecessary. - millisecond + zero offset reuses the input array with zero copy; - other units / offsets shift in one vectorized i64 pass (no i128, no per-sample Option, no overflow branch). Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> * perf(promql): check timestamp payload overflow via arrow try_unary Replace the silent wrapping shift with try_unary + checked_mul/checked_add. checked arithmetic lowers to a per-lane overflow flag so the single pass still vectorizes over the dense i64 buffer; any overflow short-circuits to an error instead of silently wrapping to a wrong timestamp. No i128, no per-sample Option. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> * perf(promql): vectorize cursor scans with i64 fast path RangeManipulate::calculate_range and InstantManipulate::manipulate compared every sample through i128 wide-precision arithmetic. For realistic timestamps and offsets the shifted values stay far inside i64, so the wide path was pure overhead. Split both scans into an i64 fast path (checked arithmetic precomputed once per batch) and an i128 exact fallback. Any input that would overflow i64 falls back to the i128 scan, keeping window membership semantics identical. Verified: 47/47 range_manipulate + instant_manipulate tests, 275/275 promql crate tests, 1612 randomized cases byte-identical to i128 scan. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> * refactor(promql): generalize cursor scans and share endpoint precheck Address review comments on PR #9417: - Generalize scan_ranges/scan_instants over Nanoseconds (i64/i128), eliminating the duplicated scan bodies. - Extract shared narrow_scan precheck used by both RangeManipulate and InstantManipulate, with identical checked endpoint logic. - Trim doc comments to one line; fix factual errors about fallback trigger (2262 for ms/s, not native type limits). Semantics unchanged: same cursor algorithm, same overflow fallback, same i64-vs-i128 selection, same stale/null/tie handling. Verified: 47/47 range_manipulate + instant_manipulate tests, clippy clean. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> * perf(promql): vectorize payload with try_unary and add scan carrier bench Replace the per-sample i128 payload loop with a single arrow try_unary pass: i128 intermediate holds any native timestamp shift, only the final millisecond result must fit i64. This is semantically equivalent to the original i128 implementation (no silent wrap, no per-sample branching). Add cursor_scan_carriers A/B benchmark: i64 fast path vs i128 exact fallback on four workloads. Add five deterministic differential tests pinning scan_ranges::<i64> and scan_ranges::<i128> to be pointwise equivalent on boundary shapes (sub-ms samples, fallback triggers, nanosecond extremes, seeded precision matrix). Bench (N=4096, 15s cadence): - payload wall_time 20x15s: 74.94us -> 48.98us (-35%) - payload wall_time 240x15s: 102.44us - scan i64 fast path: 10.97us vs i128 19.84us (1.81x) Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * perf(promql): restore zero-copy payload for ms+offset=0 and fix comment Restore the zero-copy fast path for millisecond timestamps with zero offset, guarded by the input column's exact data type (Timestamp (Millisecond, None)) rather than self.time_unit. The old time_unit guard would accept a tz-carrying millisecond column and produce a dictionary value type disagreeing with the output schema; the datatype guard is the correct check. Also fix the comment: arrow's try_unary is a fallible kernel that LLVM cannot auto-vectorize; the win over the hand-written loop comes from eliminating the per-sample Option/Vec and branches, not SIMD. Add a regression test with a timezone-carrying millisecond index to prove it falls back to the try_unary path. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * perf(promql): replace try_unary with unary + Cell overflow flag Arrow's try_unary is a fallible kernel that LLVM cannot auto-vectorize. Replace it with the infallible unary kernel: the closure is a plain i64 -> i64 mapping that sets a Cell<bool> on overflow instead of returning Err per sample. The flag is checked once after the loop. This removes the per-lane Result construction and lets LLVM vectorize the shift+truncate sequence. Bench (N=4096, 15s cadence): - window4x15s: 42.12us -> 28.77us (-32%) - window20x15s: 48.98us -> 27.68us (-43%) - window240x15s: 102.44us -> 27.61us (-73%) - regular_sparse: 90.04us -> 29.20us (-68%) All cases now run at ~27-29us regardless of window size; the zero-copy path dominates for ms+offset=0. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * perf(promql): dispatch payload conversion on null_count; drop wall-time bench Null-free batches (dominant PromQL case) take arrow's vectorizable unary kernel with a Cell<bool> overflow flag; nullable batches take try_unary so a NULL slot's arbitrary underlying value cannot raise a false batch-wide overflow error. Add a regression test exercising both paths. Remove bench_range_manipulate_wall_time (all-ms zero-offset cases only exercised the zero-copy path, measuring nothing about the conversion) and its orphaned helpers; restore the cursor-scan bench helpers it shared. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(promql): keep nullable timestamp cursor scans on i128 NULL slots may contain unordered arbitrary values, so the endpoint-only narrow-scan proof cannot bound their interior timestamp arithmetic. Both range and instant scans now attempt i64 only for null-free timestamp arrays and otherwise retain the existing exact i128 path. Add focused interior-NULL regressions through both active entrypoints, checking concrete selections and output against the existing wide scan. The null-free fast path and timestamp payload conversion are unchanged. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: discord9 <discord9@163.com> (cherry picked from commitb3c756a7dd) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(metasrv): stop holding the pusher registry lock across mailbox sends (#9445) fix(meta-srv): stop holding the pusher registry lock across mailbox sends Signed-off-by: lczllx <2181719471@qq.com> (cherry picked from commit7605f27608) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito): flush memtables containing skip-WAL writes on region close (#9440) * fix(mito): flush unlogged writes when closing regions Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito): align skip-WAL naming and metric recovery tests Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commita026b3b280) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: enhance benchmark workflows with version comparisons and diagnostics (#9383) * ci: update Agent and Traces benchmark runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: use unrestricted benchmark runtimes by default Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: retry benchmark image pulls before generating datasets Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: increase tracesbench defaults to 8 CPU and 32 GiB Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: configure tracesbench query timeout and update runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: update tracesbench runtime and configure import scan workers Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: use shared parallel tracesbench import preparation Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: update tracesbench runtime to preserve Tempo trace requests Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: summarize tracesbench query results and lifecycle Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: show benchmark resource usage and update parallel loader runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): resolve shared summary module in existing test harness Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: select tracesbench engines and GreptimeDB image version Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: update benchmark runtime with parallel Agent preparation Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: select benchmark runtime registry by cloud provider Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: retry long-range benchmark image pulls Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: compare baseline and candidate GreptimeDB benchmarks Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: pin published dual-version benchmark runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: prepare agent benchmark indexes with updated runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: validate agent admission and render traces version comparison Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: use runtime with traces index preparation Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: use consolidated runtime for benchmark version reports Signed-off-by: WenyXu <wenymedia@gmail.com> * test: add license header to benchmark comparison tests Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): read legacy Tempo ingestion metrics in traces summaries Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: require tracesbench query plan artifacts Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: use optimized tracesbench report runtime Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: update tracesbench runtime to merged report validation Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit6e1a137411) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): accept evaluation times before the Unix epoch (#9480) * fix(promql): accept evaluation times before the Unix epoch Prometheus accepts negative timestamps in query parameters; GreptimeDB rejected them while parsing and would panic converting them in the planner. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: update flow_tql error for a negative TQL end time A negative end now parses and fails the end >= start check instead of the timestamp conversion. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): reject evaluation times outside the millisecond range The signed millisecond conversion cast u128 to i64 unchecked, so a far-past time wrapped into the future. Reuse the checked conversion from label_values and return an error instead. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * chore: drop an unused import Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): round query times to milliseconds like Prometheus Numeric times round the fraction of a second to milliseconds, and pre-epoch times floor sub-millisecond parts, matching Prometheus' parseTime and timestamp.FromTime. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): round the float fraction before converting to nanoseconds Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: skip the overflow case where SystemTime cannot hold it Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commita8e293fd79) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: unify unknown flow option rejection across OSS and enterprise builds (#9319) * fix(operator): unify unknown flow option rejection across OSS and enterprise builds Remove the enterprise-gated passthrough that let unknown flow options through SQL-layer validation. Both OSS and enterprise binaries now reject unknown flow options with a single 1004(InvalidArguments) error at the SQL layer, producing identical sqlness output. The metasrv-side validate_flow_options remains as a defensive check. Signed-off-by: discord9 <discord9@163.com> * fix(flow): use DISTINCT output columns as sink key for streaming flows When a streaming flow's source table has no primary key, sink key inference produced an empty key. For SELECT DISTINCT v, the DISTINCT establishes v as the output key, but the inference only looked at source primary keys. Combined with the zero-valued placeholder timestamp, all output rows shared the same storage identity and were deduplicated into a single row. Use all non-time-index output columns as the sink key when the plan is a DISTINCT and no source primary key was inherited. Signed-off-by: discord9 <discord9@163.com> * fix(flow): preserve extension options and complete DISTINCT sink identity Keep enterprise CREATE FLOW options available to the existing handler and report unknown ordinary options as InvalidArguments with the OSS SQL error text. Infer DISTINCT sink keys from every selected non-time-index output column, including when source key columns survive. Wait for asynchronous mirrors in show_create_flow and exercise 1000 distinct sink rows in the SQL regression. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(flow): align meta unknown option status assertions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(flow): use valid aliases in DISTINCT sink regression SQL Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(flow): record complete DISTINCT sink SQL results Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <discord9@163.com> Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit178a0021b8) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): return range-vector results for instant queries (#9477) * fix(promql): return range-vector results for instant queries An instant query of a range selector or subquery (e.g. `up[5m]`) returned the internal range arrays, which panicked when the frontend decoded them as dictionaries. Plan such queries as the selected samples with their own timestamps, and reject range-vector expressions in range queries like Prometheus does. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): keep encode_substrait on a valid range query A range query of a range-vector expression now fails like in Prometheus. Wrap the subquery in max_over_time so the case still encodes both RangeManipulate nodes through substrait. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): plan wrapped range-vector roots and reject a zero subquery step TQL EXPLAIN, ANALYZE and AS alias wrap the root in an extension, which the range-vector path did not unwrap. A zero subquery step divided by zero. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover wrapped range-vector roots and a zero subquery step Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): default subquery step and reject range vectors in range queries A root subquery without a step uses the one-minute default instead of the query step. The HTTP range query endpoint rejects range-vector expressions even when start equals end. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: cover root subquery default step Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor(promql): align root subqueries with subquery_child_window Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): return millisecond timestamps for range-vector results Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor(promql): keep the time index timezone and name its table in errors Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitb913be2af7) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(query): stop reporting region scans as hash partitioned (#9491) * fix(query): stop reporting region scans as hash partitioned Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): use current DataFusion plan downcasts Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): match null rendering in join regression Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): refresh union plan for unknown scan partitioning Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): refresh shared plans after distribution rule removal Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): cover semi joins with remote dynamic filtering Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): strengthen partitioned merge scan regressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): remove unused merge scan test helpers Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(perf): measure merge scan hash repartition cost Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(query): adapt merge scan deserialization to two-arg constructor Adapt the production and test serializer callers added by #9206 and remove the obsolete partition_cols assertion. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(query): adapt nested merge scan integration constructor Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit32e8467cda) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(table): require complete primary keys for hash partitioning (#9507) Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit28ec3ff5aa) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(deps): bump OpenDAL to 0.59.4 for OSS RRSA credentials (#9520) Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commite6f3ca10a5) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): default a stepless subquery to a one-minute step (#9481) * fix(promql): default a stepless subquery to a one-minute step `x[5m:]` evaluated the inner expression at the query step, which is 1s for instant queries. Prometheus uses the evaluation interval, one minute by default. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover the default subquery step Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover the default subquery step in a range query Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit845fc0af9c) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): follow IEEE 754 float semantics in comparisons and aggregations (#9479) * fix(promql): follow IEEE 754 float semantics in operators and aggregations - Comparisons: Arrow orders floats totally (NaN == NaN, NaN > +Inf); PromQL comparisons with NaN are false except !=. - min/max: skip NaN unless the whole group is NaN. DataFusion's grouped min/max let NaN replace and be replaced by neighbours, so the result depended on input order. - topk/bottomk rank NaN last. - round() rounds ties up and returns NaN for a zero step, like Prometheus. - sqrt() of a negative number is NaN instead of an error. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): round() without a step rounds to integers The planner passed a zero step for single-argument round(), which now yields NaN like Prometheus; pass 1 instead. Found by the compatibility harness. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): apply the sqrt guard to builtin functions and register min/max for plan decoding sqrt resolves through the session's builtin functions, not the UDF branch. Distributed plans are decoded with the serializer's session, which did not know prom_min/prom_max. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): cover NaN and rounding semantics Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): keep the nested count rewrite for PromQL min/max count(min(x) by (l)) was rewritten by count_nest_aggr only for the builtin min/max names. prom_min/prom_max return NULL for the same groups, so the rewrite stays valid. Update plan snapshots for the IEEE comparison, sqrt, topk and min/max changes. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): register prom_min/prom_max in the function registry Distributed plans split an aggregation into state and merge steps only for functions whose state function exists in FUNCTION_REGISTRY; prom_min and prom_max lived in the promql crate, so min/max stopped being pushed down to datanodes. Move them to common-function and register them before the state functions are derived. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: update sqlness results for PromQL float semantics Plans and column names now show prom_min/prom_max, the NaN guards of comparisons and sqrt, and the NaN sort key of topk. round() results follow Prometheus' formula. Distributed min/max keep the state/merge split. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): keep column names and NULLs in IEEE comparisons and sqrt Rewriting comparisons as `a op b AND NOT (isnan(a) OR isnan(b))` changed TQL output column names and turned NULL operands into true for `!=` and false for the bool forms. Implement comparisons and sqrt as PromQL UDFs that propagate NULL and report the column name of the expression they replace; prom_min/prom_max report min/max column names the same way. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: restore column names in sqlness results for PromQL float semantics Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test(promql): share the prom_min/prom_max groups accumulator with tests Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(promql): keep distinct names for prom_min/prom_max Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitf3a0841f4f) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(query): keep filter fetch when rewriting matches term (#9521) * test: cover matches term limit dropped by constant rewrite Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(query): keep filter fetch when rewriting matches term Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit483d1e49a3) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: adapt release OptionMap iteration to Rust 1.98 Signed-off-by: WenyXu <wenymedia@gmail.com> * build: reduce checkout-specific inputs for cache reuse (#9198) * test(mito2): cover regex inverted index pruning Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * build: keep build inputs checkout-relative for cross-worktree cache reuse Two small changes so compilations are byte-identical across worktrees of the same commit and can share a build cache (mbx, sccache): - src/common/version/build.rs: derive the workspace root relative to CARGO_MANIFEST_DIR instead of the CARGO_WORKSPACE_DIR injected by .cargo/config.toml (an absolute path). The env var remains as an explicit override for the submodule case; the [env] injection in .cargo/config.toml is removed so the relative derivation is used by default. - Replace three env!("CARGO_MANIFEST_DIR") uses in test/bench code (servers prom_decode bench + decode test, common-meta test_util) with a runtime std::env::var lookup, so the compiled artifact no longer embeds the checkout path. No behavior change: same files are read, same version info emitted. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * build: address cross-worktree cache review feedback Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commitf50f42140a) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito): preserve version ordering in bulk merges and flushes (#9517) * fix(mito): preserve delete barriers in flat last-non-null dedup Signed-off-by: evenyag <realevenyag@gmail.com> * fix(mito): respect sequence ranges in bulk merges and flushes Signed-off-by: evenyag <realevenyag@gmail.com> * perf(mito): avoid merge selection on the bulk write path Signed-off-by: evenyag <realevenyag@gmail.com> --------- Signed-off-by: evenyag <realevenyag@gmail.com> (cherry picked from commit674ba72669) Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: benchmark the object store WAL against S3 from an EC2 runner (#9536) * ci: benchmark the object store WAL against S3 from an EC2 runner Add a workflow_dispatch workflow that loads the o11ybench agent observability dataset into a fresh GreptimeDB container per cell and compares three WAL configurations that differ only in [wal]: Raft Engine with sync_write on the instance store, and the object store WAL in durable and enqueued mode, with the data files on S3 in the runner's region for all three. Rounds alternate the configuration order. A summary reports rows per second, D/R and E/R as the geometric mean over rounds, append acknowledgement p50 and p99, and the object store WAL counters. A smoke mode runs the same chain on ubuntu-latest against the MinIO fixture. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: scrape WAL metrics on failed loads and keep logic out of the benchmark YAML Scrape the object store WAL metrics before stopping a cell's server whether the load succeeded or failed, so failed cells keep their stall, poison and create failure counters. Build the summary from the plan so that cells which never ran are listed and leave their ratios undefined. Move budget, region, runtime image, manifest and credential scan logic from the workflow into the helper scripts, and drive the cell lifecycle in the tests through fake docker and curl binaries. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: drop redundant required flags from benchmark inputs Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: fail a benchmark cell whose metrics scrape fails and zero untouched WAL counters A load that succeeds without its metrics scrape now fails the cell with exit code 4 and records metrics_scraped in cell.json. The summary reports an event counter that never registered as zero when the cell is an object store WAL cell whose scrape succeeded and created objects, and as unknown otherwise. Planned cells that never ran are read through the same path as recorded ones. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: register the S3 benchmark workflow for dispatch from its branch A workflow_dispatch workflow can only be dispatched once GitHub has registered it, which happens on the default branch or when one of its triggers fires. A push trigger limited to this branch registers it; validate runs only for workflow_dispatch, so a push run skips every job. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: delete the benchmark S3 prefix one object at a time boto3 now sends a CRC checksum instead of Content-MD5 with DeleteObjects, which the MinIO fixture of the smoke run rejects. DeleteObject needs neither and the prefix of one run holds at most a few thousand objects. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: default the S3 benchmark to c6id.2xlarge c7id is not offered in the runner region ap-southeast-1, where DescribeInstanceTypes rejects it. c6id is the compute optimized x86 family with a local NVMe instance store that the region offers, with the same 8 vCPU and 16 GiB at 2xlarge. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: drop the registration trigger of the S3 benchmark workflow The workflow is registered now, so dispatches no longer need a push trigger on its branch. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: fail an S3 benchmark run on EC2 without a local instance store Without an instance store the Raft Engine WAL of the R cells silently stayed on the root EBS volume, so the run measured a different baseline. Only a smoke run may keep it on the root volume now. Signed-off-by: jeremyhi <fengjiachun@gmail.com> * ci: let the instance store check fail its workflow step The step piped wal-disk into tee without pipefail, so a rejected host still passed. wal-disk now writes its record itself and the step runs it alone. Signed-off-by: jeremyhi <fengjiachun@gmail.com> --------- Signed-off-by: jeremyhi <fengjiachun@gmail.com> (cherry picked from commitc1a0ca9595) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito2): ignore missing inverted indexes in old SSTs (#9545) Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit287ced2de2) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: prevent stale leader metadata after concurrent writes (#9549) fix: fence leader cache fills around store mutations Signed-off-by: jeremyhi <fengjiachun@gmail.com> (cherry picked from commit394562aa9e) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): stop fabricating histogram fold hash partitioning (#9509) * fix(promql): stop fabricating histogram fold hash partitioning Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(promql): cover histogram fraction binary join missing rows Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(promql): normalize dictionary labels in histogram regression Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: cover histogram joins on logical metric tables Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: specify insert columns for logical metric fixture Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: use supported histogram child replacement API Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(promql): keep histogram fold fixtures truthful Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit2039f508fc) Signed-off-by: WenyXu <wenymedia@gmail.com> * feat(client): support region request reuse and route refresh hints (#9540) * feat(client): support shared region requests without deep cloning Signed-off-by: WenyXu <wenymedia@gmail.com> * feat: propagate route refresh retry hints Signed-off-by: WenyXu <wenymedia@gmail.com> * style: preserve local error variant imports in retry hints Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(mito2): distinguish staging rejection from write backpressure Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(meta): preserve legacy retry hints in instruction replies Signed-off-by: WenyXu <wenymedia@gmail.com> * test(client): inline retry hint compatibility cases Signed-off-by: WenyXu <wenymedia@gmail.com> * test(datanode): align batch-open retry hint assertion Signed-off-by: WenyXu <wenymedia@gmail.com> * style: use imported types in region request tests Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit684c8ff291) Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(operator): centralize write partitioning and dispatch (#9541) * refactor(operator): centralize write partitioning and dispatch Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): preserve write dispatch behavior during refactor Signed-off-by: WenyXu <wenymedia@gmail.com> * style(operator): import write dispatch error paths Signed-off-by: WenyXu <wenymedia@gmail.com> * style(operator): align dispatch imports and test utilities Signed-off-by: WenyXu <wenymedia@gmail.com> * style(operator): use imported types in partition tests Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commita2d28e6060) Signed-off-by: WenyXu <wenymedia@gmail.com> * feat(operator): retry failed writes with refreshed partitions (#9542) * feat(operator): retry failed writes with refreshed partitions Signed-off-by: WenyXu <wenymedia@gmail.com> * style(operator): simplify retry test type references Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(operator): clarify write request rebuilding names Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(operator): encapsulate write retry attempts Signed-off-by: WenyXu <wenymedia@gmail.com> * style(operator): group inserter configuration methods Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(operator): share routed peers and separate retry decisions Signed-off-by: WenyXu <wenymedia@gmail.com> * refactor(operator): avoid temporary write routing allocations Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commitd86741d0ad) Signed-off-by: WenyXu <wenymedia@gmail.com> * feat(frontend): retry transient distributed write failures (#9543) * refactor(operator): route shared requests through NodeManager Remove the concrete NodeClients requester API and use the existing node manager for owned and shared requests. Preserve the original write hint while the legacy error adapter remains in place. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(meta): preserve retry policy through error hints Propagate client errors through External and let DDL read retry hints while preserving cleanup and terminal-child policies. Region transport failures retain the legacy Retryable classification, which now also participates in bounded write retries. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): restore sparse partition tags when rebuilding writes Decode retained primary keys when refreshed partition expressions need different tags. Keep the partitioner and multi-region sparse write regression tests with the fix. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): stop detached write retries when callers cancel Signed-off-by: WenyXu <wenymedia@gmail.com> * feat(frontend): configure retries across distributed write paths Wire the shared dispatcher through frontend and logical batching, preserving partition columns and request headers. Keep standalone retries disabled and include configuration, batching, and storage regression coverage. Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit863eebdecb) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(table): stop claiming tag order for logical metric scans (#9522) * test: cover metric logical scan tag ordering Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(table): only claim projected primary key prefix ordering Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(table): keep physical key ordering for metric projections Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(table): compare unknown partition counts explicitly Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(table): model scoped internal TSID scan properties Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit75bc4ce46d) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(promql): stop claiming scalar calculate preserves order (#9523) * test: cover scalar calculate order contract Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(promql): stop claiming scalar calculate preserves order Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * docs(test): describe scalar ordering regression as pre-fix behavior Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit0e40281262) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: align fulltext bloom tokens with matches_term and use them for LIKE (#9470) * fix: align fulltext bloom tokens with matches_term and use them for LIKE The bloom fulltext index stored tokens that did not follow matches_term's word boundaries (`_`, ASCII next to Han, Han substrings), so pruning dropped rows matches_term accepts. Tokens and query probes now share the character classes of matches_term, Han runs are indexed as bigrams, and LIKE patterns use the same probes. The bloom blob is written under a new key; blobs from older versions are no longer probed. Tantivy no longer serves matches_term. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * chore: drop unused lazy_static from index and avoid typo-flagged test tokens Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * docs: link like_probes to tokenize instead of the downstream ScriptTokenizer Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * refactor: name probe extraction with verbs and document CharClass Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * feat: rebuild legacy fulltext bloom indexes and probe lower(col) The manifest records the fulltext bloom version per column, so ADMIN BUILD_INDEX now rebuilds SSTs whose bloom index predates the current tokens, and ssts_index_meta shows the version. Case-insensitive bloom indexes also store the tokens of the whole lowercased text for rows where it can differ (U+0130, U+212A, sigma), so matches_term(lower(col), ..) and lower(col) LIKE .. use the index. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: cover legacy bloom index rewrite by compaction in the compat case Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * chore: log outdated fulltext bloom indexes in the consistency check Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * docs: explain the unrecorded fulltext bloom version Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit5c4d9eb4f8) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(query): guard deep expressions during query planning (#9564) * fix(query): avoid speculative clones in constant normalization Signed-off-by: evenyag <realevenyag@gmail.com> * fix(query): reject deeply nested expressions before optimization Signed-off-by: evenyag <realevenyag@gmail.com> --------- Signed-off-by: evenyag <realevenyag@gmail.com> (cherry picked from commit00aa65de7a) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): reuse catalog table resolution for write retries (#9574) * fix(operator): inherit logical table partitions when retrying writes Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(operator): resolve retried writes through catalog manager Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com> (cherry picked from commit36423a95c6) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(query): accept narrowed nullability in MergeScan remote schema check (#9592) On append-mode tables a region reports an exact row count. When it is zero, DataFusion's AggregateStatistics replaces the region-side __min_state/__max_state aggregate with a non-null struct literal, so the remote stream advertises the state column as non-nullable. The MergeScan schema check required equal nullability and failed the query. Accept a remote field that is non-nullable where the expected field is nullable, matching DataFusion's own physical-plan schema invariant. Batches are already rebuilt with the expected schema. Widening is still rejected, and nested field nullability is still compared exactly as part of the data type. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitef1298ce02) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(table): stop claiming exact null and distinct counts for append scans (#9593) RegionScanExec reported null_count = 0 and distinct_count = total_rows for every column of an append-only table. DataFusion answers count(col) and count(DISTINCT col) from these statistics, so both returned the row count. Keep the exact row count and an exact zero null count for non-nullable columns, so count(*) is still answered from statistics. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commit5cfdc30a69) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix: avoid pinning memtables during series-index reconciliation (#9588) Signed-off-by: evenyag <realevenyag@gmail.com> (cherry picked from commitce66d8d4ac) Signed-off-by: WenyXu <wenymedia@gmail.com> * perf(query): infer safe aggregate input bounds from HAVING (#9582) * perf(query): infer safe aggregate input bounds from HAVING Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> * test(query): refresh explain snapshots for aggregate bound inference Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> * fix(query): avoid reinferring bounds across MergeScan Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> * fix(query): validate aggregate bound column provenance Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> * test(query): retain zero-match groups for nonpositive HAVING thresholds Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> * test(query): assert inferred bounds in remote scan plans Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> --------- Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> (cherry picked from commitaf396e60a3) Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(query): keep state column names unique when splitting aggregates (#9594) * fix(query): keep state column names unique when splitting aggregates Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix(query): probe allocated names when renaming colliding state columns Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com> (cherry picked from commitf7a3a7033c) Signed-off-by: WenyXu <wenymedia@gmail.com> * chore: bump version to 1.3.0-beta.2 Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: greptimedb-ci <greptimedb-ci@greptime.com> Signed-off-by: WenyXu <wenymedia@gmail.com> Signed-off-by: Aarav <aaravsjadav@gmail.com> Signed-off-by: Ning Sun <sunning@greptime.com> Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Signed-off-by: Dennis Zhuang <killme2008@gmail.com> Signed-off-by: evenyag <realevenyag@gmail.com> Signed-off-by: fengyue-xve <1759519137@qq.com> Signed-off-by: Aetherance <inkToAether@gmail.com> Signed-off-by: tian1220A <1573612521@qq.com> Signed-off-by: 风月 <1759519137@qq.com> Signed-off-by: Niladri Adhikary <niladrix719@gmail.com> Signed-off-by: lczllx <2181719471@qq.com> Signed-off-by: Aumnertic <hznuzc@gmail.com> Signed-off-by: discord9 <discord9@163.com> Signed-off-by: jeremyhi <fengjiachun@gmail.com> Signed-off-by: Lei, HUANG <ratuthomm@gmail.com> Co-authored-by: LFC <990479+MichaelScofield@users.noreply.github.com> Co-authored-by: greptimedb-ci <greptimedb-ci@greptime.com> Co-authored-by: DeviousCardi <115358213+DeviousCardi@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ning Sun <sunng@protonmail.com> Co-authored-by: discord9 <discord9@163.com> Co-authored-by: dennis zhuang <killme2008@gmail.com> Co-authored-by: Yingwen <realevenyag@gmail.com> Co-authored-by: fengyue-xve <1759519137@qq.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aether <98370028+Aetherance@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: zhaiAohan <1573612521@qq.com> Co-authored-by: Niladri Adhikary <91966855+niladrix719@users.noreply.github.com> Co-authored-by: Dragon Roar <2181719471@qq.com> Co-authored-by: Aum <hznuzc@gmail.com> Co-authored-by: jeremyhi <jiachun_feng@proton.me> Co-authored-by: Lei, HUANG <6406592+v0y4g3r@users.noreply.github.com>
This commit is contained in:
co-authored by
LFC
greptimedb-ci
DeviousCardi
Claude Opus 5
Ning Sun
discord9
dennis zhuang
Yingwen
fengyue-xve
Copilot Autofix powered by AI
Aether
github-actions[bot]
zhaiAohan
Niladri Adhikary
Dragon Roar
Aum
jeremyhi
Lei, HUANG
parent
865986d306
commit
a788cd977a
+3
-2
@@ -21,5 +21,6 @@ checkout = true
|
||||
list_files = true
|
||||
internal_use_git2 = false
|
||||
|
||||
[env]
|
||||
CARGO_WORKSPACE_DIR = { value = "", relative = true }
|
||||
# Avoid globally injecting a checkout-specific workspace path. The version build script
|
||||
# resolves the absolute workspace path from layout at build time; set CARGO_WORKSPACE_DIR
|
||||
# explicitly when this repo is built as a submodule.
|
||||
|
||||
@@ -56,7 +56,7 @@ runs:
|
||||
|
||||
- name: Start EC2 runner
|
||||
if: startsWith(inputs.runner, 'ec2')
|
||||
uses: machulav/ec2-github-runner@v2.3.8
|
||||
uses: machulav/ec2-github-runner@v2.6.1
|
||||
id: start-linux-arm64-ec2-runner
|
||||
with:
|
||||
mode: start
|
||||
|
||||
@@ -33,7 +33,7 @@ runs:
|
||||
|
||||
- name: Stop EC2 runner
|
||||
if: ${{ inputs.label && inputs.ec2-instance-id }}
|
||||
uses: machulav/ec2-github-runner@v2.3.8
|
||||
uses: machulav/ec2-github-runner@v2.6.1
|
||||
with:
|
||||
mode: stop
|
||||
label: ${{ inputs.label }}
|
||||
|
||||
@@ -18,6 +18,8 @@ import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from benchmark_resource_summary import render_resources
|
||||
|
||||
TARGETS = ('greptimedb', 'clickhouse', 'victorialogs')
|
||||
|
||||
|
||||
@@ -201,6 +203,7 @@ def lifecycle(root, targets):
|
||||
rows.append([context, entry.get('phase'), duration(entry.get('elapsed_ms')) if valid else
|
||||
'invalid timestamp units (legacy artifact)', entry.get('exit_code')])
|
||||
lines += ['## Phase timings', '', 'Phase duration includes orchestration overhead. Container cleanup does not prove ECS teardown.', '', table(['DB / scope', 'Phase', 'Duration', 'Exit code'], rows), '']
|
||||
lines.append(render_resources([('dataset', root)] + [(t, root / f'{t}-1') for t in targets], table))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Compare validated GreptimeDB query results; never gate CI on noisy latency deltas."""
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
|
||||
|
||||
def read(path):
|
||||
return json.loads(path.read_text()) if path.is_file() else {}
|
||||
|
||||
|
||||
def validate(env):
|
||||
enabled = env.get('COMPARE_GREPTIMEDB', 'false')
|
||||
if enabled not in ('true', 'false'):
|
||||
raise ValueError('COMPARE_GREPTIMEDB must be true or false')
|
||||
if enabled == 'false':
|
||||
return
|
||||
if env.get('RUN_GREPTIMEDB') != 'true':
|
||||
raise ValueError('Version comparison requires Run GreptimeDB')
|
||||
for key in ('BASELINE_GREPTIMEDB_TAG', 'GREPTIMEDB_TAG'):
|
||||
if not re.fullmatch(r'[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}(@sha256:[0-9a-f]{64})?', env.get(key, '')):
|
||||
raise ValueError(f'Invalid {key}')
|
||||
if env['BASELINE_GREPTIMEDB_TAG'] == env['GREPTIMEDB_TAG']:
|
||||
raise ValueError('Baseline and candidate must have different image tags')
|
||||
|
||||
|
||||
def same(a, b, fields):
|
||||
return all(a.get(k) is not None and a.get(k) == b.get(k) for k in fields)
|
||||
|
||||
|
||||
def finite(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
|
||||
|
||||
|
||||
def improvement(old, new, throughput=False):
|
||||
if not finite(old) or not finite(new) or old <= 0:
|
||||
return None
|
||||
return (new / old - 1 if throughput else 1 - new / old) * 100
|
||||
|
||||
|
||||
def row(query, metric, old, new, compatible, reason):
|
||||
delta = improvement(old, new, metric == 'QPS') if compatible else None
|
||||
return dict(query=query, metric=metric, baseline=old, candidate=new,
|
||||
improvement_percent=delta, status='comparable' if delta is not None else reason)
|
||||
|
||||
|
||||
def agent(root):
|
||||
def load(r):
|
||||
return read(r / 'greptimedb-1/greptimedb-1/measured-summary.json')
|
||||
a, b = load(root / 'baseline'), load(root)
|
||||
finished = [r / 'greptimedb-1/exit-code.txt' for r in (root / 'baseline', root)]
|
||||
compatible = (all(p.is_file() and p.read_text().strip() == '0' for p in finished)
|
||||
and a.get('passed') is True and b.get('passed') is True
|
||||
and same(a, b, ('contract_sha256', 'data_profile', 'query_window', 'warmup_runs', 'measured_runs')))
|
||||
qa, qb = ({q['query_id']: q for q in x.get('per_query', [])} for x in (a, b))
|
||||
rows = []
|
||||
for q in sorted(qa.keys() | qb.keys()):
|
||||
old, new = qa.get(q, {}), qb.get(q, {})
|
||||
ok = compatible and same(old, new, ('result_fingerprint', 'success')) and old.get('success', 0) > 0
|
||||
ok = ok and all(v.get('errors') == 0 and v.get('timeouts') == 0 for v in (old, new))
|
||||
for key in ('p50_ms', 'p95_ms', 'p99_ms'):
|
||||
rows.append(row(q, key, old.get(key), new.get(key), ok, 'failed, missing or incompatible result'))
|
||||
return rows
|
||||
|
||||
|
||||
def traces(root):
|
||||
a, b = (read(r / 'runs/greptimedb/run.json') for r in (root / 'baseline', root))
|
||||
compatible = (a.get('success') is True and b.get('success') is True
|
||||
and same(a, b, ('expected', 'protocol', 'audit_expected', 'corpus'))
|
||||
and same(a.get('execution', {}), b.get('execution', {}),
|
||||
('db_cpus', 'db_memory', 'load_workers_requested', 'max_in_flight')))
|
||||
rows = []
|
||||
for q in (f'T{i:02}' for i in range(1, 9)):
|
||||
values, identities, counts = [], [], []
|
||||
ok = compatible
|
||||
for run in (a, b):
|
||||
correctness = [s for s in run.get('correctness', []) if s.get('query_id') == q]
|
||||
all_samples = [s for s in run.get('samples', []) if s.get('query_id') == q]
|
||||
measured = [s for s in all_samples if s.get('phase') == 'measured']
|
||||
passed = bool(correctness and measured) and all(s.get('outcome') == 'passed' for s in correctness + all_samples)
|
||||
expected = run.get('expected', {}).get('contracts', {}).get(q, {})
|
||||
passed = passed and all(s.get('fingerprint') == expected.get('fingerprint') and s.get('fingerprint') is not None
|
||||
and s.get('row_count') == expected.get('row_count') for s in correctness + all_samples)
|
||||
elapsed = [s.get('elapsed_ms') for s in measured]
|
||||
passed = passed and all(finite(v) for v in elapsed)
|
||||
ok = ok and passed
|
||||
values.append(statistics.median(elapsed) if passed else None)
|
||||
identities.append(expected)
|
||||
counts.append((len(correctness), len(all_samples), len(measured)))
|
||||
ok = ok and identities[0] == identities[1] and counts[0] == counts[1]
|
||||
rows.append(row(q, 'median_ms', *values, ok, 'failed, skipped, missing or incompatible result'))
|
||||
return rows
|
||||
|
||||
|
||||
def long_range(root):
|
||||
rows = []
|
||||
manifest = read(root / 'run-manifest.json')
|
||||
healthy = all(
|
||||
all(field in (manifest.get(phase, {}).get(target, {}).get('docker', {}) if phase == 'post_load_results'
|
||||
else manifest.get(phase, {}).get(target, {})).get('state', '').split()
|
||||
for field in ('Running=true', 'ExitCode=0', 'OOMKilled=false'))
|
||||
for phase in ('post_load_results', 'post_query_results')
|
||||
for target in ('greptimedb-baseline', 'greptimedb'))
|
||||
for mode in ('serial', 'concurrent-duration-c1', 'concurrent-duration-c4'):
|
||||
a, b = (read(root / name / 'results-vmbench-long-range' / (mode + '-summary.json'))
|
||||
for name in ('greptimedb-baseline', 'greptimedb'))
|
||||
fields = ('workload', 'query_file_sha256', 'start', 'end', 'step', 'profile', 'data_duration',
|
||||
'scrape_interval', 'cache_policy_effective', 'mode', 'connection_mode', 'request_timeout_seconds')
|
||||
fields += ('tries', 'warmup_runs') if mode == 'serial' else ('concurrency', 'duration_seconds', 'warmup_seconds')
|
||||
compatible = healthy and same(a, b, fields) and all(x.get('errors') == 0 and x.get('timeouts') == 0 for x in (a, b))
|
||||
qa, qb = ({q['query_index']: q for q in x.get('per_query', [])} for x in (a, b))
|
||||
for q in sorted(qa.keys() | qb.keys()):
|
||||
old, new = qa.get(q, {}), qb.get(q, {})
|
||||
ok = compatible and same(old, new, ('query', 'series', 'points'))
|
||||
ok = ok and all(v.get('success', 0) > 0 and v.get('series', 0) > 0 and v.get('points', 0) > 0
|
||||
and v.get('errors') == 0 and v.get('timeouts') == 0 for v in (old, new))
|
||||
for key in ('p50_ms', 'qps') if mode != 'serial' else ('p50_ms',):
|
||||
rows.append(row(f'{mode}/Q{q}', 'QPS' if key == 'qps' else key,
|
||||
old.get(key), new.get(key), ok, 'failed, missing or incompatible result'))
|
||||
return rows
|
||||
|
||||
|
||||
def render(document):
|
||||
def cell(value):
|
||||
return html.escape(str(value)).replace('|', '|').replace('\n', ' ')
|
||||
def value(v, metric):
|
||||
if not finite(v):
|
||||
return '—'
|
||||
if metric == 'QPS':
|
||||
return f'{v:.3f}'
|
||||
return f'{v / 1000:.2f} s' if v >= 1000 else f'{v:.2f} ms'
|
||||
lines = ['# GreptimeDB version comparison', '',
|
||||
f'Baseline: `{cell(document["baseline_tag"])}` → candidate: `{cell(document["candidate_tag"])}`.', '',
|
||||
'Same-host sequential runs with fresh databases and shared input data. Positive = improvement; '
|
||||
'latency: (baseline − candidate) / baseline; QPS: (candidate − baseline) / baseline. '
|
||||
'Single runs are diagnostic, not a statistically established regression. No performance threshold gates CI.', '',
|
||||
'| Query | Metric | Baseline | Candidate | Improvement | Status |',
|
||||
'| --- | --- | --- | --- | --- | --- |']
|
||||
for r in document['rows']:
|
||||
delta = r['improvement_percent']
|
||||
lines.append('| ' + ' | '.join(map(cell, [r['query'], r['metric'], value(r['baseline'], r['metric']),
|
||||
value(r['candidate'], r['metric']), f'{delta:+.1f}%' if delta is not None else '—', r['status']])) + ' |')
|
||||
if document.get('kind') == 'long-range':
|
||||
lines += ['', 'Long-range retains the existing non-empty series/point-count checks; these do not establish equality of every returned value.']
|
||||
if not document['rows']:
|
||||
lines += ['', 'Results unavailable; comparison not performed.']
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=('validate', 'report'))
|
||||
parser.add_argument('--kind', choices=('agent', 'traces', 'long-range'))
|
||||
parser.add_argument('--root', type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.command == 'validate':
|
||||
validate(os.environ)
|
||||
return
|
||||
if args.root is None or args.kind is None:
|
||||
parser.error('report requires --root and --kind')
|
||||
document = {'kind': args.kind, 'baseline_tag': os.environ.get('BASELINE_GREPTIMEDB_TAG', ''),
|
||||
'candidate_tag': os.environ.get('GREPTIMEDB_TAG', ''),
|
||||
'rows': {'agent': agent, 'traces': traces, 'long-range': long_range}[args.kind](args.root)}
|
||||
text = render(document)
|
||||
# Preserve partial comparisons even if a target failed before creating output.
|
||||
args.root.mkdir(parents=True, exist_ok=True)
|
||||
(args.root / 'version-comparison.json').write_text(json.dumps(document, indent=2, allow_nan=False) + '\n')
|
||||
(args.root / 'version-comparison.md').write_text(text)
|
||||
print(text, end='')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Render shared O11yBench sampled resource artifacts for lifecycle summaries."""
|
||||
import json
|
||||
import math
|
||||
|
||||
|
||||
def number(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
|
||||
|
||||
|
||||
def metric(values, key, statistic):
|
||||
entry = values.get(key, {})
|
||||
value = entry.get(statistic)
|
||||
return value if number(entry.get('samples')) and entry['samples'] > 0 and number(value) else None
|
||||
|
||||
|
||||
def decimal(value, suffix=''):
|
||||
return f'{value:.2f}{suffix}' if number(value) else '—'
|
||||
|
||||
|
||||
def memory(value):
|
||||
if not number(value):
|
||||
return '—'
|
||||
for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'):
|
||||
if value < 1024 or unit == 'TiB':
|
||||
return decimal(value, f' {unit}')
|
||||
value /= 1024
|
||||
|
||||
|
||||
def capacities(path):
|
||||
# Quotas are metadata, not recomputed CPU statistics. Bound reads on long runs.
|
||||
result = {}
|
||||
try:
|
||||
with path.open() as source:
|
||||
for index, line in enumerate(source):
|
||||
if index == 256:
|
||||
break
|
||||
record = json.loads(line)
|
||||
cores = record.get('host', {}).get('cpu_count')
|
||||
if number(cores) and cores > 0:
|
||||
result.setdefault('host', cores)
|
||||
for role, values in record.get('containers', {}).items():
|
||||
quota = values.get('configured_cpu_limit_cores')
|
||||
if number(quota):
|
||||
result.setdefault(role, quota)
|
||||
if all(role in result for role in ('host', 'runtime', 'db')):
|
||||
break
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def render_resources(scopes, table):
|
||||
rows = []
|
||||
for scope, directory in scopes:
|
||||
try:
|
||||
summary = json.loads((directory / 'resources.summary.json').read_text())
|
||||
phases = summary.get('phases', {}) if summary.get('schema') == 'o11ybench-resources-v1' else {}
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
phases = {}
|
||||
if not isinstance(phases, dict) or not phases:
|
||||
rows.append([scope, 'unavailable', *['—'] * 9])
|
||||
continue
|
||||
limits = capacities(directory / 'resources.jsonl')
|
||||
for phase, data in phases.items():
|
||||
for role in ('host', 'runtime', 'db'):
|
||||
values = data.get('host', {}) if role == 'host' else data.get('containers', {}).get(role, {})
|
||||
if role == 'db' and scope == 'dataset':
|
||||
continue
|
||||
quota = limits.get(role)
|
||||
cpu_samples = values.get('cpu_used_cores', {}).get('samples')
|
||||
rows.append([scope, phase, role, cpu_samples,
|
||||
decimal(metric(values, 'cpu_used_cores', 'mean')),
|
||||
decimal(metric(values, 'cpu_used_cores', 'max')),
|
||||
'unlimited' if quota == 0 else decimal(quota),
|
||||
memory(metric(values, 'memory_used_bytes' if role == 'host' else 'memory_usage_bytes', 'max')),
|
||||
decimal(metric(values, 'io_wait_percent', 'mean'), '%'),
|
||||
decimal(metric(values, 'io_wait_percent', 'max'), '%'),
|
||||
memory(metric(values, 'swap_used_bytes', 'max'))])
|
||||
return '\n'.join(['## Sampled resources', '',
|
||||
'CPU is measured in cores. Mean/max summarize available samples, not continuous peaks; '
|
||||
'short phases may have no CPU sample. CPU capacity is host cores or the configured container quota. '
|
||||
'Host memory is total minus available; Docker memory excludes cache where supported. '
|
||||
'Swap is host-wide usage, not swap activity. Missing evidence stays —.', '',
|
||||
table(['Scope', 'Phase', 'Component', 'CPU samples', 'Mean CPU cores', 'Max CPU cores',
|
||||
'CPU capacity', 'Max memory', 'Mean I/O wait', 'Max I/O wait', 'Max swap'], rows), ''])
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Returns whether the current release run should proceed, 'true' or 'false'.
|
||||
#
|
||||
# The nightly schedule runs every weekday, but a scheduled release only proceeds
|
||||
# when the latest published nightly release is older than
|
||||
# NIGHTLY_RELEASE_MAX_AGE_DAYS days. This way, a failed nightly run is retried
|
||||
# by the next weekday schedule, instead of leaving a whole week without nightly
|
||||
# builds. Non-scheduled runs (tag pushes and manual dispatches) always proceed.
|
||||
#
|
||||
# Only the result is written to stdout, so that it can be used in GitHub Actions
|
||||
# outputs; all diagnostics go to stderr.
|
||||
#
|
||||
# You can run as following examples:
|
||||
# GITHUB_EVENT_NAME=push ./check-nightly-release.sh
|
||||
# GITHUB_EVENT_NAME=schedule GITHUB_REPOSITORY=GreptimeTeam/greptimedb GH_TOKEN=ghp_xxx ./check-nightly-release.sh
|
||||
# GITHUB_EVENT_NAME=schedule NIGHTLY_RELEASE_MAX_AGE_DAYS=5 GITHUB_REPOSITORY=GreptimeTeam/greptimedb GH_TOKEN=ghp_xxx ./check-nightly-release.sh
|
||||
if [ -z "$GITHUB_EVENT_NAME" ]; then
|
||||
echo "GITHUB_EVENT_NAME is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
|
||||
echo "Not a scheduled run, the nightly check is skipped." >&2
|
||||
echo "true"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MAX_AGE_DAYS="${NIGHTLY_RELEASE_MAX_AGE_DAYS:-5}"
|
||||
if ! [[ "$MAX_AGE_DAYS" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid NIGHTLY_RELEASE_MAX_AGE_DAYS: ${MAX_AGE_DAYS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SECONDS_PER_DAY=86400
|
||||
|
||||
# The publish time of the latest published nightly release, like the tag
|
||||
# 'v0.2.0-nightly-20230313'. Fail open on GitHub API errors: proceeding with
|
||||
# the release is safer than skipping it.
|
||||
if ! latest_nightly_published_at="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq '[.[] | select(.draft == false and (.tag_name | contains("nightly")))][0].published_at // empty')"; then
|
||||
echo "::warning::Failed to query the latest nightly release from the GitHub API, will proceed with the nightly release." >&2
|
||||
echo "true"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$latest_nightly_published_at" ]; then
|
||||
echo "No published nightly release found, will proceed with the nightly release." >&2
|
||||
echo "true"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
published_at_epoch="$(date --date="$latest_nightly_published_at" +%s)"
|
||||
now_epoch="$(date +%s)"
|
||||
age_seconds=$(( now_epoch - published_at_epoch ))
|
||||
age_days=$(( age_seconds / SECONDS_PER_DAY ))
|
||||
|
||||
echo "The latest nightly release was published at ${latest_nightly_published_at} (${age_days} days ago)." >&2
|
||||
|
||||
if [ "$age_seconds" -gt $(( MAX_AGE_DAYS * SECONDS_PER_DAY )) ]; then
|
||||
echo "The latest nightly release is older than ${MAX_AGE_DAYS} days, will proceed with the nightly release." >&2
|
||||
echo "true"
|
||||
else
|
||||
echo "The latest nightly release is not older than ${MAX_AGE_DAYS} days, will skip the nightly release." >&2
|
||||
echo "false"
|
||||
fi
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host side of oswal-s3-bench.yml: one subcommand per step; each cell of $PLAN gets a fresh GreptimeDB.
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
# Same isolation as the o11ybench runtime's own docker run.
|
||||
runtime() {
|
||||
docker run --rm --user "$(id -u):$(id -g)" --label "o11ybench.owner=$OWNER" \
|
||||
--security-opt no-new-privileges --cap-drop ALL "$@"
|
||||
}
|
||||
|
||||
check_runtime() {
|
||||
local image=$1 resolved
|
||||
[[ "$image" == public.ecr.aws/* ]] || { echo 'The runtime image must come from AWS ECR Public' >&2; return 1; }
|
||||
bash "$here/pull-benchmark-image.sh" "$image"
|
||||
resolved=$(docker image inspect "$image" | jq -er '.[0].RepoDigests[0]')
|
||||
docker run --rm --entrypoint sh "$resolved" -c \
|
||||
'test -f scripts/agent-observability/greptimedb.sh && test -f scripts/generate_agent_observations.py' \
|
||||
|| { echo 'The runtime image lacks the agent observability loader or generator' >&2; return 1; }
|
||||
printf 'runtime_image=%s\n' "$resolved" >> "${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
}
|
||||
|
||||
pull_images() {
|
||||
: "${RESULTS:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_TAG:?}"
|
||||
mkdir -p "$RESULTS/images"
|
||||
bash "$here/pull-benchmark-image.sh" "$RUNTIME_IMAGE"
|
||||
bash "$here/pull-benchmark-image.sh" "greptime/greptimedb:$GREPTIMEDB_TAG"
|
||||
docker image inspect "$RUNTIME_IMAGE" > "$RESULTS/images/runtime.json"
|
||||
docker image inspect "greptime/greptimedb:$GREPTIMEDB_TAG" > "$RESULTS/images/greptimedb.json"
|
||||
}
|
||||
|
||||
wal_disk() {
|
||||
: "${RESULTS:?}"
|
||||
local root=$1 device
|
||||
device=$(lsblk -dnpo NAME,MODEL | awk '/Instance Storage/ {print $1; exit}')
|
||||
# Only a smoke run may keep the Raft Engine WAL on the root volume.
|
||||
[[ -n "$device" || "${SMOKE:-}" == true ]] || { echo 'No local NVMe instance store; choose an instance type with one' >&2; return 1; }
|
||||
sudo -n mkdir -p "$root"
|
||||
if [[ -n "$device" ]]; then
|
||||
sudo -n umount "$device" 2>/dev/null || true
|
||||
sudo -n mkfs.ext4 -q -F "$device"
|
||||
sudo -n mount -o noatime "$device" "$root"
|
||||
fi
|
||||
sudo -n chown "$(id -u):$(id -g)" "$root"
|
||||
findmnt -no SOURCE,FSTYPE -T "$root" | tee "$RESULTS/wal-disk.txt"
|
||||
}
|
||||
|
||||
generate() {
|
||||
: "${PLAN:?}" "${RESULTS:?}" "${CORPUS_DIR:?}" "${OWNER:?}" "${RUNTIME_IMAGE:?}"
|
||||
local args=() arg
|
||||
while IFS= read -r arg; do args+=("$arg"); done < <(jq -r '.generator_args[]' "$PLAN")
|
||||
mkdir -p "$CORPUS_DIR"
|
||||
runtime --name "$OWNER-generator" --mount "type=bind,src=$CORPUS_DIR,dst=/work/corpus" \
|
||||
"$RUNTIME_IMAGE" scripts/generate_agent_observations.py "${args[@]}" \
|
||||
--output /work/corpus/agent_observations.jsonl \
|
||||
--summary-output /work/corpus/summary.json --evidence-output /work/corpus/evidence.json \
|
||||
> "$RESULTS/generator.log" 2>&1
|
||||
jq -e --argjson rows "$(jq .rows "$PLAN")" '.line_count == $rows' "$CORPUS_DIR/summary.json" >/dev/null
|
||||
}
|
||||
|
||||
# Scrape once, after the load succeeded or failed, while the server still runs.
|
||||
scrape_metrics() {
|
||||
curl --noproxy '*' -fsS --max-time 30 http://127.0.0.1:4000/metrics > "$dir/metrics.all" || { rm -f "$dir/metrics.all"; return 1; }
|
||||
grep -E '^(# (HELP|TYPE) )?greptime_logstore_object_store_wal_' "$dir/metrics.all" > "$dir/metrics.prom" || true
|
||||
rm -f "$dir/metrics.all"
|
||||
metrics_scraped=true
|
||||
}
|
||||
|
||||
finish() {
|
||||
local rc=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
if [[ -n "$cid" ]]; then
|
||||
# A load that succeeded without its metrics is not a measurement.
|
||||
scrape_metrics || { echo "$id: metrics scrape failed" >&2; [[ $rc != 0 ]] || rc=4; }
|
||||
docker stop -t 120 "$cid" >/dev/null
|
||||
docker logs "$cid" > "$dir/server.log" 2>&1
|
||||
docker rm -fv "$cid" >/dev/null
|
||||
fi
|
||||
[[ -z "$wal_dir" ]] || sudo -n rm -rf "$wal_dir"
|
||||
jq -n --argjson spec "$spec" --arg device "$wal_device" --arg version "$version" \
|
||||
--arg load_seconds "$load_seconds" --argjson exit_code "$rc" --argjson scraped "$metrics_scraped" \
|
||||
'$spec + {wal_device: $device, version: $version, exit_code: $exit_code, metrics_scraped: $scraped,
|
||||
load_wall_seconds: ($load_seconds | tonumber? // null)}' > "$dir/cell.json"
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# One cell in its own process, so errexit holds inside it. The cell's state is
|
||||
# global because the EXIT trap runs after this function returns.
|
||||
cell() {
|
||||
: "${PLAN:?}" "${RESULTS:?}" "${CORPUS_DIR:?}" "${OWNER:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_IMAGE:?}"
|
||||
: "${RUN_PREFIX:?}" "${WAL_ROOT:?}" "${S3_BUCKET:?}" "${S3_REGION:?}" "${AWS_ACCESS_KEY_ID:?}" "${AWS_SECRET_ACCESS_KEY:?}"
|
||||
spec=$1 cid= wal_dir= version= load_seconds= metrics_scraped=false id=
|
||||
local concurrency name setting started ended
|
||||
id=$(jq -r .id <<< "$spec"); concurrency=$(jq -r .concurrency <<< "$spec")
|
||||
dir="$RESULTS/cells/$id"; name="$OWNER-$id"
|
||||
mkdir -p "$dir"
|
||||
# The root holds data/ and the WAL's default prefix wal/ side by side.
|
||||
local settings=(STORAGE__TYPE=S3 "STORAGE__BUCKET=$S3_BUCKET" "STORAGE__REGION=$S3_REGION"
|
||||
"STORAGE__ROOT=$RUN_PREFIX/$id")
|
||||
[[ -z "${S3_CONTAINER_ENDPOINT:-}" ]] || settings+=("STORAGE__ENDPOINT=$S3_CONTAINER_ENDPOINT")
|
||||
while IFS= read -r setting; do settings+=("$setting"); done \
|
||||
< <(jq -r '.wal | to_entries[] | "WAL__\(.key)=\(.value)"' <<< "$spec")
|
||||
local docker_env=() mounts=()
|
||||
for setting in "${settings[@]}"; do
|
||||
docker_env+=(-e "GREPTIMEDB_STANDALONE__$setting")
|
||||
printf 'GREPTIMEDB_STANDALONE__%s\n' "$setting" >> "$dir/config.env"
|
||||
done
|
||||
# Pass the two keys by name: their values never reach argv, a file or the artifact.
|
||||
export GREPTIMEDB_STANDALONE__STORAGE__ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
|
||||
export GREPTIMEDB_STANDALONE__STORAGE__SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
|
||||
docker_env+=(-e GREPTIMEDB_STANDALONE__STORAGE__ACCESS_KEY_ID -e GREPTIMEDB_STANDALONE__STORAGE__SECRET_ACCESS_KEY)
|
||||
printf 'GREPTIMEDB_STANDALONE__STORAGE__%s=<redacted>\n' ACCESS_KEY_ID SECRET_ACCESS_KEY >> "$dir/config.env"
|
||||
if jq -e '.wal.DIR' <<< "$spec" >/dev/null; then
|
||||
wal_dir="$WAL_ROOT/$id"; mkdir -p "$wal_dir"
|
||||
mounts=(--mount "type=bind,src=$wal_dir,dst=$(jq -r .wal.DIR <<< "$spec")")
|
||||
wal_device=$(findmnt -no SOURCE -T "$wal_dir")
|
||||
else
|
||||
wal_device="s3://$S3_BUCKET/$RUN_PREFIX/$id/wal"
|
||||
fi
|
||||
trap finish EXIT
|
||||
cid=$(docker run -d --init --name "$name" --label "o11ybench.owner=$OWNER" \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-p 127.0.0.1:4000:4000 -p 127.0.0.1:4001:4001 "${docker_env[@]}" ${mounts[@]+"${mounts[@]}"} \
|
||||
"$GREPTIMEDB_IMAGE" standalone start --http-addr=0.0.0.0:4000 --grpc-bind-addr=0.0.0.0:4001)
|
||||
local deadline=$((SECONDS + 300))
|
||||
until curl --noproxy '*' -fsS --max-time 5 http://127.0.0.1:4000/health >/dev/null 2>&1; do
|
||||
[[ $(docker inspect --format '{{.State.Running}}' "$cid") == true ]] || { echo "$id: server exited" >&2; exit 1; }
|
||||
((SECONDS < deadline)) || { echo "$id: server not healthy in 300s" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
version=$(docker exec "$cid" greptime --version | sed -n 's/^version: //p')
|
||||
[[ -n "$version" ]]
|
||||
started=$(date +%s.%N)
|
||||
# Only the load phase, run exactly as run-agent-observability.sh runs it.
|
||||
runtime --name "$name-load" --network host \
|
||||
--mount "type=bind,src=$CORPUS_DIR,dst=/work/corpus,readonly" --mount "type=bind,src=$dir,dst=/results" \
|
||||
-e GRPC_ENDPOINT=127.0.0.1:4001 -e "DATA_PROFILE=$(jq -r .data_profile "$PLAN")" -e RESULT_NAME=greptimedb-1 \
|
||||
-e "ENDPOINT=http://127.0.0.1:4000/v1/sql?db=public&format=greptimedb_v1" \
|
||||
-e "TARGET_IMAGE=$GREPTIMEDB_IMAGE" -e "TARGET_VERSION=$version" -e "TARGET_DIGEST=${GREPTIMEDB_IMAGE##*@}" \
|
||||
-e "TARGET_ID=$name" -e TARGET_HOST=127.0.0.1 -e PREPARE_WORKERS= -e "MAX_IN_FLIGHT=$concurrency" \
|
||||
-e "GREPTIMEDB_BATCH_SIZE=$(jq -r .batch_rows "$PLAN")" --entrypoint /bin/bash \
|
||||
"$RUNTIME_IMAGE" scripts/agent-observability/greptimedb.sh load > "$dir/load.log" 2>&1
|
||||
ended=$(date +%s.%N)
|
||||
load_seconds=$(awk -v a="$started" -v b="$ended" 'BEGIN {printf "%.3f", b - a}')
|
||||
jq -e --argjson rows "$(jq .rows "$PLAN")" '.count_before == 0 and .count_after == $rows' \
|
||||
"$dir/greptimedb-1/load-result.json" >/dev/null
|
||||
}
|
||||
|
||||
scrub() {
|
||||
: "${RESULTS:?}"
|
||||
[[ -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" && -d "$RESULTS" ]] || return 0
|
||||
if grep -rqF -e "$AWS_ACCESS_KEY_ID" -e "$AWS_SECRET_ACCESS_KEY" "$RESULTS"; then
|
||||
echo '::error::A credential value appears in the results; removing them instead of uploading' >&2
|
||||
rm -rf "$RESULTS"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
check-runtime) check_runtime "${2:?}" ;;
|
||||
pull) pull_images ;;
|
||||
wal-disk) wal_disk "${2:?}" ;;
|
||||
generate) generate ;;
|
||||
cell) cell "${2:?}" ;;
|
||||
cells)
|
||||
: "${PLAN:?}"
|
||||
failed=0
|
||||
while IFS= read -r spec; do
|
||||
id=$(jq -r .id <<< "$spec")
|
||||
echo "::group::cell $id"
|
||||
bash "${BASH_SOURCE[0]}" cell "$spec" < /dev/null || { echo "::error::cell $id failed"; failed=1; }
|
||||
echo '::endgroup::'
|
||||
done < <(jq -c '.cells[]' "$PLAN")
|
||||
exit "$failed" ;;
|
||||
scrub) scrub ;;
|
||||
*) echo "usage: $0 check-runtime IMAGE | pull | wal-disk DIR | generate | cells | scrub" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Summarize the object store WAL S3 benchmark as Markdown and summary.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PREFIX = "greptime_logstore_object_store_wal_"
|
||||
COUNTERS = ("created_objects_total", "stalled_appends_total", "poisoned_total",
|
||||
"create_failures_total", "create_conflicts_total")
|
||||
SAMPLE = re.compile(r'([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(\S+)')
|
||||
|
||||
|
||||
def parse_metrics(text: str) -> dict:
|
||||
"""Map each unlabeled sample to its value and each histogram to its buckets."""
|
||||
samples, buckets = {}, {}
|
||||
for line in text.splitlines():
|
||||
match = SAMPLE.fullmatch(line.strip())
|
||||
if not match or not match.group(1).startswith(PREFIX):
|
||||
continue
|
||||
name, labels, value = match.group(1)[len(PREFIX):], match.group(2) or "", float(match.group(3))
|
||||
le = re.search(r'le="([^"]+)"', labels)
|
||||
if name.endswith("_bucket") and le:
|
||||
buckets.setdefault(name[:-len("_bucket")], []).append((float(le.group(1)), value))
|
||||
else:
|
||||
samples[name] = value
|
||||
return {"samples": samples, "buckets": {name: sorted(items) for name, items in buckets.items()}}
|
||||
|
||||
|
||||
def quantile(q: float, buckets: list) -> float | None:
|
||||
"""Prometheus histogram_quantile over cumulative (upper bound, count) buckets."""
|
||||
if not buckets or buckets[-1][1] == 0:
|
||||
return None
|
||||
rank = q * buckets[-1][1]
|
||||
lower, below = 0.0, 0.0
|
||||
for upper, count in buckets:
|
||||
if count >= rank:
|
||||
if math.isinf(upper):
|
||||
return lower
|
||||
return lower + (upper - lower) * (rank - below) / (count - below) if count > below else upper
|
||||
lower, below = upper, count
|
||||
return lower
|
||||
|
||||
|
||||
def geomean(values: list) -> float | None:
|
||||
return math.exp(sum(map(math.log, values)) / len(values)) if values and all(v > 0 for v in values) else None
|
||||
|
||||
|
||||
def load_cell(path: Path, spec: dict | None = None) -> dict:
|
||||
"""One cell's row; a planned cell that never recorded a result has no values, so no ratio uses its round."""
|
||||
recorded = path / "cell.json"
|
||||
cell = json.loads(recorded.read_text()) if recorded.exists() else spec | {"exit_code": None, "missing": True}
|
||||
result_path = path / "greptimedb-1" / "load-result.json"
|
||||
result = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
metrics_path = path / "metrics.prom"
|
||||
metrics = parse_metrics(metrics_path.read_text() if metrics_path.exists() else "")
|
||||
ack = metrics["buckets"].get("append_ack_seconds", [])
|
||||
load_seconds = result.get("load_seconds")
|
||||
row = {
|
||||
"id": cell["id"], "round": cell["round"], "concurrency": cell["concurrency"],
|
||||
"config": cell["config"], "exit_code": cell["exit_code"], "wal_device": cell.get("wal_device"),
|
||||
"missing": cell.get("missing", False),
|
||||
"version": cell.get("version"), "load_wall_seconds": cell.get("load_wall_seconds"),
|
||||
"rows": result.get("count_after"), "load_seconds": load_seconds,
|
||||
"rows_per_second": result.get("rows_per_second") if cell["exit_code"] == 0 else None,
|
||||
"append_ack_p50_seconds": quantile(0.5, ack), "append_ack_p99_seconds": quantile(0.99, ack),
|
||||
}
|
||||
# The counters register on their first event, so a scrape of an object store WAL
|
||||
# that created objects omits the ones that never moved: those are zero, not unknown.
|
||||
active = (cell.get("metrics_scraped") and cell.get("wal", {}).get("PROVIDER") == "experimental_object_store"
|
||||
and metrics["samples"].get("created_objects_total"))
|
||||
for counter in COUNTERS:
|
||||
row[counter] = metrics["samples"].get(counter, 0 if active else None)
|
||||
created = row["created_objects_total"]
|
||||
row["creates_per_minute"] = created * 60 / load_seconds if created is not None and load_seconds else None
|
||||
return row
|
||||
|
||||
|
||||
def ratios(rows: list) -> list:
|
||||
"""D/R and E/R per round and their geometric mean over the rounds, per concurrency."""
|
||||
speed = {(r["round"], r["concurrency"], r["config"]): r["rows_per_second"] for r in rows}
|
||||
result = []
|
||||
for concurrency in sorted({r["concurrency"] for r in rows}):
|
||||
rounds = sorted({r["round"] for r in rows if r["concurrency"] == concurrency})
|
||||
for config in ("D", "E"):
|
||||
per_round = []
|
||||
for round_number in rounds:
|
||||
base, value = speed.get((round_number, concurrency, "R")), speed.get((round_number, concurrency, config))
|
||||
per_round.append(value / base if base and value else None)
|
||||
complete = [value for value in per_round if value is not None]
|
||||
result.append({"concurrency": concurrency, "ratio": f"{config}/R", "per_round": per_round,
|
||||
"geomean": geomean(complete) if len(complete) == len(per_round) else None})
|
||||
return result
|
||||
|
||||
|
||||
def fmt(value, digits=0, scale=1.0) -> str:
|
||||
return "—" if value is None else f"{value * scale:,.{digits}f}"
|
||||
|
||||
|
||||
def render(run: dict, rows: list, pairs: list) -> str:
|
||||
lines = [f"## Object store WAL against S3 ({'smoke, MinIO' if run.get('smoke') else run.get('instance_type')})", ""]
|
||||
lines += [f"GreptimeDB `{run.get('greptimedb_image')}` ({next((r['version'] for r in rows if r['version']), '—')}); "
|
||||
f"{fmt(run.get('rows'))} rows in {run.get('batch_rows')}-row batches; bucket region {run.get('region')}.", ""]
|
||||
lines += ["| Cell | Rows/s | Load s | Wall s | Rows | Ack p50 ms | Ack p99 ms | Creates | Creates/min "
|
||||
"| Stalled | Poisoned | Create failures | Conflicts | WAL on |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"]
|
||||
for r in rows:
|
||||
cell = f"{r['id']} (not run)" if r["missing"] else r["id"] if r["exit_code"] == 0 \
|
||||
else f"{r['id']} (failed, exit {r['exit_code']})"
|
||||
lines.append(" | ".join([
|
||||
f"| {cell}", fmt(r["rows_per_second"]), fmt(r["load_seconds"], 1), fmt(r["load_wall_seconds"], 1),
|
||||
fmt(r["rows"]), fmt(r["append_ack_p50_seconds"], 1, 1000), fmt(r["append_ack_p99_seconds"], 1, 1000),
|
||||
*(fmt(r[c]) for c in COUNTERS[:1]), fmt(r["creates_per_minute"], 1),
|
||||
*(fmt(r[c]) for c in COUNTERS[1:]), f"`{r['wal_device'] or '—'}` |"]))
|
||||
lines += ["", "| Concurrency | Ratio | Per round | Geometric mean |", "| ---: | --- | --- | ---: |"]
|
||||
for p in pairs:
|
||||
lines.append(f"| {p['concurrency']} | {p['ratio']} | {', '.join(fmt(v, 3) for v in p['per_round'])} "
|
||||
f"| {fmt(p['geomean'], 3)} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.root.mkdir(parents=True, exist_ok=True)
|
||||
run_path = args.root / "run.json"
|
||||
run = json.loads(run_path.read_text()) if run_path.exists() else {}
|
||||
plan_path = args.root / "plan.json"
|
||||
planned = json.loads(plan_path.read_text())["cells"] if plan_path.exists() else []
|
||||
# The plan, not what happened to run, defines the expected cells and rounds.
|
||||
rows = [load_cell(args.root / "cells" / spec["id"], spec) for spec in planned]
|
||||
rows += [load_cell(path) for path in sorted((args.root / "cells").glob("*"))
|
||||
if path.name not in {spec["id"] for spec in planned} and (path / "cell.json").exists()]
|
||||
pairs = ratios(rows)
|
||||
missing = [r["id"] for r in rows if r["missing"]]
|
||||
(args.root / "summary.json").write_text(json.dumps(
|
||||
{"run": run, "planned_cells": len(planned), "missing_cells": missing, "cells": rows, "ratios": pairs},
|
||||
indent=2) + "\n")
|
||||
print(render(run, rows, pairs) if rows else "No benchmark cell completed.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["boto3>=1.35,<2"]
|
||||
# ///
|
||||
|
||||
"""Helpers of the object store WAL S3 benchmark; only `probe` and `cleanup` need boto3 (`uv run`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SMOKE_ROWS = 50_000
|
||||
# The o11ybench data profiles; a matching row count generates the canonical dataset.
|
||||
DATA_PROFILES = {5_000: "S", 2_000_000: "D", 10_000_000: "P", 100_000_000: "M"}
|
||||
# Identical [storage] for every configuration; only [wal] differs.
|
||||
WAL_CONFIGS = {
|
||||
"R": {"PROVIDER": "raft_engine", "SYNC_WRITE": "true", "DIR": "/wal"},
|
||||
"D": {"PROVIDER": "experimental_object_store", "ACK_MODE": "durable"},
|
||||
"E": {"PROVIDER": "experimental_object_store", "ACK_MODE": "enqueued"},
|
||||
}
|
||||
ORDERS = (("R", "D", "E"), ("E", "D", "R"))
|
||||
NIGHTLY = re.compile(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?-nightly-(\d{8})")
|
||||
TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}")
|
||||
DIGEST = re.compile(r".+@sha256:[0-9a-f]{64}")
|
||||
|
||||
|
||||
def append_github(variable: str, name: str, value: str) -> None:
|
||||
if path := os.environ.get(variable):
|
||||
with open(path, "a") as output:
|
||||
output.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def positive(name: str, value: str, upper: int) -> int:
|
||||
if not re.fullmatch(r"[1-9][0-9]*", value) or int(value) > upper:
|
||||
raise ValueError(f"{name} must be an integer in 1..{upper}, got {value!r}")
|
||||
return int(value)
|
||||
|
||||
|
||||
def validate_budgets(timeout_minutes: str, ttl_hours: str) -> None:
|
||||
timeout, ttl = positive("timeout", timeout_minutes, 360), positive("ttl", ttl_hours, 168)
|
||||
# Provisioning, teardown and queueing need two hours beyond the benchmark job.
|
||||
if ttl * 60 < timeout + 120:
|
||||
raise ValueError(f"janitor TTL {ttl}h must cover the {timeout}-minute timeout plus 2 hours")
|
||||
|
||||
|
||||
def check_regions(bucket: str, runner: str) -> None:
|
||||
if not bucket or bucket != runner:
|
||||
raise ValueError(f"bucket region {bucket!r} differs from runner region {runner!r}; "
|
||||
"a cross-region bucket measures the WAN, not S3")
|
||||
|
||||
|
||||
def build_manifest(plan: dict, greptimedb_inspect: list, runtime_image: str,
|
||||
instance_type: str, region: str, prefix: str) -> dict:
|
||||
image = next((d for d in greptimedb_inspect[0].get("RepoDigests") or [] if DIGEST.fullmatch(d)), None)
|
||||
if image is None:
|
||||
raise ValueError("the GreptimeDB image has no sha256 repository digest")
|
||||
return {"smoke": plan["smoke"], "rows": plan["rows"], "rounds": plan["rounds"],
|
||||
"concurrency": plan["concurrency"], "batch_rows": plan["batch_rows"],
|
||||
"greptimedb_image": image, "runtime_image": runtime_image,
|
||||
"instance_type": instance_type, "region": region, "prefix": prefix}
|
||||
|
||||
|
||||
def build_plan(concurrency: str, rows: str, batch_rows: str, rounds: str, smoke: bool) -> dict:
|
||||
levels = [positive("concurrency", level.strip(), 256) for level in concurrency.split(",")]
|
||||
if len(set(levels)) != len(levels):
|
||||
raise ValueError(f"concurrency repeats a level: {concurrency!r}")
|
||||
row_count = SMOKE_ROWS if smoke else positive("rows", rows, 100_000_000)
|
||||
round_count = 1 if smoke else positive("rounds", rounds, 10)
|
||||
profile = DATA_PROFILES.get(row_count)
|
||||
cells = []
|
||||
for round_number in range(1, round_count + 1):
|
||||
# Alternate the order between rounds so no configuration always runs first.
|
||||
for level in levels:
|
||||
for config in ORDERS[(round_number - 1) % 2]:
|
||||
cells.append({
|
||||
"id": f"{round_number}-{level}-{config}", "round": round_number,
|
||||
"concurrency": level, "config": config, "wal": WAL_CONFIGS[config],
|
||||
})
|
||||
return {
|
||||
"smoke": smoke, "rows": row_count, "rounds": round_count,
|
||||
"batch_rows": positive("batch_rows", batch_rows, 100_000), "concurrency": levels,
|
||||
"data_profile": profile or "custom",
|
||||
"generator_args": ["--data-profile", profile] if profile else ["--line-count", str(row_count)],
|
||||
"cells": cells,
|
||||
}
|
||||
|
||||
|
||||
def newest_nightly(tags: list[str]) -> str:
|
||||
dated = [(match.group(1), tag) for tag in tags if (match := NIGHTLY.fullmatch(tag))]
|
||||
if not dated:
|
||||
raise ValueError("no v*-nightly-* tag found")
|
||||
return max(dated)[1]
|
||||
|
||||
|
||||
def resolve_tag(requested: str) -> str:
|
||||
if requested != "nightly":
|
||||
if not TAG.fullmatch(requested):
|
||||
raise ValueError(f"invalid image tag: {requested!r}")
|
||||
return requested
|
||||
url = ("https://hub.docker.com/v2/repositories/greptime/greptimedb/tags"
|
||||
"?page_size=100&name=nightly&ordering=last_updated")
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
return newest_nightly([tag["name"] for tag in json.load(response)["results"]])
|
||||
|
||||
|
||||
def s3_client(args):
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
return boto3.client("s3", region_name=args.region, endpoint_url=args.endpoint or None,
|
||||
config=Config(retries={"mode": "standard", "max_attempts": 5}))
|
||||
|
||||
|
||||
def probe(args) -> None:
|
||||
client, key = s3_client(args), f"{args.prefix}/probe/object"
|
||||
deadline = time.monotonic() + 60
|
||||
while True:
|
||||
try:
|
||||
client.put_object(Bucket=args.bucket, Key=key, Body=b"probe")
|
||||
break
|
||||
except Exception:
|
||||
# A fresh MinIO creates its bucket after the container starts.
|
||||
if time.monotonic() > deadline:
|
||||
raise
|
||||
time.sleep(3)
|
||||
listed = client.list_objects_v2(Bucket=args.bucket, Prefix=f"{args.prefix}/probe/")
|
||||
if [item["Key"] for item in listed.get("Contents", [])] != [key]:
|
||||
raise RuntimeError(f"listing {args.prefix}/probe/ did not return exactly {key}")
|
||||
client.delete_object(Bucket=args.bucket, Key=key)
|
||||
print(f"put, list and delete succeeded under s3://{args.bucket}/{args.prefix}/probe/")
|
||||
|
||||
|
||||
def cleanup(args) -> None:
|
||||
# One DeleteObject per key: DeleteObjects needs a checksum header the MinIO fixture rejects.
|
||||
client, deleted = s3_client(args), 0
|
||||
for page in client.get_paginator("list_objects_v2").paginate(Bucket=args.bucket, Prefix=f"{args.prefix}/"):
|
||||
for item in page.get("Contents", []):
|
||||
client.delete_object(Bucket=args.bucket, Key=item["Key"])
|
||||
deleted += 1
|
||||
print(f"deleted {deleted} objects under s3://{args.bucket}/{args.prefix}/")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
plan = commands.add_parser("plan")
|
||||
for name in ("concurrency", "rows", "batch-rows", "rounds"):
|
||||
plan.add_argument(f"--{name}", required=True)
|
||||
plan.add_argument("--smoke", choices=("true", "false"), required=True)
|
||||
for name in ("timeout-minutes", "ttl-hours", "output"):
|
||||
plan.add_argument(f"--{name}")
|
||||
commands.add_parser("tag").add_argument("requested")
|
||||
regions = commands.add_parser("regions")
|
||||
for name in ("bucket", "runner"):
|
||||
regions.add_argument(f"--{name}", required=True)
|
||||
manifest = commands.add_parser("manifest")
|
||||
for name in ("results", "runtime-image", "instance-type", "region", "prefix"):
|
||||
manifest.add_argument(f"--{name}", required=True)
|
||||
for name in ("probe", "cleanup"):
|
||||
bucket = commands.add_parser(name)
|
||||
bucket.add_argument("--endpoint", default="")
|
||||
for option in ("bucket", "region", "prefix"):
|
||||
bucket.add_argument(f"--{option}", required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == "plan":
|
||||
if args.timeout_minutes or args.ttl_hours:
|
||||
validate_budgets(args.timeout_minutes or "", args.ttl_hours or "")
|
||||
result = build_plan(args.concurrency, args.rows, args.batch_rows, args.rounds, args.smoke == "true")
|
||||
if args.output:
|
||||
with open(args.output, "w") as output:
|
||||
json.dump(result, output, indent=2)
|
||||
print(f"{len(result['cells'])} cells, {result['rows']} rows, profile {result['data_profile']}")
|
||||
elif args.command == "tag":
|
||||
tag = resolve_tag(args.requested)
|
||||
append_github("GITHUB_OUTPUT", "greptimedb_tag", tag)
|
||||
print(f"GreptimeDB image tag: {tag}")
|
||||
elif args.command == "regions":
|
||||
check_regions(args.bucket, args.runner)
|
||||
print(f"bucket and runner both in {args.runner}")
|
||||
elif args.command == "manifest":
|
||||
results = Path(args.results)
|
||||
manifest = build_manifest(json.loads((results / "plan.json").read_text()),
|
||||
json.loads((results / "images" / "greptimedb.json").read_text()),
|
||||
args.runtime_image, args.instance_type, args.region, args.prefix)
|
||||
(results / "run.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
append_github("GITHUB_ENV", "GREPTIMEDB_IMAGE", manifest["greptimedb_image"])
|
||||
print(f"GreptimeDB image: {manifest['greptimedb_image']}")
|
||||
else:
|
||||
if not re.fullmatch(r"oswal-bench/[0-9]+-[0-9]+", args.prefix):
|
||||
raise ValueError(f"refusing a prefix outside oswal-bench/<run>: {args.prefix!r}")
|
||||
(probe if args.command == "probe" else cleanup)(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except ValueError as error:
|
||||
print(f"::error::{error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pull only: never retry database writes, benchmarks, or cloud provisioning.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# != 1 || -z "$1" || "$1" == -* ]]; then
|
||||
echo "Usage: $0 IMAGE" >&2
|
||||
exit 2
|
||||
fi
|
||||
image=$1
|
||||
attempts=3
|
||||
for ((attempt=1; attempt<=attempts; attempt++)); do
|
||||
echo "Pulling $image (attempt $attempt/$attempts)" >&2
|
||||
if timeout --kill-after=10s 180s docker pull "$image"; then
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if ((attempt == attempts)); then
|
||||
echo "Failed to pull $image after $attempts attempts (exit $status)" >&2
|
||||
exit "$status"
|
||||
fi
|
||||
delay=$((attempt * 10))
|
||||
echo "Pull failed (exit $status); retrying in ${delay}s" >&2
|
||||
sleep "$delay"
|
||||
done
|
||||
@@ -0,0 +1,169 @@
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import html
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from benchmark_resource_summary import render_resources
|
||||
|
||||
TARGETS = ('greptimedb', 'victoriatraces', 'tempo')
|
||||
|
||||
|
||||
def duration(ms):
|
||||
if not isinstance(ms, (int, float)) or not math.isfinite(ms) or ms < 0:
|
||||
return '—'
|
||||
if ms < 1:
|
||||
return f'{ms * 1000:.0f} µs'
|
||||
if ms < 1000:
|
||||
return f'{ms:.1f} ms'
|
||||
if ms < 60_000:
|
||||
return f'{ms / 1000:.2f} s'
|
||||
minutes, seconds = divmod(round(ms / 1000), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f'{hours}h {minutes}m {seconds}s' if hours else f'{minutes}m {seconds}s'
|
||||
|
||||
|
||||
def cell(value):
|
||||
return html.escape(str(value if value is not None else '—')).replace('|', '|').replace('\n', ' ')
|
||||
|
||||
|
||||
def read(path):
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def table(headers, rows):
|
||||
return '\n'.join(['| ' + ' | '.join(map(cell, headers)) + ' |',
|
||||
'| ' + ' | '.join('---' for _ in headers) + ' |'] +
|
||||
['| ' + ' | '.join(map(cell, row)) + ' |' for row in rows])
|
||||
|
||||
|
||||
def delta(value, baseline):
|
||||
if not all(isinstance(v, (int, float)) and math.isfinite(v) for v in (value, baseline)):
|
||||
return '—'
|
||||
return f'{(value / baseline - 1) * 100:+.1f}%' if baseline > 0 and value >= 0 else '—'
|
||||
|
||||
|
||||
|
||||
def load(root):
|
||||
report = read(root / 'report.json')
|
||||
runs = [read(p) for p in sorted((root / 'runs').glob('*/run.json'))]
|
||||
return report, runs
|
||||
|
||||
|
||||
def benchmark(report, runs, targets=TARGETS):
|
||||
lines = ['# Tracesbench benchmark summary', '',
|
||||
'Median Δ = (target / GreptimeDB − 1) × 100%; negative is faster. '
|
||||
'Min/median/max come from the validated report, not recalculated samples. '
|
||||
'Counts exclude warmup. Missing or excluded results have no latency comparison.',
|
||||
'', f'Classification: {cell(report.get("classification"))}. '
|
||||
'See the uploaded HTML for query semantics and client-side compensation.', '']
|
||||
statistics = {}
|
||||
for corpus in report.get('per_corpus', []):
|
||||
for stat in corpus.get('statistics', []):
|
||||
if stat['target'] not in targets:
|
||||
continue
|
||||
statistics[(corpus['corpus'], stat['round'], stat['target'], stat['query_id'])] = stat
|
||||
rows, details = [], []
|
||||
for target in targets:
|
||||
selected = [run for run in runs if run.get('target') == target]
|
||||
if not selected:
|
||||
lines.append(f'- **{target}**: unavailable / not run.')
|
||||
for run in selected:
|
||||
lines.append(f'- **{target}**: lifecycle '
|
||||
f'{"completed" if run.get("success") is True else "failed / incomplete"}; '
|
||||
f'query failures: {len(run.get("query_failures", []))}.')
|
||||
for qid in (f'T{i:02}' for i in range(1, 9)):
|
||||
correct = [q for q in run.get('correctness', []) if q.get('query_id') == qid]
|
||||
measured = [q for q in run.get('samples', [])
|
||||
if q.get('query_id') == qid and q.get('phase') == 'measured']
|
||||
errors = [q for q in correct + run.get('samples', [])
|
||||
if q.get('query_id') == qid and q.get('outcome') not in ('passed', 'not_executed')]
|
||||
reason = (run.get('exclusions', {}).get(qid) or
|
||||
run.get('execution', {}).get('query_skips', {}).get(qid))
|
||||
stat = statistics.get((run.get('corpus'), run.get('round'), target, qid), {})
|
||||
base = statistics.get((run.get('corpus'), run.get('round'), 'greptimedb', qid), {})
|
||||
status = ('failed' if errors else 'excluded' if reason else
|
||||
'passed' if stat else
|
||||
'correctness passed; report unavailable' if correct and
|
||||
all(q.get('outcome') == 'passed' for q in correct) else 'unavailable / incomplete')
|
||||
rows.append([run.get('corpus'), run.get('round'), qid, target, status,
|
||||
duration(stat.get('min_ms')), duration(stat.get('median_ms')),
|
||||
duration(stat.get('max_ms')),
|
||||
delta(stat.get('median_ms'), base.get('median_ms')) if target != 'greptimedb' else '—',
|
||||
sum(q.get('outcome') == 'passed' for q in measured),
|
||||
sum(q.get('outcome') != 'passed' for q in measured)])
|
||||
if reason or errors:
|
||||
messages = [str(q.get('error') or q.get('outcome')) for q in errors]
|
||||
details.append(f'- **{target} / {qid}**: {cell(str(reason or "") + " " + "; ".join(messages))[:4000]}')
|
||||
lines += ['', table(['Dataset', 'Round', 'Query', 'DB', 'Status', 'Min', 'Median', 'Max',
|
||||
'Median Δ', 'Measured success', 'Measured errors'], rows), '',
|
||||
'<details>', '<summary>Query failures and exclusions</summary>', '', *details, '', '</details>']
|
||||
if not report:
|
||||
lines += ['', 'Validated report unavailable; raw outcomes are shown without latency statistics.']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def lifecycle(report, runs, root, targets=TARGETS):
|
||||
rows = []
|
||||
for target in targets:
|
||||
selected = [r for r in runs if r.get('target') == target] or [{}]
|
||||
for run in selected:
|
||||
load = run.get('load', {})
|
||||
ingest = load.get('ingest', {})
|
||||
if target == 'tempo' and 'ingest' not in load:
|
||||
ingest = load.get('transport', {}).get('ingest', {})
|
||||
health = run.get('post_load_health', {})
|
||||
execution = run.get('execution', {})
|
||||
start, finish = run.get('started_unix_ns'), run.get('finished_unix_ns')
|
||||
elapsed = (finish - start) / 1_000_000 if isinstance(start, int) and isinstance(finish, int) else None
|
||||
seconds = ingest.get('wall_seconds')
|
||||
rows.append([target, run.get('corpus'), run.get('load_summary', {}).get('after'),
|
||||
duration(elapsed), duration(seconds * 1000 if isinstance(seconds, (int, float)) else None),
|
||||
ingest.get('successful_requests'), ingest.get('failed_requests'),
|
||||
execution.get('load_workers_requested'), execution.get('max_in_flight'),
|
||||
execution.get('db_cpus'), execution.get('db_memory'),
|
||||
health.get('running'), health.get('oom_killed'),
|
||||
run.get('cleanup', {}).get('verified_absent')])
|
||||
return '\n'.join(['# Tracesbench load / lifecycle summary', '',
|
||||
'Total is the recorded target lifecycle; ingest is the sender wall time, '
|
||||
'not the entire load/readiness phase. Health is the post-load snapshot. '
|
||||
'Missing evidence remains blank.', '',
|
||||
table(['DB', 'Dataset', 'Loaded spans', 'Total', 'Ingest',
|
||||
'Write success', 'Write errors', 'Load workers', 'Max in flight',
|
||||
'DB CPU', 'DB memory', 'Running after load', 'OOM after load',
|
||||
'Container removed'], rows), '',
|
||||
render_resources([('dataset', root / 'runs' / 'generate')] +
|
||||
[(t, root / 'runs' / t) for t in targets], table)])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', type=Path, required=True)
|
||||
parser.add_argument('--section', choices=('benchmark', 'lifecycle'), required=True)
|
||||
parser.add_argument('--targets', default=','.join(TARGETS), help='Comma-separated selected engines')
|
||||
args = parser.parse_args()
|
||||
targets = args.targets.split(',')
|
||||
if not targets or any(t not in TARGETS for t in targets) or len(set(targets)) != len(targets):
|
||||
parser.error('targets must be a non-empty comma-separated subset of greptimedb,victoriatraces,tempo')
|
||||
report, runs = load(args.root)
|
||||
print(benchmark(report, runs, targets) if args.section == 'benchmark' else lifecycle(report, runs, args.root, targets))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -26,8 +26,18 @@ on:
|
||||
- M
|
||||
default: S
|
||||
required: true
|
||||
compare_greptimedb:
|
||||
description: Compare baseline and candidate sequentially on the same host (adds one full GreptimeDB run)
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
baseline_greptimedb_tag:
|
||||
description: Baseline Docker Hub greptime/greptimedb tag; required when comparison is enabled
|
||||
type: string
|
||||
default: v1.2.1
|
||||
required: false
|
||||
greptimedb_tag:
|
||||
description: Docker Hub greptime/greptimedb tag
|
||||
description: Candidate Docker Hub greptime/greptimedb tag
|
||||
type: string
|
||||
default: latest
|
||||
required: true
|
||||
@@ -46,9 +56,9 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
runtime_image:
|
||||
description: Aliyun runtime image (tag or digest reference)
|
||||
description: 'Runtime image: auto = AWS ECR Public or Aliyun ACR by provider; explicit tag/digest overrides'
|
||||
type: string
|
||||
default: greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:20260917-8111034e@sha256:ce9dee2abb1e70f907a09ea01854904a91cc97748b0c4ef6f33d9ec34044bec0
|
||||
default: auto
|
||||
required: true
|
||||
provider:
|
||||
description: Cloud provider for the benchmark runner
|
||||
@@ -89,12 +99,14 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
COMPARE_GREPTIMEDB: ${{ inputs.compare_greptimedb }}
|
||||
BASELINE_GREPTIMEDB_TAG: ${{ inputs.baseline_greptimedb_tag }}
|
||||
PROFILE: ${{ inputs.dataset }}
|
||||
GREPTIMEDB_TAG: ${{ inputs.greptimedb_tag }}
|
||||
CLICKHOUSE_TAG: ${{ inputs.clickhouse_tag }}
|
||||
VICTORIALOGS_TAG: ${{ inputs.victorialogs_tag }}
|
||||
O11YBENCH_REF: ${{ inputs.o11ybench_ref }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image != 'auto' && inputs.runtime_image || inputs.provider == 'AWS' && 'public.ecr.aws/i8k6a5e1/greptime/o11ybenchruntime:2f40fb3fdbdc@sha256:8940782216bf5d066308fd71f6e346d950073c41cfb5756c5f3dc8aacf069cbc' || 'greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:2f40fb3fdbdc@sha256:8940782216bf5d066308fd71f6e346d950073c41cfb5756c5f3dc8aacf069cbc' }}
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
REQUESTED_INSTANCE_TYPE: ${{ inputs.instance_type }}
|
||||
SYSTEM_DISK_GIB: ${{ inputs.system_disk_gib }}
|
||||
@@ -118,6 +130,12 @@ jobs:
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- name: Validate version comparison
|
||||
env:
|
||||
RUN_GREPTIMEDB: ${{ inputs.run_greptimedb }}
|
||||
run: |
|
||||
python3 greptimedb/tests/perf/test_benchmark_version_comparison.py
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py validate
|
||||
- name: Resolve CI runner
|
||||
id: runner
|
||||
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider "$PROVIDER" --instance-type "$REQUESTED_INSTANCE_TYPE"
|
||||
@@ -126,6 +144,16 @@ jobs:
|
||||
python3 greptimedb/tests/perf/test_aliyun_ecs_runner_scripts.py
|
||||
python3 greptimedb/tests/perf/test_ci_runner_scripts.py
|
||||
python3 greptimedb/tests/perf/test_agent_observability_summary.py
|
||||
- name: Check runtime supports shared version-comparison data
|
||||
if: inputs.compare_greptimedb
|
||||
run: |
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RUNTIME_IMAGE"
|
||||
docker run --rm --entrypoint grep "$RUNTIME_IMAGE" -q -- '--data-root)' /opt/o11ybench/scripts/run-agent-observability.sh \
|
||||
|| { echo 'Comparison requires a runtime with --data-root support'; exit 1; }
|
||||
- name: Validate dataset admission in runtime
|
||||
run: |
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RUNTIME_IMAGE"
|
||||
docker run --rm "$RUNTIME_IMAGE" scripts/logbench_agent_observability_measured.py --preflight-profile "$PROFILE"
|
||||
- name: Validate requested benchmark
|
||||
id: inputs
|
||||
shell: bash
|
||||
@@ -262,11 +290,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
actual=
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* ]]
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* || "$RUNTIME_IMAGE" == public.ecr.aws/* ]]
|
||||
images='{}'
|
||||
resolve_image() {
|
||||
local target=$1 reference=$2 variable=$3 metadata resolved revision
|
||||
docker pull "$reference"
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$reference"
|
||||
metadata=$(docker image inspect "$reference")
|
||||
resolved=$(jq -er '.[0].RepoDigests[0]' <<< "$metadata")
|
||||
if [[ "$target" == runtime ]]; then
|
||||
@@ -289,6 +317,9 @@ jobs:
|
||||
victorialogs) resolve_image "$target" "victoriametrics/victoria-logs:$VICTORIALOGS_TAG" VICTORIALOGS_IMAGE ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
resolve_image greptimedb-baseline "greptime/greptimedb:$BASELINE_GREPTIMEDB_TAG" BASELINE_GREPTIMEDB_IMAGE
|
||||
fi
|
||||
jq -n --arg sha "$actual" --arg targets "$TARGETS" --arg profile "$PROFILE" \
|
||||
--arg cpus "$DB_CPUS" --arg memory "$DB_MEMORY" --argjson images "$images" \
|
||||
--arg provider "$PROVIDER" --arg instance_type "$INSTANCE_TYPE" --argjson disk_gib "$SYSTEM_DISK_GIB" \
|
||||
@@ -312,6 +343,15 @@ jobs:
|
||||
--owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE" \
|
||||
--root "$ARTIFACT_ROOT" --profile "$PROFILE" --db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY"
|
||||
cp "$MANIFEST_PATH" "$ARTIFACT_ROOT/run-manifest.json"
|
||||
- name: Load and benchmark baseline GreptimeDB
|
||||
id: baseline_greptimedb
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.greptimedb == 'true' && inputs.compare_greptimedb }}
|
||||
run: |-
|
||||
GITHUB_STEP_SUMMARY= bash o11ybench/scripts/run-agent-observability.sh --stage target --execute \
|
||||
--owner "$OWNER-base" --runtime-image "$RESOLVED_RUNTIME_IMAGE" --root "$ARTIFACT_ROOT/baseline" --data-root "$ARTIFACT_ROOT" \
|
||||
--profile "$PROFILE" --db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" \
|
||||
--target greptimedb --image "$BASELINE_GREPTIMEDB_IMAGE" \
|
||||
--greptimedb-http-port "$GREPTIMEDB_HTTP_PORT" --greptimedb-grpc-port "$GREPTIMEDB_GRPC_PORT"
|
||||
- name: Load and benchmark greptimedb
|
||||
id: greptimedb
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.greptimedb == 'true' }}
|
||||
@@ -357,6 +397,13 @@ jobs:
|
||||
GITHUB_STEP_SUMMARY= bash o11ybench/scripts/run-agent-observability.sh --stage cleanup --execute \
|
||||
--owner "$OWNER" --root "$ARTIFACT_ROOT"
|
||||
fi
|
||||
- name: Clean up baseline containers
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |-
|
||||
if [[ -f o11ybench/scripts/run-agent-observability.sh ]]; then
|
||||
GITHUB_STEP_SUMMARY= bash o11ybench/scripts/run-agent-observability.sh --stage cleanup --execute \
|
||||
--owner "$OWNER-base" --root "$ARTIFACT_ROOT/baseline"
|
||||
fi
|
||||
- name: Benchmark summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -367,6 +414,16 @@ jobs:
|
||||
run: |
|
||||
python3 greptimedb/.github/scripts/agent-observability-summary.py \
|
||||
--root "$ARTIFACT_ROOT" --targets "$TARGETS" --section lifecycle >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Baseline load / lifecycle summary
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |
|
||||
echo '## Baseline GreptimeDB' >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 greptimedb/.github/scripts/agent-observability-summary.py \
|
||||
--root "$ARTIFACT_ROOT/baseline" --targets greptimedb --section lifecycle >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: GreptimeDB version comparison
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py report --kind agent --root "$ARTIFACT_ROOT" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload results (no dataset)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -99,6 +99,7 @@ jobs:
|
||||
python3 tests/perf/test_aliyun_ecs_runner_scripts.py
|
||||
python3 tests/perf/test_ci_runner_scripts.py
|
||||
python3 tests/perf/test_agent_observability_summary.py
|
||||
python3 tests/perf/test_tracesbench_summary.py
|
||||
|
||||
check:
|
||||
if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
name: Object Store WAL S3 Benchmark
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
smoke:
|
||||
description: 'Smoke run: ubuntu-latest and the MinIO fixture instead of EC2 and S3, 50000 rows, one round; its numbers mean nothing'
|
||||
type: boolean
|
||||
default: false
|
||||
greptimedb_tag:
|
||||
description: 'Docker Hub greptime/greptimedb tag; nightly = the newest v*-nightly-* tag (latest is a release without the object store WAL)'
|
||||
type: string
|
||||
default: nightly
|
||||
instance_type:
|
||||
description: EC2 instance type in the bucket region; the d of c6id gives a local NVMe instance store for the Raft Engine WAL
|
||||
type: string
|
||||
default: c6id.2xlarge
|
||||
concurrency:
|
||||
description: Comma-separated loader concurrency levels
|
||||
type: string
|
||||
default: '1,8'
|
||||
rows:
|
||||
description: Dataset rows (2000000 = o11ybench profile D)
|
||||
type: number
|
||||
default: 2000000
|
||||
batch_rows:
|
||||
description: Rows per write request
|
||||
type: number
|
||||
default: 1000
|
||||
rounds:
|
||||
description: Rounds; the configuration order alternates between rounds (R, D, E then E, D, R)
|
||||
type: number
|
||||
default: 2
|
||||
runtime_image:
|
||||
description: 'o11ybench runtime image: auto = AWS ECR Public; explicit tag/digest overrides'
|
||||
type: string
|
||||
default: auto
|
||||
benchmark_timeout_minutes:
|
||||
description: Benchmark job timeout in minutes (1..360)
|
||||
type: number
|
||||
default: 240
|
||||
janitor_ttl_hours:
|
||||
description: Instance lifetime before janitor eligibility (1..168 hours; at least timeout + 2h)
|
||||
type: number
|
||||
default: 8
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
SMOKE: ${{ inputs.smoke }}
|
||||
GREPTIMEDB_TAG: ${{ inputs.greptimedb_tag }}
|
||||
REQUESTED_INSTANCE_TYPE: ${{ inputs.instance_type }}
|
||||
CONCURRENCY: ${{ inputs.concurrency }}
|
||||
ROWS: ${{ inputs.rows }}
|
||||
BATCH_ROWS: ${{ inputs.batch_rows }}
|
||||
ROUNDS: ${{ inputs.rounds }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image != 'auto' && inputs.runtime_image || 'public.ecr.aws/i8k6a5e1/greptime/o11ybenchruntime:77a84e1c8136@sha256:c482993ec177b026235d6cd0c61d4fb6d6f1088bc78bdc0e5964a5398de6dba0' }}
|
||||
BENCHMARK_TIMEOUT_MINUTES: ${{ inputs.benchmark_timeout_minutes }}
|
||||
JANITOR_TTL_HOURS: ${{ inputs.janitor_ttl_hours }}
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
instance_type: ${{ steps.runner.outputs.instance_type }}
|
||||
greptimedb_tag: ${{ steps.plan.outputs.greptimedb_tag }}
|
||||
runtime_image: ${{ steps.runtime.outputs.runtime_image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- name: Test benchmark scripts
|
||||
run: |
|
||||
python3 greptimedb/tests/perf/test_oswal_s3_bench_scripts.py
|
||||
python3 greptimedb/tests/perf/test_ci_runner_scripts.py
|
||||
- name: Validate plan, budgets and image tag
|
||||
id: plan
|
||||
run: |
|
||||
python3 greptimedb/.github/scripts/oswal-s3-bench.py plan --smoke "$SMOKE" \
|
||||
--concurrency "$CONCURRENCY" --rows "$ROWS" --batch-rows "$BATCH_ROWS" --rounds "$ROUNDS" \
|
||||
--timeout-minutes "$BENCHMARK_TIMEOUT_MINUTES" --ttl-hours "$JANITOR_TTL_HOURS"
|
||||
python3 greptimedb/.github/scripts/oswal-s3-bench.py tag "$GREPTIMEDB_TAG"
|
||||
- name: Resolve CI runner
|
||||
id: runner
|
||||
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider AWS --instance-type "$REQUESTED_INSTANCE_TYPE"
|
||||
- name: Check bucket and runner regions
|
||||
env:
|
||||
BUCKET_REGION: ${{ vars.AWS_CI_TEST_BUCKET_REGION }}
|
||||
RUNNER_REGION: ${{ vars.EC2_RUNNER_REGION }}
|
||||
run: python3 greptimedb/.github/scripts/oswal-s3-bench.py regions --bucket "$BUCKET_REGION" --runner "$RUNNER_REGION"
|
||||
- name: Check the runtime image
|
||||
id: runtime
|
||||
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh check-runtime "$RUNTIME_IMAGE"
|
||||
provision:
|
||||
needs: validate
|
||||
if: ${{ !inputs.smoke }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
label: ${{ steps.aws.outputs.label }}
|
||||
instance_id: ${{ steps.aws.outputs.instance_id }}
|
||||
runner_name: ${{ steps.aws.outputs.runner_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.EC2_RUNNER_REGION }}
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- name: Create ephemeral EC2 runner
|
||||
id: aws
|
||||
env:
|
||||
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
|
||||
AWS_REGION: ${{ vars.EC2_RUNNER_REGION }}
|
||||
AWS_EC2_IMAGE_ID: ${{ vars.BENCHMARK_EC2_IMAGE_ID || 'auto' }}
|
||||
AWS_EC2_INSTANCE_TYPE: ${{ needs.validate.outputs.instance_type }}
|
||||
AWS_EC2_SUBNET_ID: ${{ vars.EC2_RUNNER_SUBNET_ID }}
|
||||
AWS_EC2_SECURITY_GROUP_ID: ${{ vars.EC2_RUNNER_SECURITY_GROUP_ID }}
|
||||
AWS_EC2_SYSTEM_DISK_GIB: '60'
|
||||
AWS_EC2_TTL_HOURS: ${{ inputs.janitor_ttl_hours }}
|
||||
AWS_EC2_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: uv run greptimedb/.github/scripts/aws-ec2-runner-provision.py
|
||||
benchmark:
|
||||
needs:
|
||||
- validate
|
||||
- provision
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' && (inputs.smoke || needs.provision.result == 'success') }}
|
||||
runs-on: ${{ inputs.smoke && 'ubuntu-latest' || needs.provision.outputs.label }}
|
||||
timeout-minutes: ${{ fromJSON(format('{0}', inputs.benchmark_timeout_minutes)) }}
|
||||
env:
|
||||
INSTANCE_TYPE: ${{ inputs.smoke && 'ubuntu-latest' || needs.validate.outputs.instance_type }}
|
||||
GREPTIMEDB_TAG: ${{ needs.validate.outputs.greptimedb_tag }}
|
||||
RUNTIME_IMAGE: ${{ needs.validate.outputs.runtime_image }}
|
||||
RUN_PREFIX: oswal-bench/${{ github.run_id }}-${{ github.run_attempt }}
|
||||
OWNER: o11ybench-oswal-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RESULTS: ${{ github.workspace }}/oswal-results
|
||||
CORPUS_DIR: ${{ github.workspace }}/oswal-corpus
|
||||
PLAN: ${{ github.workspace }}/oswal-results/plan.json
|
||||
WAL_ROOT: /mnt/oswal-wal
|
||||
S3_BUCKET: ${{ inputs.smoke && 'greptime' || vars.AWS_CI_TEST_BUCKET }}
|
||||
S3_REGION: ${{ inputs.smoke && 'us-west-2' || vars.AWS_CI_TEST_BUCKET_REGION }}
|
||||
S3_ENDPOINT: ${{ inputs.smoke && 'http://127.0.0.1:9000' || '' }}
|
||||
S3_CONTAINER_ENDPOINT: ${{ inputs.smoke && 'http://host.docker.internal:9000' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- name: Set up benchmark dependencies
|
||||
uses: ./greptimedb/.github/actions/setup-benchmark
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- name: Start the MinIO fixture
|
||||
if: inputs.smoke
|
||||
run: |
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh greptime/minio:2024
|
||||
docker compose -f greptimedb/tests-integration/fixtures/docker-compose.yml up -d --wait minio
|
||||
- name: Pull images and write the plan
|
||||
run: |
|
||||
bash greptimedb/.github/scripts/oswal-s3-bench-run.sh pull
|
||||
python3 greptimedb/.github/scripts/oswal-s3-bench.py plan --smoke "$SMOKE" --output "$PLAN" \
|
||||
--concurrency "$CONCURRENCY" --rows "$ROWS" --batch-rows "$BATCH_ROWS" --rounds "$ROUNDS"
|
||||
python3 greptimedb/.github/scripts/oswal-s3-bench.py manifest --results "$RESULTS" \
|
||||
--runtime-image "$RUNTIME_IMAGE" --instance-type "$INSTANCE_TYPE" --region "$S3_REGION" --prefix "$RUN_PREFIX"
|
||||
- name: Probe the bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ inputs.smoke && 'superpower_ci_user' || secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ inputs.smoke && 'superpower_password' || secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
|
||||
run: uv run greptimedb/.github/scripts/oswal-s3-bench.py probe --bucket "$S3_BUCKET" --region "$S3_REGION" --endpoint "$S3_ENDPOINT" --prefix "$RUN_PREFIX"
|
||||
- name: Put the Raft Engine WAL on the instance store
|
||||
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh wal-disk "$WAL_ROOT"
|
||||
- name: Generate the dataset once
|
||||
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh generate
|
||||
- name: Load every cell
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ inputs.smoke && 'superpower_ci_user' || secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ inputs.smoke && 'superpower_password' || secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
|
||||
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh cells
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: python3 greptimedb/.github/scripts/oswal-s3-bench-summary.py --root "$RESULTS" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Delete the run's S3 prefix
|
||||
if: always()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ inputs.smoke && 'superpower_ci_user' || secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ inputs.smoke && 'superpower_password' || secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
|
||||
run: uv run greptimedb/.github/scripts/oswal-s3-bench.py cleanup --bucket "$S3_BUCKET" --region "$S3_REGION" --endpoint "$S3_ENDPOINT" --prefix "$RUN_PREFIX"
|
||||
- name: Check the results hold no credentials
|
||||
if: always()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ inputs.smoke && 'superpower_ci_user' || secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ inputs.smoke && 'superpower_password' || secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
|
||||
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh scrub
|
||||
- name: Upload results (no corpus)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oswal-s3-bench-${{ inputs.smoke && 'smoke' || 'ec2' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.RESULTS }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
teardown:
|
||||
needs:
|
||||
- provision
|
||||
- benchmark
|
||||
if: ${{ always() && !inputs.smoke && (needs.provision.outputs.instance_id != '' || needs.provision.result != 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.EC2_RUNNER_REGION }}
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- name: Delete EC2 and unregister runner
|
||||
env:
|
||||
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
|
||||
AWS_REGION: ${{ vars.EC2_RUNNER_REGION }}
|
||||
AWS_EC2_INSTANCE_ID: ${{ needs.provision.outputs.instance_id }}
|
||||
AWS_EC2_RUNNER_NAME: ${{ needs.provision.outputs.runner_name }}
|
||||
AWS_EC2_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: uv run greptimedb/.github/scripts/aws-ec2-runner-teardown.py
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
readonly EXPECTED_SCCACHE_DIR="/home/runner/.cache/sccache"
|
||||
readonly EXPECTED_RUSTC_WRAPPER="/usr/local/bin/sccache"
|
||||
readonly RUNNER_IMAGE_DIGEST="sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11"
|
||||
readonly RUNNER_IMAGE_EPOCH="6"
|
||||
readonly RUNNER_IMAGE_EPOCH="7"
|
||||
|
||||
require_expected_root() {
|
||||
local name="$1"
|
||||
|
||||
@@ -4,15 +4,20 @@ name: Release
|
||||
# 1. The tag('v*.*.*') push release: the release workflow will be triggered by the tag push event.
|
||||
# 2. The scheduled release(the version will be '<base-version>-nightly-YYYYMMDD', any pre-release
|
||||
# extension in the next release version like '-alpha.1' is stripped, so 'nightly' is the extension):
|
||||
# the release workflow will be triggered by the schedule event.
|
||||
# the release workflow will be triggered by the schedule event. The schedule runs every weekday,
|
||||
# but the release only proceeds when the latest published nightly release is older than
|
||||
# NIGHTLY_RELEASE_MAX_AGE_DAYS days, so a failed nightly run is retried by the next weekday
|
||||
# schedule instead of leaving a whole week without nightly builds.
|
||||
# A failed tag push release can be manually dispatched on the same tag to recover it without changing the formal release version.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
schedule:
|
||||
# At 00:00 on Monday.
|
||||
- cron: '0 0 * * 1'
|
||||
# At 00:00 on every weekday (Monday to Friday). The scheduled nightly release only
|
||||
# proceeds when the latest published nightly release is older than
|
||||
# NIGHTLY_RELEASE_MAX_AGE_DAYS days, see the 'Check nightly release necessity' step.
|
||||
- cron: '0 0 * * 1-5'
|
||||
workflow_dispatch: # Allows you to run this workflow manually.
|
||||
# Notes: The GitHub Actions ONLY support 10 inputs; this final input is the tenth.
|
||||
inputs:
|
||||
@@ -79,7 +84,7 @@ on:
|
||||
default: false
|
||||
release_validation:
|
||||
type: choice
|
||||
description: Manual policy for accepted perf regressions or runner failures; automatic releases always run all validation
|
||||
description: Manual policy for accepted perf regressions or runner failures; automatic releases always run all validation (scheduled nightlies run query regression non-blocking)
|
||||
required: true
|
||||
default: all
|
||||
options:
|
||||
@@ -100,6 +105,10 @@ env:
|
||||
# the pre-release extension of the next release version is stripped, e.g. v0.2.0-alpha.1 -> v0.2.0-nightly-20230313.
|
||||
NIGHTLY_RELEASE_PREFIX: nightly
|
||||
|
||||
# The maximum age (in days) of the latest published nightly release before a scheduled
|
||||
# nightly release may run again; see the 'Check nightly release necessity' step.
|
||||
NIGHTLY_RELEASE_MAX_AGE_DAYS: 5
|
||||
|
||||
jobs:
|
||||
allocate-runners:
|
||||
name: Allocate runners
|
||||
@@ -123,6 +132,10 @@ jobs:
|
||||
# The 'is-current-version-latest' determines whether to update 'latest' Docker tags and downstream repositories.
|
||||
is-current-version-latest: ${{ steps.check-version.outputs.is-current-version-latest }}
|
||||
is-current-version-stable: ${{ steps.check-version.outputs.is-current-version-stable }}
|
||||
|
||||
# Whether a scheduled nightly release should run: it only runs when the latest published
|
||||
# nightly release is older than NIGHTLY_RELEASE_MAX_AGE_DAYS days. Always 'true' for other events.
|
||||
nightly-required: ${{ steps.check-nightly.outputs.nightly-required }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -135,6 +148,19 @@ jobs:
|
||||
run: |
|
||||
./scripts/check-builder-rust-version.sh
|
||||
|
||||
# A scheduled run only proceeds when the latest published nightly release is older than
|
||||
# NIGHTLY_RELEASE_MAX_AGE_DAYS days, so a failed nightly run is retried by the next weekday
|
||||
# schedule. The check runs before the runners are allocated to avoid wasting EC2 resources.
|
||||
- name: Check nightly release necessity
|
||||
id: check-nightly
|
||||
run: |
|
||||
echo "nightly-required=$(./.github/scripts/check-nightly-release.sh)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
NIGHTLY_RELEASE_MAX_AGE_DAYS: ${{ env.NIGHTLY_RELEASE_MAX_AGE_DAYS }}
|
||||
|
||||
# The create-version will create a global variable named 'version' in the global workflows.
|
||||
# - If it's a tag push release or manual dispatch on a tag with REUSE_EXISTING_RELEASE_TAG=true, the version is the tag name(${{ github.ref_name }});
|
||||
# - If it's a scheduled release, the version is '<base-version>-nightly-$buildTime', like v0.2.0-nightly-20230313 (pre-release extensions like '-alpha.1' are stripped);
|
||||
@@ -156,7 +182,7 @@ jobs:
|
||||
./.github/scripts/check-version.sh "${{ steps.create-version.outputs.version }}"
|
||||
|
||||
- name: Allocate linux-amd64 runner
|
||||
if: ${{ inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && steps.check-nightly.outputs.nightly-required == 'true' }}
|
||||
uses: ./.github/actions/start-runner
|
||||
id: start-linux-amd64-runner
|
||||
with:
|
||||
@@ -170,7 +196,7 @@ jobs:
|
||||
subnet-id: ${{ vars.EC2_RUNNER_SUBNET_ID }}
|
||||
|
||||
- name: Allocate linux-arm64 runner
|
||||
if: ${{ inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && steps.check-nightly.outputs.nightly-required == 'true' }}
|
||||
uses: ./.github/actions/start-runner
|
||||
id: start-linux-arm64-runner
|
||||
with:
|
||||
@@ -185,7 +211,7 @@ jobs:
|
||||
|
||||
build-linux-amd64-artifacts:
|
||||
name: Build linux-amd64 artifacts
|
||||
if: ${{ inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
@@ -211,7 +237,7 @@ jobs:
|
||||
|
||||
build-linux-arm64-artifacts:
|
||||
name: Build linux-arm64 artifacts
|
||||
if: ${{ inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
@@ -238,7 +264,7 @@ jobs:
|
||||
|
||||
build-linux-riscv64-artifacts:
|
||||
name: Build linux-riscv64 artifacts
|
||||
if: ${{ inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
@@ -302,7 +328,7 @@ jobs:
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
if: ${{ inputs.build_macos_artifacts || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_macos_artifacts || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -340,7 +366,7 @@ jobs:
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
if: ${{ inputs.build_windows_artifacts || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.build_windows_artifacts || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
steps:
|
||||
- run: git config --global core.autocrlf false
|
||||
|
||||
@@ -365,7 +391,7 @@ jobs:
|
||||
|
||||
prepare-release-validation:
|
||||
name: Prepare release validation
|
||||
if: ${{ inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
if: ${{ (inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
|
||||
needs: [
|
||||
allocate-runners,
|
||||
]
|
||||
@@ -442,6 +468,9 @@ jobs:
|
||||
if [[ "${validation_policy}" != "all" ]]; then
|
||||
echo "- ⚠️ Validation bypassed: compatibility=${run_compat}, query-regression=${run_query_regression}"
|
||||
fi
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then
|
||||
echo "- ℹ️ Query-regression failure or cancellation is non-blocking for scheduled nightly releases"
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
compat-release:
|
||||
@@ -525,9 +554,13 @@ jobs:
|
||||
|
||||
release-images-to-dockerhub:
|
||||
name: Build and push images to DockerHub
|
||||
# Scheduled nightly releases do not gate on query regression: the test
|
||||
# still runs and reports, but a failure or cancellation does not block
|
||||
# the nightly release.
|
||||
if: |
|
||||
always() &&
|
||||
(inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') &&
|
||||
(github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') &&
|
||||
needs.allocate-runners.result == 'success' &&
|
||||
needs.build-linux-amd64-artifacts.result == 'success' &&
|
||||
needs.build-linux-arm64-artifacts.result == 'success' &&
|
||||
@@ -535,7 +568,8 @@ jobs:
|
||||
(needs.compat-release.result == 'success' ||
|
||||
(needs.prepare-release-validation.outputs.run-compat == 'false' && needs.compat-release.result == 'skipped')) &&
|
||||
(needs.query-regression-release.result == 'success' ||
|
||||
(needs.prepare-release-validation.outputs.run-query-regression == 'false' && needs.query-regression-release.result == 'skipped'))
|
||||
(needs.prepare-release-validation.outputs.run-query-regression == 'false' && needs.query-regression-release.result == 'skipped') ||
|
||||
github.event_name == 'schedule')
|
||||
needs: [
|
||||
allocate-runners,
|
||||
build-linux-amd64-artifacts,
|
||||
@@ -571,7 +605,18 @@ jobs:
|
||||
|
||||
release-cn-artifacts:
|
||||
name: Release artifacts to CN region
|
||||
if: ${{ inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
# A non-blocking query regression failure must not skip downstream releases.
|
||||
if: |
|
||||
always() &&
|
||||
(inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') &&
|
||||
(github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') &&
|
||||
needs.allocate-runners.result == 'success' &&
|
||||
needs.build-linux-amd64-artifacts.result == 'success' &&
|
||||
needs.build-linux-arm64-artifacts.result == 'success' &&
|
||||
needs.build-linux-riscv64-artifacts.result == 'success' &&
|
||||
needs.build-macos-artifacts.result == 'success' &&
|
||||
needs.build-windows-artifacts.result == 'success' &&
|
||||
needs.release-images-to-dockerhub.result == 'success'
|
||||
needs: [ # The job have to wait for all the artifacts are built.
|
||||
allocate-runners,
|
||||
build-linux-amd64-artifacts,
|
||||
@@ -615,15 +660,19 @@ jobs:
|
||||
name: Create GitHub release and upload artifacts
|
||||
# Use always() to run even when optional jobs (macos, windows) are skipped.
|
||||
# Then check that required jobs succeeded and optional jobs didn't fail.
|
||||
# Scheduled nightly releases do not gate on query regression either
|
||||
# (see release-images-to-dockerhub); the test still runs and reports.
|
||||
if: |
|
||||
always() &&
|
||||
(inputs.publish_github_release || github.event_name == 'push' || github.event_name == 'schedule') &&
|
||||
(github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') &&
|
||||
needs.allocate-runners.result == 'success' &&
|
||||
needs.prepare-release-validation.result == 'success' &&
|
||||
(needs.compat-release.result == 'success' ||
|
||||
(needs.prepare-release-validation.outputs.run-compat == 'false' && needs.compat-release.result == 'skipped')) &&
|
||||
(needs.query-regression-release.result == 'success' ||
|
||||
(needs.prepare-release-validation.outputs.run-query-regression == 'false' && needs.query-regression-release.result == 'skipped')) &&
|
||||
(needs.prepare-release-validation.outputs.run-query-regression == 'false' && needs.query-regression-release.result == 'skipped') ||
|
||||
github.event_name == 'schedule') &&
|
||||
needs.build-linux-amd64-artifacts.result == 'success' &&
|
||||
needs.build-linux-arm64-artifacts.result == 'success' &&
|
||||
needs.build-linux-riscv64-artifacts.result == 'success' &&
|
||||
@@ -726,7 +775,21 @@ jobs:
|
||||
|
||||
bump-downstream-repo-versions:
|
||||
name: Bump downstream repo versions
|
||||
if: ${{ github.event_name == 'schedule' || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref_type == 'tag' && !contains(github.ref_name, 'nightly')) && (needs.allocate-runners.outputs.is-current-version-stable == 'true' && needs.allocate-runners.outputs.is-current-version-latest == 'true') }}
|
||||
# A non-blocking query regression failure must not skip downstream updates.
|
||||
if: |
|
||||
always() &&
|
||||
needs.allocate-runners.result == 'success' &&
|
||||
needs.publish-github-release.result == 'success' &&
|
||||
(
|
||||
(github.event_name == 'schedule' && needs.allocate-runners.outputs.nightly-required == 'true') ||
|
||||
(
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
|
||||
github.ref_type == 'tag' &&
|
||||
!contains(github.ref_name, 'nightly') &&
|
||||
needs.allocate-runners.outputs.is-current-version-stable == 'true' &&
|
||||
needs.allocate-runners.outputs.is-current-version-latest == 'true'
|
||||
)
|
||||
)
|
||||
needs: [allocate-runners, publish-github-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Permission reference: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
|
||||
@@ -793,9 +856,10 @@ jobs:
|
||||
./.github/scripts/update-homebrew-greptme-version.sh
|
||||
|
||||
notification:
|
||||
if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'push' || github.event_name == 'schedule') && always() }}
|
||||
if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'push' || (github.event_name == 'schedule' && needs.allocate-runners.outputs.nightly-required == 'true')) && always() }}
|
||||
name: Send notification to Greptime team
|
||||
needs: [
|
||||
allocate-runners,
|
||||
release-images-to-dockerhub,
|
||||
build-macos-artifacts,
|
||||
build-windows-artifacts,
|
||||
|
||||
@@ -2,6 +2,36 @@ name: Tracesbench
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_greptimedb:
|
||||
description: Run GreptimeDB
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
run_victoriatraces:
|
||||
description: Run VictoriaTraces
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
run_tempo:
|
||||
description: Run Tempo
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
compare_greptimedb:
|
||||
description: Compare baseline and candidate sequentially on the same host (adds one full GreptimeDB run)
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
baseline_greptimedb_tag:
|
||||
description: Baseline Docker Hub greptime/greptimedb tag; required when comparison is enabled
|
||||
type: string
|
||||
default: v1.2.1
|
||||
required: false
|
||||
greptimedb_tag:
|
||||
description: Candidate Docker Hub greptime/greptimedb tag
|
||||
type: string
|
||||
default: latest
|
||||
required: true
|
||||
dataset:
|
||||
description: Dataset profile (main is experimental; native queries may exceed memory limits)
|
||||
type: choice
|
||||
@@ -9,9 +39,9 @@ on:
|
||||
default: sanity
|
||||
required: true
|
||||
runtime_image:
|
||||
description: Aliyun runtime image (tag or digest reference)
|
||||
description: 'Runtime image: auto = AWS ECR Public or Aliyun ACR by provider; explicit tag/digest overrides'
|
||||
type: string
|
||||
default: greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:20260928-4e52d0b0@sha256:39d2d87593788e0c47a54768f6323679b8e03d7dc72a8158a9b22cf29f883baa
|
||||
default: auto
|
||||
required: true
|
||||
provider:
|
||||
description: Cloud provider for the benchmark runner
|
||||
@@ -20,7 +50,7 @@ on:
|
||||
default: Aliyun
|
||||
required: true
|
||||
instance_type:
|
||||
description: 'Instance type; auto = Aliyun: ecs.c9i.2xlarge, AWS: c7i.2xlarge (both 8 vCPU / 16 GiB). Enter a specific type to override.'
|
||||
description: 'Instance type; auto = Aliyun: ecs.g9i.2xlarge, AWS: m7i.2xlarge (both 8 vCPU / 32 GiB). Enter a specific type to override.'
|
||||
type: string
|
||||
default: auto
|
||||
required: true
|
||||
@@ -45,33 +75,46 @@ on:
|
||||
default: 8
|
||||
required: true
|
||||
db_cpus:
|
||||
description: CPU limit per DB container
|
||||
description: CPU limit per DB container (default 8 vCPU)
|
||||
type: string
|
||||
default: '8'
|
||||
required: true
|
||||
db_memory:
|
||||
description: Memory limit per DB container
|
||||
description: Memory limit per DB container (default 32 GiB; shared host also runs the runtime and runner)
|
||||
type: string
|
||||
default: 16g
|
||||
default: 32g
|
||||
required: true
|
||||
load_workers:
|
||||
description: CPU workers for parallel import reading/parsing across all engines (separate from write concurrency)
|
||||
type: number
|
||||
default: 4
|
||||
required: true
|
||||
query_timeout_seconds:
|
||||
description: Timeout per query request in seconds (correctness, warmup and measurement; 1..3600)
|
||||
type: number
|
||||
default: 300
|
||||
required: true
|
||||
runtime_cpus:
|
||||
description: CPU limit per runtime container
|
||||
description: CPU limit per runtime container (0 = unrestricted)
|
||||
type: string
|
||||
default: '2'
|
||||
default: '0'
|
||||
required: true
|
||||
runtime_memory:
|
||||
description: Memory limit per runtime container (separate from DB memory)
|
||||
description: Memory limit per runtime container (0 = unrestricted; separate from DB memory)
|
||||
type: string
|
||||
default: 4g
|
||||
default: '0'
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
COMPARE_GREPTIMEDB: ${{ inputs.compare_greptimedb }}
|
||||
BASELINE_GREPTIMEDB_TAG: ${{ inputs.baseline_greptimedb_tag }}
|
||||
DATASET: ${{ inputs.dataset }}
|
||||
GREPTIMEDB_TAG: ${{ inputs.greptimedb_tag }}
|
||||
RUNTIME_CPUS: ${{ inputs.runtime_cpus }}
|
||||
RUNTIME_MEMORY: ${{ inputs.runtime_memory }}
|
||||
BENCHMARK_ROOT: ${{ inputs.benchmark_root }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image != 'auto' && inputs.runtime_image || inputs.provider == 'AWS' && 'public.ecr.aws/i8k6a5e1/greptime/o11ybenchruntime:77a84e1c8136@sha256:c482993ec177b026235d6cd0c61d4fb6d6f1088bc78bdc0e5964a5398de6dba0' || 'greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:77a84e1c8136@sha256:c482993ec177b026235d6cd0c61d4fb6d6f1088bc78bdc0e5964a5398de6dba0' }}
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
REQUESTED_INSTANCE_TYPE: ${{ inputs.instance_type }}
|
||||
SYSTEM_DISK_GIB: ${{ inputs.system_disk_gib }}
|
||||
@@ -79,12 +122,18 @@ env:
|
||||
JANITOR_TTL_HOURS: ${{ inputs.janitor_ttl_hours }}
|
||||
DB_CPUS: ${{ inputs.db_cpus }}
|
||||
DB_MEMORY: ${{ inputs.db_memory }}
|
||||
QUERY_TIMEOUT_SECONDS: ${{ inputs.query_timeout_seconds }}
|
||||
LOAD_WORKERS: ${{ inputs.load_workers }}
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
instance_type: ${{ steps.runner.outputs.instance_type }}
|
||||
targets: ${{ steps.inputs.outputs.targets }}
|
||||
greptimedb: ${{ steps.inputs.outputs.greptimedb }}
|
||||
victoriatraces: ${{ steps.inputs.outputs.victoriatraces }}
|
||||
tempo: ${{ steps.inputs.outputs.tempo }}
|
||||
system_disk_gib: ${{ steps.runtime.outputs.system_disk_gib }}
|
||||
runtime_image: ${{ steps.runtime.outputs.runtime_image }}
|
||||
o11ybench_ref: ${{ steps.runtime.outputs.o11ybench_ref }}
|
||||
@@ -93,9 +142,44 @@ jobs:
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- name: Validate selected targets
|
||||
id: inputs
|
||||
env:
|
||||
greptimedb: ${{ inputs.run_greptimedb }}
|
||||
victoriatraces: ${{ inputs.run_victoriatraces }}
|
||||
tempo: ${{ inputs.run_tempo }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
selected=
|
||||
for target in greptimedb victoriatraces tempo; do
|
||||
enabled=${!target}
|
||||
[[ "$enabled" == true || "$enabled" == false ]]
|
||||
printf '%s=%s\n' "$target" "$enabled" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$enabled" == true ]]; then selected="${selected:+$selected,}$target"; fi
|
||||
done
|
||||
[[ -n "$selected" ]] || { echo 'Select at least one database' >&2; exit 1; }
|
||||
if [[ "$greptimedb" == true ]]; then
|
||||
[[ "$GREPTIMEDB_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]] || { echo 'Invalid GreptimeDB image tag' >&2; exit 1; }
|
||||
fi
|
||||
printf 'targets=%s\n' "$selected" >> "$GITHUB_OUTPUT"
|
||||
- name: Validate version comparison
|
||||
env:
|
||||
RUN_GREPTIMEDB: ${{ inputs.run_greptimedb }}
|
||||
run: |
|
||||
python3 greptimedb/tests/perf/test_benchmark_version_comparison.py
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py validate
|
||||
- name: Resolve CI runner
|
||||
id: runner
|
||||
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider "$PROVIDER" --instance-type "$REQUESTED_INSTANCE_TYPE"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
instance_type="$REQUESTED_INSTANCE_TYPE"
|
||||
if [[ "$instance_type" == auto ]]; then
|
||||
case "$PROVIDER" in
|
||||
Aliyun) instance_type=ecs.g9i.2xlarge ;;
|
||||
AWS) instance_type=m7i.2xlarge ;;
|
||||
esac
|
||||
fi
|
||||
python3 greptimedb/.github/scripts/ci-runner-config.py --provider "$PROVIDER" --instance-type "$instance_type"
|
||||
- name: Validate budgets and shared runtime
|
||||
id: runtime
|
||||
run: |
|
||||
@@ -115,19 +199,38 @@ jobs:
|
||||
((BENCHMARK_TIMEOUT_MINUTES <= 360 && JANITOR_TTL_HOURS <= 168))
|
||||
((JANITOR_TTL_HOURS * 60 >= BENCHMARK_TIMEOUT_MINUTES + 120))
|
||||
[[ "$DB_CPUS" =~ ^[1-9][0-9]*$ && "$DB_MEMORY" =~ ^[1-9][0-9]*[mMgG]$ ]]
|
||||
[[ "$RUNTIME_CPUS" =~ ^[1-9][0-9]*$ && "$RUNTIME_MEMORY" =~ ^[1-9][0-9]*[mMgG]$ ]]
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* ]]
|
||||
docker pull "$RUNTIME_IMAGE"
|
||||
[[ "$RUNTIME_CPUS" =~ ^(0|[1-9][0-9]*)$ && "$RUNTIME_MEMORY" =~ ^(0|[1-9][0-9]*[mMgG])$ ]]
|
||||
[[ "$QUERY_TIMEOUT_SECONDS" =~ ^[1-9][0-9]{0,3}$ ]]
|
||||
((QUERY_TIMEOUT_SECONDS <= 3600))
|
||||
[[ "$LOAD_WORKERS" =~ ^[1-9][0-9]{0,2}$ ]]
|
||||
((LOAD_WORKERS <= 256))
|
||||
runtime_limits=()
|
||||
[[ "$RUNTIME_CPUS" == 0 ]] || runtime_limits+=(--cpus "$RUNTIME_CPUS")
|
||||
[[ "$RUNTIME_MEMORY" == 0 ]] || runtime_limits+=(--memory "$RUNTIME_MEMORY" --memory-swap "$RUNTIME_MEMORY")
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* || "$RUNTIME_IMAGE" == public.ecr.aws/* ]]
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RUNTIME_IMAGE"
|
||||
metadata=$(docker image inspect "$RUNTIME_IMAGE")
|
||||
resolved=$(jq -er '.[0].RepoDigests[0]' <<< "$metadata")
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
docker run --rm "$resolved" scripts/run_tracesbench_docker.py --help | grep -q -- '--data-root' \
|
||||
|| { echo 'Comparison requires a runtime with --data-root support'; exit 1; }
|
||||
fi
|
||||
revision=$(jq -er '.[0].Config.Labels["org.opencontainers.image.revision"]' <<< "$metadata")
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ ]]
|
||||
# Refuse runtimes without the trace runner and its validated dataset/report contract.
|
||||
help=$(docker run --rm --cpus "$RUNTIME_CPUS" --memory "$RUNTIME_MEMORY" --memory-swap "$RUNTIME_MEMORY" "$resolved" scripts/run_tracesbench_docker.py --help)
|
||||
help=$(docker run --rm "${runtime_limits[@]}" "$resolved" scripts/run_tracesbench_docker.py --help)
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
grep -F -- '--baseline-run' <<< "$help" \
|
||||
|| { echo 'Comparison requires a runtime with --baseline-run report support'; exit 1; }
|
||||
fi
|
||||
grep -F -- '--dataset {sanity,preflight,main}' <<< "$help"
|
||||
grep -F -- '--runtime-cpus' <<< "$help"
|
||||
grep -F -- '--runtime-memory' <<< "$help"
|
||||
docker run --rm --cpus "$RUNTIME_CPUS" --memory "$RUNTIME_MEMORY" --memory-swap "$RUNTIME_MEMORY" "$resolved" scripts/check_run_tracesbench_docker.py
|
||||
grep -F -- '--query-timeout-seconds' <<< "$help"
|
||||
grep -F -- '--load-workers' <<< "$help"
|
||||
grep -F -- '--targets' <<< "$help"
|
||||
grep -F -- '--greptimedb-image' <<< "$help"
|
||||
docker run --rm "${runtime_limits[@]}" "$resolved" scripts/check_run_tracesbench_docker.py
|
||||
printf 'runtime_image=%s\no11ybench_ref=%s\nsystem_disk_gib=%s\n' "$resolved" "$revision" "$SYSTEM_DISK_GIB" >> "$GITHUB_OUTPUT"
|
||||
provision:
|
||||
needs: validate
|
||||
@@ -190,6 +293,7 @@ jobs:
|
||||
runs-on: ${{ needs.provision.outputs.label }}
|
||||
timeout-minutes: ${{ fromJSON(format('{0}', inputs.benchmark_timeout_minutes)) }}
|
||||
env:
|
||||
TARGETS: ${{ needs.validate.outputs.targets }}
|
||||
RESOLVED_RUNTIME_IMAGE: ${{ needs.validate.outputs.runtime_image }}
|
||||
RESOLVED_O11YBENCH_REF: ${{ needs.validate.outputs.o11ybench_ref }}
|
||||
RUN_ROOT: ${{ inputs.benchmark_root }}/tracesbench-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -224,36 +328,83 @@ jobs:
|
||||
available=$(df -Pk "$path" | awk 'NR == 2 {print $4}')
|
||||
((available >= minimum_gib * 1024 * 1024)) || { echo "Need at least $minimum_gib GiB free on $path"; exit 1; }
|
||||
done
|
||||
docker pull "$RESOLVED_RUNTIME_IMAGE"
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RESOLVED_RUNTIME_IMAGE"
|
||||
docker image inspect "$RESOLVED_RUNTIME_IMAGE" > "$METADATA_ROOT/runtime-image.json"
|
||||
test "$(jq -r '.[0].Config.Labels["org.opencontainers.image.revision"]' "$METADATA_ROOT/runtime-image.json")" = "$RESOLVED_O11YBENCH_REF"
|
||||
# Preserve the pinned catalog images and require all three targets in the report.
|
||||
- name: Pull database images before generating data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IFS=, read -ra targets <<< "$TARGETS"
|
||||
for target in "${targets[@]}"; do
|
||||
if [[ "$target" == greptimedb ]]; then
|
||||
image="greptime/greptimedb:$GREPTIMEDB_TAG"
|
||||
else
|
||||
image=$(jq -er '.target_identity.image | select(test("@sha256:[a-f0-9]{64}$"))' \
|
||||
"o11ybench/workloads/tracesbench/catalogs/$target.json")
|
||||
fi
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$image"
|
||||
docker image inspect "$image" > "$METADATA_ROOT/$target-image.json"
|
||||
if [[ "$target" == greptimedb ]]; then
|
||||
resolved=$(jq -er '.[0].RepoDigests[0] | select(test("@sha256:[a-f0-9]{64}$"))' "$METADATA_ROOT/$target-image.json")
|
||||
printf 'GREPTIMEDB_IMAGE=%s\n' "$resolved" >> "$GITHUB_ENV"
|
||||
fi
|
||||
done
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
image="greptime/greptimedb:$BASELINE_GREPTIMEDB_TAG"
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$image"
|
||||
docker image inspect "$image" > "$METADATA_ROOT/greptimedb-baseline-image.json"
|
||||
resolved=$(jq -er '.[0].RepoDigests[0] | select(test("@sha256:[a-f0-9]{64}$"))' "$METADATA_ROOT/greptimedb-baseline-image.json")
|
||||
printf 'BASELINE_GREPTIMEDB_IMAGE=%s\n' "$resolved" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Generate selected dataset once
|
||||
id: generate
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage generate \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: GreptimeDB load, correctness and benchmark
|
||||
- name: Baseline GreptimeDB load, correctness and benchmark
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.greptimedb == 'true' && inputs.compare_greptimedb }}
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage target --target greptimedb --port 14000 \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" \
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage target --target greptimedb --port 14000 --greptimedb-image "$BASELINE_GREPTIMEDB_IMAGE" \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" --query-timeout-seconds "$QUERY_TIMEOUT_SECONDS" --load-workers "$LOAD_WORKERS" \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT/baseline" --data-root "$RUN_ROOT" --owner "$OWNER-base" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
python3 o11ybench/scripts/check_tracesbench_query_plans.py "$RUN_ROOT/baseline/runs/greptimedb"
|
||||
- name: GreptimeDB load, correctness and benchmark
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.greptimedb == 'true' }}
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage target --target greptimedb --port 14000 --greptimedb-image "$GREPTIMEDB_IMAGE" \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" --query-timeout-seconds "$QUERY_TIMEOUT_SECONDS" --load-workers "$LOAD_WORKERS" \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
python3 o11ybench/scripts/check_tracesbench_query_plans.py "$RUN_ROOT/runs/greptimedb"
|
||||
- name: VictoriaTraces load, correctness and benchmark
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.victoriatraces == 'true' }}
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage target --target victoriatraces --port 19428 \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" --query-timeout-seconds "$QUERY_TIMEOUT_SECONDS" --load-workers "$LOAD_WORKERS" \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: Tempo load, correctness and benchmark
|
||||
if: ${{ !cancelled() && steps.generate.outcome == 'success' && needs.validate.outputs.tempo == 'true' }}
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage target --target tempo --port 13200 \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" \
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY" --query-timeout-seconds "$QUERY_TIMEOUT_SECONDS" --load-workers "$LOAD_WORKERS" \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: Validate and render report
|
||||
- name: GreptimeDB version comparison
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage report \
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py report --kind traces --root "$RUN_ROOT" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Validate and render report
|
||||
env:
|
||||
COMPARE_GREPTIMEDB: ${{ inputs.compare_greptimedb }}
|
||||
run: |
|
||||
comparison_args=()
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
comparison_args+=(--baseline-run "$RUN_ROOT/baseline/runs/greptimedb/run.json")
|
||||
fi
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage report --targets "$TARGETS" "${comparison_args[@]}" \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: Clean up owned containers
|
||||
@@ -262,6 +413,23 @@ jobs:
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage cleanup \
|
||||
--dataset "$DATASET" --runtime-cpus "$RUNTIME_CPUS" --runtime-memory "$RUNTIME_MEMORY" \
|
||||
--root "$RUN_ROOT" --owner "$OWNER" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: Clean up baseline containers
|
||||
if: always() && steps.runtime.outcome == 'success' && inputs.compare_greptimedb
|
||||
run: |
|
||||
python3 o11ybench/scripts/run_tracesbench_docker.py --stage cleanup \
|
||||
--root "$RUN_ROOT/baseline" --owner "$OWNER-base" --runtime-image "$RESOLVED_RUNTIME_IMAGE"
|
||||
- name: Benchmark summary
|
||||
if: always()
|
||||
run: python3 greptimedb/.github/scripts/tracesbench-summary.py --root "$RUN_ROOT" --targets "$TARGETS" --section benchmark >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Load / lifecycle summary
|
||||
if: always()
|
||||
run: python3 greptimedb/.github/scripts/tracesbench-summary.py --root "$RUN_ROOT" --targets "$TARGETS" --section lifecycle >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Baseline load / lifecycle summary
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |
|
||||
echo '## Baseline GreptimeDB' >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 greptimedb/.github/scripts/tracesbench-summary.py \
|
||||
--root "$RUN_ROOT/baseline" --targets greptimedb --section lifecycle >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Stage benchmark artifacts
|
||||
if: always()
|
||||
run: |
|
||||
@@ -269,11 +437,15 @@ jobs:
|
||||
if [[ -d "$METADATA_ROOT" ]]; then
|
||||
rsync -a --no-owner --no-group "$METADATA_ROOT" "$RUNNER_TEMP/tracesbench-artifacts/"
|
||||
fi
|
||||
for entry in runs report report.json report.md; do
|
||||
for entry in runs report report.json report.md version-comparison.json version-comparison.md; do
|
||||
if [[ -e "$RUN_ROOT/$entry" ]]; then
|
||||
rsync -a --no-owner --no-group "$RUN_ROOT/$entry" "$RUNNER_TEMP/tracesbench-artifacts/"
|
||||
fi
|
||||
done
|
||||
if [[ -d "$RUN_ROOT/baseline/runs" ]]; then
|
||||
mkdir -p "$RUNNER_TEMP/tracesbench-artifacts/baseline"
|
||||
rsync -a --no-owner --no-group "$RUN_ROOT/baseline/runs" "$RUNNER_TEMP/tracesbench-artifacts/baseline/"
|
||||
fi
|
||||
- name: Upload HTML and results (no corpus)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -20,8 +20,18 @@ on:
|
||||
- 1mo
|
||||
default: 1w
|
||||
required: true
|
||||
compare_greptimedb:
|
||||
description: Compare baseline and candidate sequentially on the same host (adds one full GreptimeDB run)
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
baseline_greptimedb_tag:
|
||||
description: Baseline Docker Hub greptime/greptimedb tag; required when comparison is enabled
|
||||
type: string
|
||||
default: v1.2.1
|
||||
required: false
|
||||
greptimedb_tag:
|
||||
description: Docker Hub greptime/greptimedb tag
|
||||
description: Candidate Docker Hub greptime/greptimedb tag
|
||||
type: string
|
||||
default: latest
|
||||
required: true
|
||||
@@ -31,9 +41,9 @@ on:
|
||||
default: v1.114.0@sha256:227ad53fd57a4b4430624898a1be0cac39431e1235fa0dc92f567cce38969344
|
||||
required: true
|
||||
runtime_image:
|
||||
description: Aliyun runtime image (tag or digest reference)
|
||||
description: 'Runtime image: auto = AWS ECR Public or Aliyun ACR by provider; explicit tag/digest overrides'
|
||||
type: string
|
||||
default: greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:20260918-545dc570@sha256:0ff03e7e690575e875bb85b3054a03486dd9359073546dc715da177153cf7a29
|
||||
default: auto
|
||||
required: true
|
||||
provider:
|
||||
description: Cloud provider for the benchmark runner
|
||||
@@ -79,11 +89,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
COMPARE_GREPTIMEDB: ${{ inputs.compare_greptimedb }}
|
||||
BASELINE_GREPTIMEDB_TAG: ${{ inputs.baseline_greptimedb_tag }}
|
||||
BENCHMARK_ROOT: ${{ inputs.benchmark_root }}
|
||||
RANGE_PRESET: ${{ inputs.range_preset }}
|
||||
GREPTIMEDB_TAG: ${{ inputs.greptimedb_tag }}
|
||||
VICTORIAMETRICS_TAG: ${{ inputs.victoriametrics_tag }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image }}
|
||||
RUNTIME_IMAGE: ${{ inputs.runtime_image != 'auto' && inputs.runtime_image || inputs.provider == 'AWS' && 'public.ecr.aws/i8k6a5e1/greptime/o11ybenchruntime:b43785b56f42@sha256:e38811eb68caf3682ba56f5591a3e8e27e78e7983a923abb29fa2d7cd8fdbd46' || 'greptime-registry.cn-hangzhou.cr.aliyuncs.com/tools/o11ybench-runtime:b43785b56f42@sha256:e38811eb68caf3682ba56f5591a3e8e27e78e7983a923abb29fa2d7cd8fdbd46' }}
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
REQUESTED_INSTANCE_TYPE: ${{ inputs.instance_type }}
|
||||
SYSTEM_DISK_GIB: ${{ inputs.system_disk_gib }}
|
||||
@@ -107,6 +119,12 @@ jobs:
|
||||
with:
|
||||
path: greptimedb
|
||||
persist-credentials: false
|
||||
- name: Validate version comparison
|
||||
env:
|
||||
RUN_GREPTIMEDB: ${{ inputs.run_greptimedb }}
|
||||
run: |
|
||||
python3 greptimedb/tests/perf/test_benchmark_version_comparison.py
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py validate
|
||||
- name: Resolve CI runner
|
||||
id: runner
|
||||
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider "$PROVIDER" --instance-type "$REQUESTED_INSTANCE_TYPE"
|
||||
@@ -135,8 +153,8 @@ jobs:
|
||||
((BENCHMARK_TIMEOUT_MINUTES <= 360 && JANITOR_TTL_HOURS <= 168))
|
||||
# Match the existing ECS workflow's provision, teardown and queue headroom.
|
||||
((JANITOR_TTL_HOURS * 60 >= BENCHMARK_TIMEOUT_MINUTES + 120))
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* ]]
|
||||
docker pull "$RUNTIME_IMAGE"
|
||||
[[ "$RUNTIME_IMAGE" == *.cr.aliyuncs.com/* || "$RUNTIME_IMAGE" == public.ecr.aws/* ]]
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RUNTIME_IMAGE"
|
||||
metadata=$(docker image inspect "$RUNTIME_IMAGE")
|
||||
resolved=$(jq -er '.[0].RepoDigests[0]' <<< "$metadata")
|
||||
revision=$(jq -er '.[0].Config.Labels["org.opencontainers.image.revision"]' <<< "$metadata")
|
||||
@@ -146,6 +164,9 @@ jobs:
|
||||
args=(--workload vmbench-long-range --range-preset "$RANGE_PRESET"
|
||||
--db-cpus "$DB_CPUS" --db-memory "$DB_MEMORY"
|
||||
--run-classification controlled_server_benchmark --no-open-browser)
|
||||
if [[ "$COMPARE_GREPTIMEDB" == true ]]; then
|
||||
args+=(--target "greptimedb:name=greptimedb-baseline,image=greptime/greptimedb:$BASELINE_GREPTIMEDB_TAG,url=http://127.0.0.1:14000/v1/prometheus/api/v1/query_range")
|
||||
fi
|
||||
# Tags become target-spec fields; reject separators before passing them to the planner.
|
||||
for target in greptimedb victoriametrics; do
|
||||
case "$target" in
|
||||
@@ -273,13 +294,13 @@ jobs:
|
||||
available=$(df -Pk "$path" | awk 'NR == 2 {print $4}')
|
||||
((available >= 10 * 1024 * 1024)) || { echo "Need at least 10 GiB free on $path"; exit 1; }
|
||||
done
|
||||
docker pull "$RESOLVED_RUNTIME_IMAGE"
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$RESOLVED_RUNTIME_IMAGE"
|
||||
docker image inspect "$RESOLVED_RUNTIME_IMAGE" > "$METADATA_ROOT/runtime-image.json"
|
||||
test "$(jq -r '.[0].Config.Labels["org.opencontainers.image.revision"]' "$METADATA_ROOT/runtime-image.json")" = "$RESOLVED_O11YBENCH_REF"
|
||||
plan="$METADATA_ROOT/warm.json"
|
||||
while IFS=$'\t' read -r target reference; do
|
||||
echo "Resolving $target: $reference"
|
||||
docker pull "$reference"
|
||||
bash greptimedb/.github/scripts/pull-benchmark-image.sh "$reference"
|
||||
docker image inspect "$reference" > "$METADATA_ROOT/$target-image.json"
|
||||
digest=$(jq -er '.[0].RepoDigests[0]' "$METADATA_ROOT/$target-image.json")
|
||||
jq --arg target "$target" --arg image "$digest" '.targets |= map(if .name == $target then .image = $image else . end)' "$plan" > "$plan.tmp"
|
||||
@@ -303,6 +324,10 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
docker ps -aq --filter "label=o11ybench.run-id=$RUN_ID-warm" | xargs -r docker rm -fv
|
||||
- name: GreptimeDB version comparison
|
||||
if: always() && inputs.compare_greptimedb
|
||||
run: |
|
||||
python3 greptimedb/.github/scripts/benchmark-version-comparison.py report --kind long-range --root "$ARTIFACT_ROOT-warm" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Stage benchmark artifacts
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
Generated
+123
-119
@@ -230,7 +230,7 @@ checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arrow-schema 59.2.0",
|
||||
"common-base",
|
||||
@@ -959,7 +959,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auth"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -1618,7 +1618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cache"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"catalog",
|
||||
"common-error",
|
||||
@@ -1684,7 +1684,7 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "catalog"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow 59.2.0",
|
||||
@@ -2040,7 +2040,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
|
||||
|
||||
[[package]]
|
||||
name = "cli"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -2095,7 +2095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "client"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arc-swap",
|
||||
@@ -2127,10 +2127,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"snafu 0.8.6",
|
||||
"store-api",
|
||||
"substrait 1.3.0-beta.1",
|
||||
"substrait 1.3.0-beta.2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic 0.14.2",
|
||||
"tonic-prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2175,7 +2176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cmd"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -2311,7 +2312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-base"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anymap2",
|
||||
@@ -2320,6 +2321,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"common-test-util",
|
||||
"futures",
|
||||
"icu_properties",
|
||||
"lazy_static",
|
||||
"pin-project",
|
||||
"regex",
|
||||
@@ -2332,7 +2334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-batcher"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"futures",
|
||||
@@ -2341,14 +2343,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-catalog"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"const_format",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "common-config"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-base",
|
||||
"common-error",
|
||||
@@ -2371,7 +2373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-datasource"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arrow 59.2.0",
|
||||
"arrow-schema 59.2.0",
|
||||
@@ -2408,7 +2410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-decimal"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"bigdecimal 0.4.8",
|
||||
"common-error",
|
||||
@@ -2421,7 +2423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-error"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-macro",
|
||||
"http 1.5.0",
|
||||
@@ -2433,7 +2435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-event-recorder"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -2458,7 +2460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-frontend"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -2477,7 +2479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-function"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -2513,7 +2515,6 @@ dependencies = [
|
||||
"geohash",
|
||||
"h3o",
|
||||
"hyperloglogplus",
|
||||
"icu_properties",
|
||||
"jsonb",
|
||||
"memchr",
|
||||
"mito-codec",
|
||||
@@ -2539,7 +2540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-greptimedb-telemetry"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"common-runtime",
|
||||
@@ -2556,7 +2557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-grpc"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow-flight",
|
||||
@@ -2591,7 +2592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-grpc-expr"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"common-catalog",
|
||||
@@ -2609,7 +2610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-macro"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"greptime-proto",
|
||||
"once_cell",
|
||||
@@ -2620,7 +2621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-mem-prof"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common-error",
|
||||
@@ -2636,7 +2637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-memory-manager"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-error",
|
||||
"common-macro",
|
||||
@@ -2648,7 +2649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-meta"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"anymap2",
|
||||
"api",
|
||||
@@ -2719,7 +2720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-options"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-base",
|
||||
"common-grpc",
|
||||
@@ -2730,11 +2731,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-plugins"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
|
||||
[[package]]
|
||||
name = "common-pprof"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-error",
|
||||
"common-macro",
|
||||
@@ -2745,7 +2746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-procedure"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-stream",
|
||||
@@ -2774,7 +2775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-procedure-test"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"common-procedure",
|
||||
@@ -2783,7 +2784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-query"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -2813,7 +2814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-recordbatch"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"common-base",
|
||||
@@ -2838,7 +2839,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-runtime"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"catio",
|
||||
@@ -2866,7 +2867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-session"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"strum 0.27.1",
|
||||
@@ -2874,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-sql"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arrow-schema 59.2.0",
|
||||
"common-base",
|
||||
@@ -2893,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-stat"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-base",
|
||||
"common-runtime",
|
||||
@@ -2907,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-telemetry"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"common-base",
|
||||
@@ -2937,7 +2938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-test-util"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-grpc",
|
||||
"common-query",
|
||||
@@ -2949,7 +2950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-time"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arrow 59.2.0",
|
||||
"chrono",
|
||||
@@ -2967,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-version"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"cargo-manifest",
|
||||
"const_format",
|
||||
@@ -2977,7 +2978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-wal"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-base",
|
||||
"common-error",
|
||||
@@ -3000,7 +3001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common-workload"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"common-telemetry",
|
||||
"serde",
|
||||
@@ -4473,7 +4474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "datanode"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow-flight",
|
||||
@@ -4546,7 +4547,7 @@ checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745"
|
||||
|
||||
[[package]]
|
||||
name = "datatypes"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arrow 59.2.0",
|
||||
"arrow-array 59.2.0",
|
||||
@@ -5278,7 +5279,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "file-engine"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -5419,7 +5420,7 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||
|
||||
[[package]]
|
||||
name = "flow"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow 59.2.0",
|
||||
@@ -5471,7 +5472,7 @@ dependencies = [
|
||||
"snafu 0.8.6",
|
||||
"sql",
|
||||
"store-api",
|
||||
"substrait 1.3.0-beta.1",
|
||||
"substrait 1.3.0-beta.2",
|
||||
"table",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -5568,7 +5569,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "frontend"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow-flight",
|
||||
@@ -6946,7 +6947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "index"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"async-trait",
|
||||
@@ -6965,8 +6966,6 @@ dependencies = [
|
||||
"futures",
|
||||
"greptime-proto",
|
||||
"itertools 0.14.0",
|
||||
"jieba-rs",
|
||||
"lazy_static",
|
||||
"mockall",
|
||||
"pin-project",
|
||||
"prost 0.14.1",
|
||||
@@ -8097,7 +8096,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "log-query"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"common-error",
|
||||
@@ -8109,7 +8108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log-store"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -8461,7 +8460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "meta-client"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -8490,7 +8489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "meta-srv"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -8581,7 +8580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metric-engine"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aquamarine",
|
||||
@@ -8681,7 +8680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mito-codec"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"bytes",
|
||||
@@ -8706,7 +8705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mito2"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aquamarine",
|
||||
@@ -9549,7 +9548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "object-store"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -9695,9 +9694,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "opendal"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4"
|
||||
checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a"
|
||||
dependencies = [
|
||||
"ctor",
|
||||
"opendal-core",
|
||||
@@ -9720,9 +9719,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-core"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b"
|
||||
checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"asyncband",
|
||||
@@ -9746,9 +9745,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-http-transport-reqwest"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb"
|
||||
checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -9760,9 +9759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-concurrent-limit"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3734c648617e5ad724d49175dde8323550624f9614f50e9e81b7aafae30b1734"
|
||||
checksum = "db8574309e68bf6d49c5378eaef9d0b3f6786971650b061c69e808254d71cb05"
|
||||
dependencies = [
|
||||
"asyncband",
|
||||
"futures",
|
||||
@@ -9772,9 +9771,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-logging"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a8daab5a91eac76f94516e7c71e677195c9221840ce8f5e71815e3fc8207742"
|
||||
checksum = "4ad43246d2cb7b7ad32b10e22bfa7b6ced8a6677cb6d6f005ae7e1d94cb19c6a"
|
||||
dependencies = [
|
||||
"log",
|
||||
"opendal-core",
|
||||
@@ -9782,9 +9781,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-observe-metrics-common"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe664be117c293cfa3980a0d2fc238bbb38eae5f6c191f376e88ccb830d26b"
|
||||
checksum = "3b8bc97d5a00eb41d50f1f6077c4bd5396c1eefea495614084ee9a37202ae06d"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
@@ -9793,9 +9792,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-prometheus"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b32078d8248555ee6271332ec480b78306a44f0d902ea4721ccac00d9b866a21"
|
||||
checksum = "404e766cc2e4e4c9225ce08605ae151f768a05a41a448a235e016b1aea9ff98f"
|
||||
dependencies = [
|
||||
"opendal-core",
|
||||
"opendal-layer-observe-metrics-common",
|
||||
@@ -9804,9 +9803,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-retry"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb8e22cb1f86ef9af771a8947d3ccf380c8f5b76236f628314dd41c9ba73331a"
|
||||
checksum = "4aeb54be4940c68609483591934a813f7bdd97d747826783a393914d3139e363"
|
||||
dependencies = [
|
||||
"backon",
|
||||
"log",
|
||||
@@ -9815,9 +9814,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-timeout"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ac6c77fe0d1b18e0064a02c19b98dbebcab78bf5e7753ad1f3abbc18850ff4"
|
||||
checksum = "1d11d53e67473e8914eacb2e14d586779ae57e1a84759ad25232ef339db77d8a"
|
||||
dependencies = [
|
||||
"opendal-core",
|
||||
"tokio",
|
||||
@@ -9825,9 +9824,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-layer-tracing"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "603807927a799ea981f62a3b0012e17181e39f468793b78ca8bedda66d19813c"
|
||||
checksum = "bbe2ebfc795bf08da77d20bed13d149b478cc8e5002df44d237e460134f00be4"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
@@ -9837,9 +9836,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-azblob"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6cf31b02e7b44191d97c07254eea109b5da9c17210a29e478c8d0dc26dffb1f"
|
||||
checksum = "c32c3fc2e507c854ce53615b28fc2208f5e00cbe456c4d344ffdfbf4c9cb976d"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -9858,9 +9857,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-azure-common"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8107324c3f3cb9970fe20526c2c341b7c5769acbd4699bab73bd525f5fd63d62"
|
||||
checksum = "cc93c30bd347a36d93fd46d47f3e2bb6494dcf9458938aa501fe046ff8f7562f"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"opendal-core",
|
||||
@@ -9868,9 +9867,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-fs"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0e7d8974aebd33be899200c9d0d50b2141fd5fa5c462b29e20653e3ddbfe339"
|
||||
checksum = "ec5c16fe91b77c0364f66546a1c32b2999e9c3c3c75c2e908a282268e0890bac"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9882,9 +9881,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-gcs"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d80eca6f9227bdf65d43e5b8d5fe75dca8cfab1da07571442a0420400847bea"
|
||||
checksum = "e1b5e42c6606ea1e37fa9cdedf07dc998073a45eb5044317c0fa6f3153e98d6e"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -9904,9 +9903,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-hdfs-native"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b84340e11a3bcd7f1f0b55df032d040b2907483c546c3021485aba749c3072c"
|
||||
checksum = "20fb97b2ac06a87e6720f0f93d5107ac046117deb8ac957030b0962ba4ecc3c5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -9918,9 +9917,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-http"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ac1553d6b57ca79e200e5c7ded9ab708f2016dadb28ede0f2da71e02fd0b960"
|
||||
checksum = "65fdde60d1abf14e47eaf19360c08ea1b89c14f03769db448e016781c2e0e32b"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"log",
|
||||
@@ -9930,9 +9929,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-mysql"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e15592be32b105cd152ab398b3db847e22c8c5f230d9b34ff095ea3d0dc6fd97"
|
||||
checksum = "9ba30e2a0b38aa55579b69bf57f607df7aa613f4d964496407b7998f2dbaefcc"
|
||||
dependencies = [
|
||||
"asyncband",
|
||||
"opendal-core",
|
||||
@@ -9942,9 +9941,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-oss"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ebfa355b1d4edb0e5b2a5b86d8de9f811fae1d7baca6d65246b230b2afc0bd"
|
||||
checksum = "d64d2909b444008e42f00137a50df6b55ba599eeb6c8b46c71a2a84948f91880"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.5.0",
|
||||
@@ -9959,9 +9958,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opendal-service-s3"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d"
|
||||
checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -9981,7 +9980,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "opensrv-mysql"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/GreptimeTeam/opensrv?rev=f3135e5f00401e06075cf241c0cc26489e45b20a#f3135e5f00401e06075cf241c0cc26489e45b20a"
|
||||
source = "git+https://github.com/GreptimeTeam/opensrv?rev=1f3d88ccaafb5721be6cfcc3ccbca48642426119#1f3d88ccaafb5721be6cfcc3ccbca48642426119"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byteorder",
|
||||
@@ -10094,7 +10093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "operator"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -10102,7 +10101,9 @@ dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.8.4",
|
||||
"backon",
|
||||
"bytes",
|
||||
"cache",
|
||||
"catalog",
|
||||
"chrono",
|
||||
"client",
|
||||
@@ -10133,12 +10134,14 @@ dependencies = [
|
||||
"futures",
|
||||
"futures-util",
|
||||
"humantime",
|
||||
"humantime-serde",
|
||||
"itertools 0.14.0",
|
||||
"jsonb",
|
||||
"lazy_static",
|
||||
"meta-client",
|
||||
"meter-core",
|
||||
"meter-macros",
|
||||
"mito-codec",
|
||||
"moka",
|
||||
"object-store",
|
||||
"partition",
|
||||
@@ -10156,9 +10159,10 @@ dependencies = [
|
||||
"sql",
|
||||
"sqlparser",
|
||||
"store-api",
|
||||
"substrait 1.3.0-beta.1",
|
||||
"substrait 1.3.0-beta.2",
|
||||
"table",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tonic 0.14.2",
|
||||
"tracing",
|
||||
@@ -10490,7 +10494,7 @@ checksum = "e3c406c9e2aa74554e662d2c2ee11cd3e73756988800be7e6f5eddb16fed4699"
|
||||
|
||||
[[package]]
|
||||
name = "partition"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"common-base",
|
||||
@@ -10884,7 +10888,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pipeline"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -11039,7 +11043,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"auth",
|
||||
"catalog",
|
||||
@@ -11382,7 +11386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "promql"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"async-trait",
|
||||
@@ -11799,7 +11803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "puffin"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-trait",
|
||||
@@ -11851,7 +11855,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "query"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -11918,7 +11922,7 @@ dependencies = [
|
||||
"sql",
|
||||
"sqlparser",
|
||||
"store-api",
|
||||
"substrait 1.3.0-beta.1",
|
||||
"substrait 1.3.0-beta.2",
|
||||
"table",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
@@ -13533,7 +13537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "servers"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -13676,7 +13680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"api",
|
||||
@@ -14036,7 +14040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sql"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow-buffer 59.2.0",
|
||||
@@ -14091,7 +14095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlness-runner"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"clap",
|
||||
@@ -14370,7 +14374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "standalone"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"async-trait",
|
||||
@@ -14420,7 +14424,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "store-api"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aquamarine",
|
||||
@@ -14612,7 +14616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "substrait"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -14748,7 +14752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "table"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arc-swap",
|
||||
@@ -15028,7 +15032,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "tests-fuzz"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"async-trait",
|
||||
@@ -15076,7 +15080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tests-integration"
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
dependencies = [
|
||||
"api",
|
||||
"arrow-flight",
|
||||
@@ -15155,7 +15159,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"standalone",
|
||||
"store-api",
|
||||
"substrait 1.3.0-beta.1",
|
||||
"substrait 1.3.0-beta.2",
|
||||
"table",
|
||||
"tempfile",
|
||||
"time",
|
||||
|
||||
+2
-1
@@ -76,7 +76,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.3.0-beta.1"
|
||||
version = "1.3.0-beta.2"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -271,6 +271,7 @@ url = "2.3"
|
||||
# Pin below 0.14.5 to avoid the `connection_timeout_future` "async fn resumed after
|
||||
# completion" panic (greptimedb#8265 / grpc-rust#2522). Remove once tonic ships a fix.
|
||||
tonic = { version = "0.14, <0.14.5", features = ["tls-aws-lc", "gzip", "zstd"] }
|
||||
tonic-prost = "0.14"
|
||||
tower = "0.5"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
|
||||
@@ -10,7 +10,7 @@ CARGO_BUILD_OPTS := --locked
|
||||
IMAGE_REGISTRY ?= docker.io
|
||||
IMAGE_NAMESPACE ?= greptime
|
||||
IMAGE_TAG ?= latest
|
||||
DEV_BUILDER_IMAGE_TAG ?= 1.96.1-822042c7-20260924091315
|
||||
DEV_BUILDER_IMAGE_TAG ?= 1.98.1-7f0f592b-20261008091949
|
||||
DEV_BUILDER_RISCV64_IMAGE_TAG ?= $(DEV_BUILDER_IMAGE_TAG)
|
||||
DEV_BUILDER_RISCV64_BASE_IMAGE ?= ubuntu:22.04
|
||||
RISCV64_TARGET ?= riscv64gc-unknown-linux-gnu
|
||||
|
||||
+56
-51
@@ -16,17 +16,16 @@
|
||||
| `default_column_prefix` | String | Unset | The default column prefix for auto-created time index, value, and native histogram columns.<br/>Legacy OTLP summary columns keep their historical `greptime_` prefix. |
|
||||
| `auto_create_table` | Bool | `true` | Server-side global switch for auto table creation on write.<br/>When `false`, a missing table is never auto-created even if the request sets the `auto_create_table` hint to `true`. Default: `true`. |
|
||||
| `experimental_metric_export` | Bool | `false` | Enables experimental Parquet COPY DATABASE using shared Metric physical scans.<br/>Resume requires the previous export and storage writes to have ended; HTTP timeout is not confirmation. |
|
||||
| `user_provider` | String | Unset | The user provider for authentication.<br/>Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"<br/>Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",<br/>"mysql_native_password:<hex_sha1_sha1_password>",<br/>"pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"<br/>"pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but cannot authenticate over MySQL's<br/>native password handshake; a MySQL client must send the password in cleartext for such users.<br/>"mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.<br/>PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or<br/>"mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.<br/>For "pg_scram_sha256" users, keep the default iteration count (4096) and salt length (16): both are<br/>observable in the SCRAM server-first message, and non-default values weaken resistance to username<br/>enumeration. |
|
||||
| `max_in_flight_write_bytes` | String | Unset | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).<br/>Set to 0 to disable the limit. Default: "0" (unlimited) |
|
||||
| `write_bytes_exhausted_policy` | String | Unset | Policy when write bytes quota is exhausted.<br/>Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail" |
|
||||
| `user_provider` | String | Unset | The user provider for authentication.<br/>Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"<br/>Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",<br/>"mysql_native_password:<hex_sha1_sha1_password>",<br/>"pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"<br/>"pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but such users cannot log in over MySQL:<br/>`static_user_provider` and `watch_file_user_provider` only negotiate `mysql_native_password`, which cannot verify these formats.<br/>"mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.<br/>PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or<br/>"mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.<br/>For "pg_scram_sha256" users, keep the default iteration count (4096) and salt length (16): both are<br/>observable in the SCRAM server-first message, and non-default values weaken resistance to username<br/>enumeration. |
|
||||
| `max_in_flight_write_bytes` | String | 0 | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).<br/>Set to 0 to disable the limit. Default: "0" (unlimited) |
|
||||
| `write_bytes_exhausted_policy` | String | `wait` | Policy when write bytes quota is exhausted.<br/>Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail" |
|
||||
| `init_regions_in_background` | Bool | `false` | Initialize all regions in the background during the startup.<br/>By default, it provides services after all regions have been initialized. |
|
||||
| `init_regions_parallelism` | Integer | `16` | Parallelism of initializing regions. |
|
||||
| `max_concurrent_queries` | Integer | `0` | The maximum concurrent queries allowed to be executed. Zero means unlimited. |
|
||||
| `enable_telemetry` | Bool | `true` | Enable telemetry to collect anonymous usage data. Enabled by default. |
|
||||
| `runtime` | -- | -- | The runtime options. |
|
||||
| `runtime.global_rt_size` | Integer | `8` | The number of threads to execute the runtime for global read operations. |
|
||||
| `runtime.compact_rt_size` | Integer | `4` | The number of threads to execute compact operations. |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | `4` | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.global_rt_size` | Integer | Auto | The number of threads to execute the runtime for global read operations.<br/>Defaults to max(num_cpus, 2). |
|
||||
| `runtime.compact_rt_size` | Integer | Auto | The number of threads to execute compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | Auto | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.experimental_workload_scheduler` | -- | -- | Experimental weighted, work-conserving query/write task scheduler. |
|
||||
| `runtime.experimental_workload_scheduler.enable` | Bool | `false` | Enable when concurrent queries and writes interfere with each other—for example, when long-running queries increase ingestion latency.<br/>The weights set their relative runtime shares while both are backlogged. Disabled by default. |
|
||||
| `runtime.experimental_workload_scheduler.query_weight` | Integer | `2` | Relative query share while both query and write workloads are backlogged. |
|
||||
@@ -110,7 +109,7 @@
|
||||
| `wal.enable_log_recycle` | Bool | `true` | Whether to reuse logically truncated log files.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.prefill_log_files` | Bool | `false` | Whether to pre-create log files on start up.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.sync_period` | String | `5s` | Duration for fsyncing log files.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.recovery_parallelism` | Integer | `2` | Parallelism during WAL recovery. |
|
||||
| `wal.recovery_parallelism` | Integer | Auto | Parallelism during WAL recovery. Defaults to the number of CPU cores; this example sets it to 2. |
|
||||
| `wal.broker_endpoints` | Array | -- | The Kafka broker endpoints.<br/>**It's only used when the provider is `kafka`**. |
|
||||
| `wal.connect_timeout` | String | `3s` | The connect timeout for kafka client.<br/>**It's only used when the provider is `kafka`**. |
|
||||
| `wal.timeout` | String | `5s` | The total request timeout for kafka client.<br/>**It's only used when the provider is `kafka`**. |
|
||||
@@ -140,7 +139,7 @@
|
||||
| `procedure.max_running_procedures` | Integer | `128` | Max running procedures.<br/>The maximum number of procedures that can be running at the same time.<br/>If the number of running procedures exceeds this limit, the procedure will be rejected. |
|
||||
| `query` | -- | -- | The query engine options. |
|
||||
| `query.parallelism` | Integer | `0` | Parallelism of the query engine.<br/>Default to 0, which means the number of CPU cores. |
|
||||
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit (unbounded, default behavior).<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy:<br/>- "greedy" (default): first-come-first-served allocation; preserves current behavior.<br/>- "fair": divides available memory among spillable operators and may spill earlier.<br/>Only effective when `memory_pool_size` is bounded (>0). |
|
||||
| `query.experimental_spill_mode` | String | `default` | Spill mode:<br/>- "default": preserve DataFusion built-in OS temp directory (default).<br/>- "custom": explicitly configure spill path, quota, and compression.<br/>- "disabled": explicitly disable disk spilling.<br/>Set this to "custom" before using the path/quota/compression keys below. |
|
||||
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
|
||||
@@ -174,7 +173,7 @@
|
||||
| `storage.http_client.skip_ssl_validation` | Bool | `false` | To skip the ssl verification<br/>**Security Notice**: Setting `skip_ssl_validation = true` disables certificate verification, making connections vulnerable to man-in-the-middle attacks. Only use this in development or trusted private networks. |
|
||||
| `[[region_engine]]` | -- | -- | The region engine options. You can configure multiple region engines.<br/>Each engine type (mito, file, metric) may appear only once; duplicates cause startup to fail. |
|
||||
| `region_engine.mito` | -- | -- | The Mito engine options. |
|
||||
| `region_engine.mito.num_workers` | Integer | `8` | Number of region workers. |
|
||||
| `region_engine.mito.num_workers` | Integer | Auto | Number of region workers. Defaults to half of the available CPU cores, rounded up. |
|
||||
| `region_engine.mito.worker_channel_size` | Integer | `128` | Request channel size of each worker. |
|
||||
| `region_engine.mito.worker_request_batch_size` | Integer | `64` | Max batch size for a worker to handle requests. |
|
||||
| `region_engine.mito.manifest_checkpoint_distance` | Integer | `10` | Number of meta action updated to trigger a new checkpoint for the manifest. |
|
||||
@@ -258,17 +257,18 @@
|
||||
| `logging.tracing_sample_ratio` | -- | Unset | The percentage of tracing will be sampled and exported.<br/>Valid range `[0, 1]`, 1 means all traces are sampled, 0 means all traces are not sampled, the default value is 1.<br/>ratio > 1 are treated as 1. Fractions < 0 are treated as 0 |
|
||||
| `logging.tracing_sample_ratio.default_ratio` | Float | `1.0` | -- |
|
||||
| `slow_query` | -- | -- | The slow query log options. |
|
||||
| `slow_query.enable` | Bool | `false` | Whether to enable slow query log. |
|
||||
| `slow_query.record_type` | String | Unset | The record type of slow queries. It can be `system_table` or `log`. |
|
||||
| `slow_query.threshold` | String | Unset | The threshold of slow query. |
|
||||
| `slow_query.sample_ratio` | Float | Unset | The sampling ratio of slow query log. The value should be in the range of (0, 1]. |
|
||||
| `slow_query.enable` | Bool | `true` | Whether to enable slow query log. |
|
||||
| `slow_query.record_type` | String | `system_table` | The record type of slow queries. It can be `system_table` or `log`.<br/>If `system_table` is selected, the slow queries will be recorded in a system table `greptime_private.slow_queries`.<br/>If `log` is selected, the slow queries will be logged in a log file `greptimedb-slow-queries.*`. |
|
||||
| `slow_query.threshold` | String | `30s` | The threshold of slow query. It can be human readable time string, for example: `10s`, `100ms`, `1s`. |
|
||||
| `slow_query.sample_ratio` | Float | `1.0` | The sampling ratio of slow query log. The value should be in the range of (0, 1]. For example, `0.1` means 10% of the slow queries will be logged and `1.0` means all slow queries will be logged. |
|
||||
| `slow_query.ttl` | String | `90d` | The TTL of the `slow_queries` system table. Default is `90d` when `record_type` is `system_table`. |
|
||||
| `tracing` | -- | -- | The tracing options. Only effect when compiled with `tokio-console` feature. |
|
||||
| `tracing.tokio_console_addr` | String | Unset | The tokio console address. |
|
||||
| `pipeline` | -- | -- | The pipeline options. |
|
||||
| `pipeline.cache_ttl` | String | `10s` | Time to live of the local pipeline cache. Default is `10s`. |
|
||||
| `event_recorder` | -- | -- | Configuration options for the event recorder. |
|
||||
| `event_recorder.ttl` | String | `90d` | TTL for the events table that will be used to store the events. Default is `90d`. |
|
||||
| `event_recorder.event_types` | Array | -- | Event types to record. Current available event types: `create_database`,<br/>`alter_database`, `drop_database`, `create_flow`, `drop_flow`,<br/>`create_table`, `create_logical_tables`, `alter_table`, `alter_logical_tables`,<br/>`drop_table`, `undrop_table`, `purge_dropped_table`, `truncate_table`,<br/>`create_view`, `drop_view`, `admin_function`, `reconcile_catalog`,<br/>`reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
| `event_recorder.event_types` | Array | All | Event types to record. Current available event types: `create_database`,<br/>`alter_database`, `drop_database`, `create_flow`, `drop_flow`,<br/>`create_table`, `create_logical_tables`, `alter_table`, `alter_logical_tables`,<br/>`drop_table`, `undrop_table`, `purge_dropped_table`, `truncate_table`,<br/>`create_view`, `drop_view`, `admin_function`, `reconcile_catalog`,<br/>`reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
| `memory` | -- | -- | The memory options. |
|
||||
| `memory.enable_heap_profiling` | Bool | `true` | Whether to enable heap profiling activation during startup.<br/>When enabled, heap profiling will be activated if the `MALLOC_CONF` environment variable<br/>is set to "prof:true,prof_active:false". The official image adds this env variable.<br/>Default is true. |
|
||||
|
||||
@@ -283,13 +283,13 @@
|
||||
| `default_column_prefix` | String | Unset | The default column prefix for auto-created time index, value, and native histogram columns.<br/>Legacy OTLP summary columns keep their historical `greptime_` prefix. |
|
||||
| `auto_create_table` | Bool | `true` | Server-side global switch for auto table creation on write.<br/>When `false`, a missing table is never auto-created even if the request sets the `auto_create_table` hint to `true`. Default: `true`. |
|
||||
| `experimental_metric_export` | Bool | `false` | Enables experimental Parquet COPY DATABASE using shared Metric physical scans.<br/>Resume requires the previous export and storage writes to have ended; HTTP timeout is not confirmation. |
|
||||
| `user_provider` | String | Unset | The user provider for authentication.<br/>Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"<br/>Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",<br/>"mysql_native_password:<hex_sha1_sha1_password>",<br/>"pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"<br/>"pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but cannot authenticate over MySQL's<br/>native password handshake; a MySQL client must send the password in cleartext for such users.<br/>"mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.<br/>PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or<br/>"mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.<br/>For "pg_scram_sha256" users, keep the default iteration count (4096) and salt length (16): both are<br/>observable in the SCRAM server-first message, and non-default values weaken resistance to username<br/>enumeration. |
|
||||
| `max_in_flight_write_bytes` | String | Unset | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).<br/>Set to 0 to disable the limit. Default: "0" (unlimited) |
|
||||
| `write_bytes_exhausted_policy` | String | Unset | Policy when write bytes quota is exhausted.<br/>Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail" |
|
||||
| `user_provider` | String | Unset | The user provider for authentication.<br/>Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"<br/>Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",<br/>"mysql_native_password:<hex_sha1_sha1_password>",<br/>"pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"<br/>"pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but such users cannot log in over MySQL:<br/>`static_user_provider` and `watch_file_user_provider` only negotiate `mysql_native_password`, which cannot verify these formats.<br/>"mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.<br/>PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or<br/>"mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.<br/>For "pg_scram_sha256" users, keep the default iteration count (4096) and salt length (16): both are<br/>observable in the SCRAM server-first message, and non-default values weaken resistance to username<br/>enumeration. |
|
||||
| `max_in_flight_write_bytes` | String | 0 | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).<br/>Set to 0 to disable the limit. Default: "0" (unlimited) |
|
||||
| `write_bytes_exhausted_policy` | String | `wait` | Policy when write bytes quota is exhausted.<br/>Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail" |
|
||||
| `runtime` | -- | -- | The runtime options. |
|
||||
| `runtime.global_rt_size` | Integer | `8` | The number of threads to execute the runtime for global read operations. |
|
||||
| `runtime.compact_rt_size` | Integer | `4` | The number of threads to execute compact operations. |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | `4` | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.global_rt_size` | Integer | Auto | The number of threads to execute the runtime for global read operations.<br/>Defaults to max(num_cpus, 2). |
|
||||
| `runtime.compact_rt_size` | Integer | Auto | The number of threads to execute compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | Auto | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `http` | -- | -- | The HTTP server options. |
|
||||
| `http.addr` | String | `127.0.0.1:4000` | The address to bind the HTTP server. |
|
||||
| `http.timeout` | String | `0s` | HTTP request timeout. Set to 0 to disable timeout.<br/>When synchronous Prometheus, OTLP metrics, or ordinary-table batching is enabled, a nonzero timeout is<br/>raised to at least the largest active flush interval plus 1 second. The intervals come from<br/>`pending_rows_batcher.logical_table.pending_rows_flush_interval` and `pending_rows_batcher.pending_rows_flush_interval`. |
|
||||
@@ -300,20 +300,20 @@
|
||||
| `http.api_server_addr` | String | `127.0.0.1:4006` | The address to bind the dedicated HTTP API server, in the same form as `addr`.<br/>Defaults to `127.0.0.1:4006`. |
|
||||
| `grpc` | -- | -- | The gRPC server options. |
|
||||
| `grpc.bind_addr` | String | `127.0.0.1:4001` | The address to bind the gRPC server. |
|
||||
| `grpc.server_addr` | String | `127.0.0.1:4001` | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.server_addr` | String | Unset | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
|
||||
| `grpc.flight_compression` | String | `arrow_ipc` | Compression mode for frontend side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `none` |
|
||||
| `grpc.flight_compression` | String | `arrow_ipc` | Compression mode for frontend side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `arrow_ipc`. |
|
||||
| `grpc.max_connection_age` | String | Unset | The maximum connection age for gRPC connection.<br/>The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.<br/>Refer to https://grpc.io/docs/guides/keepalive/ for more details. |
|
||||
| `grpc.tls` | -- | -- | gRPC server TLS options, see `mysql.tls` section. |
|
||||
| `grpc.tls.mode` | String | `disable` | TLS mode. |
|
||||
| `grpc.tls.cert_path` | String | Unset | Certificate file path. |
|
||||
| `grpc.tls.key_path` | String | Unset | Private key file path. |
|
||||
| `grpc.tls.watch` | Bool | `false` | Watch for Certificate and key file change and auto reload.<br/>For now, gRPC tls config does not support auto reload. |
|
||||
| `internal_grpc` | -- | -- | The internal gRPC server options. Internal gRPC port for nodes inside cluster to access frontend. |
|
||||
| `internal_grpc.bind_addr` | String | `127.0.0.1:4010` | The address to bind the gRPC server. |
|
||||
| `internal_grpc.server_addr` | String | `127.0.0.1:4010` | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `internal_grpc.bind_addr`. |
|
||||
| `internal_grpc` | -- | -- | The internal gRPC server options. Internal gRPC port for nodes inside cluster to access frontend.<br/>The internal gRPC server starts only when this section is set. |
|
||||
| `internal_grpc.bind_addr` | String | Unset | The address to bind the gRPC server.<br/>If unset, it falls back to `127.0.0.1:4001`, the same address as `grpc.bind_addr`. |
|
||||
| `internal_grpc.server_addr` | String | Unset | The address advertised to the metasrv, and used for connections from outside the host.<br/>Unlike `grpc.server_addr`, it is not detected automatically; set it explicitly. |
|
||||
| `internal_grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
|
||||
| `internal_grpc.flight_compression` | String | `arrow_ipc` | Compression mode for frontend side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `none` |
|
||||
| `internal_grpc.flight_compression` | String | `arrow_ipc` | Compression mode for frontend side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `arrow_ipc`. |
|
||||
| `internal_grpc.tls` | -- | -- | internal gRPC server TLS options, see `mysql.tls` section. |
|
||||
| `internal_grpc.tls.mode` | String | `disable` | TLS mode. |
|
||||
| `internal_grpc.tls.cert_path` | String | Unset | Certificate file path. |
|
||||
@@ -381,15 +381,20 @@
|
||||
| `query` | -- | -- | The query engine options. |
|
||||
| `query.parallelism` | Integer | `0` | Parallelism of the query engine.<br/>Default to 0, which means the number of CPU cores. |
|
||||
| `query.allow_query_fallback` | Bool | `false` | Whether to allow query fallback when push down optimize fails.<br/>Default to false, meaning when push down optimize failed, return error msg |
|
||||
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%").<br/>Setting it to 0 disables the limit (unbounded, default behavior).<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans (only applies to datanodes). |
|
||||
| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%").<br/>Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans (only applies to datanodes). |
|
||||
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy:<br/>- "greedy" (default): first-come-first-served allocation; preserves current behavior.<br/>- "fair": divides available memory among spillable operators and may spill earlier.<br/>Only effective when `memory_pool_size` is bounded (>0). |
|
||||
| `query.experimental_spill_mode` | String | `default` | Spill mode:<br/>- "default": preserve DataFusion built-in OS temp directory (default).<br/>- "custom": explicitly configure spill path, quota, and compression.<br/>- "disabled": explicitly disable disk spilling.<br/>Set this to "custom" before using the path/quota/compression keys below. |
|
||||
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
|
||||
| `query.experimental_spill_max_temp_directory_size` | String | `1GiB` | Maximum total size of spill directory (default: "1GiB").<br/>Ignored unless mode is "custom". |
|
||||
| `query.experimental_spill_compression` | String | `uncompressed` | Compression for spilled data files: "uncompressed" (default), "lz4_frame", "zstd".<br/>Ignored unless mode is "custom". |
|
||||
| `write_retry` | -- | -- | Retry retryable distributed write failures.<br/>Backoff grows exponentially, with jitter to spread concurrent retries. |
|
||||
| `write_retry.max_retries` | Integer | `3` | Maximum retries after the initial write attempt. Set to 0 to disable retries. |
|
||||
| `write_retry.initial_backoff` | String | `1500ms` | Initial backoff before jitter is applied. Must be positive. |
|
||||
| `write_retry.max_backoff` | String | `10s` | Maximum backoff before jitter is applied. Must be at least initial_backoff. |
|
||||
| `write_retry.backoff_multiplier` | Float | `2.0` | Backoff multiplier. Must be finite and at least 1. |
|
||||
| `datanode` | -- | -- | Datanode options. |
|
||||
| `datanode.client` | -- | -- | Datanode client options. |
|
||||
| `datanode.client.connect_timeout` | String | `10s` | -- |
|
||||
| `datanode.client.connect_timeout` | String | 1s | Connect server timeout. Defaults to `1s`; this example sets it to `10s`. |
|
||||
| `datanode.client.tcp_nodelay` | Bool | `true` | -- |
|
||||
| `datanode.client.max_recv_message_size` | String | `512MB` | The maximum receive message size for the gRPC client. |
|
||||
| `datanode.client.max_send_message_size` | String | `512MB` | The maximum send message size for the gRPC client. |
|
||||
@@ -422,7 +427,7 @@
|
||||
| `pipeline.cache_ttl` | String | `10s` | Time to live of the frontend-local pipeline cache. A pipeline created or deleted on<br/>another frontend takes effect on this one after at most this duration. |
|
||||
| `event_recorder` | -- | -- | Configuration options for the event recorder. |
|
||||
| `event_recorder.ttl` | String | `90d` | TTL for the events table that will be used to store the events. Default is `90d`. |
|
||||
| `event_recorder.event_types` | Array | -- | Event types to record. Current available event type: `admin_function`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
| `event_recorder.event_types` | Array | All | Event types to record. Current available event type: `admin_function`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
|
||||
|
||||
### Metasrv
|
||||
@@ -434,7 +439,7 @@
|
||||
| `store_key_prefix` | String | `""` | If it's not empty, the metasrv will store all data with this key prefix. |
|
||||
| `backend` | String | `etcd_store` | The datastore for meta server.<br/>Available values:<br/>- `etcd_store` (default value)<br/>- `memory_store`<br/>- `postgres_store`<br/>- `mysql_store` |
|
||||
| `meta_table_name` | String | `greptime_metakv` | Table name in RDS to store metadata. Effect when using a RDS kvbackend.<br/>**Only used when backend is `postgres_store`.** |
|
||||
| `meta_schema_name` | String | `greptime_schema` | Optional PostgreSQL schema for metadata table and election table name qualification.<br/>When PostgreSQL public schema is not writable (e.g., PostgreSQL 15+ with restricted public),<br/>set this to a writable schema. GreptimeDB will use `meta_schema_name`.`meta_table_name`.<br/>**Only used when backend is `postgres_store`.** |
|
||||
| `meta_schema_name` | String | Unset | Optional PostgreSQL schema for metadata table and election table name qualification.<br/>When PostgreSQL public schema is not writable (e.g., PostgreSQL 15+ with restricted public),<br/>set this to a writable schema. GreptimeDB will use `meta_schema_name`.`meta_table_name`.<br/>**Only used when backend is `postgres_store`.**<br/>Unset by default; this example sets it to `greptime_schema`. |
|
||||
| `auto_create_schema` | Bool | `true` | Automatically create PostgreSQL schema if it doesn't exist.<br/>When enabled, the system will execute `CREATE SCHEMA IF NOT EXISTS <schema_name>`<br/>before creating metadata tables. This is useful in production environments where<br/>manual schema creation may be restricted.<br/>Default is true.<br/>Note: The PostgreSQL user must have CREATE SCHEMA permission for this to work.<br/>**Only used when backend is `postgres_store`.** |
|
||||
| `meta_election_lock_id` | Integer | `1` | Advisory lock id in PostgreSQL for election. Effect when using PostgreSQL as kvbackend<br/>Only used when backend is `postgres_store`. |
|
||||
| `selector` | String | `round_robin` | Datanode selector type.<br/>- `round_robin` (default value)<br/>- `lease_based`<br/>- `load_based`<br/>For details, please see "https://docs.greptime.com/developer-guide/metasrv/selector". |
|
||||
@@ -445,9 +450,9 @@
|
||||
| `heartbeat_interval` | String | `3s` | Base heartbeat interval for calculating distributed time constants.<br/>The frontend heartbeat interval is 6 times of the base heartbeat interval.<br/>The flownode/datanode heartbeat interval is 1 times of the base heartbeat interval.<br/>e.g., If the base heartbeat interval is 3s, the frontend heartbeat interval is 18s, the flownode/datanode heartbeat interval is 3s.<br/>Heartbeat intervals are negotiated from metasrv during handshake; local node configs do not override this. |
|
||||
| `enable_telemetry` | Bool | `true` | Whether to enable greptimedb telemetry. Enabled by default. |
|
||||
| `runtime` | -- | -- | The runtime options. |
|
||||
| `runtime.global_rt_size` | Integer | `8` | The number of threads to execute the runtime for global read operations. |
|
||||
| `runtime.compact_rt_size` | Integer | `4` | The number of threads to execute compact operations. |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | `4` | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.global_rt_size` | Integer | Auto | The number of threads to execute the runtime for global read operations.<br/>Defaults to max(num_cpus, 2). |
|
||||
| `runtime.compact_rt_size` | Integer | Auto | The number of threads to execute compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | Auto | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `backend_tls` | -- | -- | TLS configuration for kv store backend (applicable for etcd, PostgreSQL, and MySQL backends)<br/>When using etcd, PostgreSQL, or MySQL as metadata store, you can configure TLS here<br/><br/>Note: if TLS is configured in both this section and the `store_addrs` connection string, the<br/>settings here will override the TLS settings in `store_addrs`. |
|
||||
| `backend_tls.mode` | String | `prefer` | TLS mode, refer to https://www.postgresql.org/docs/current/libpq-ssl.html<br/>- "disable" - No TLS<br/>- "prefer" (default) - Try TLS, fallback to plain<br/>- "require" - Require TLS<br/>- "verify_ca" - Require TLS and verify CA<br/>- "verify_full" - Require TLS and verify hostname |
|
||||
| `backend_tls.cert_path` | String | `""` | Path to client certificate file (for client authentication)<br/>Like "/path/to/client.crt" |
|
||||
@@ -459,7 +464,7 @@
|
||||
| `backend_client.connect_timeout` | String | `3s` | The connect timeout for backend client. |
|
||||
| `grpc` | -- | -- | The gRPC server options. |
|
||||
| `grpc.bind_addr` | String | `127.0.0.1:3002` | The address to bind the gRPC server. |
|
||||
| `grpc.server_addr` | String | `127.0.0.1:3002` | The communication server address for the frontend and datanode to connect to metasrv.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.server_addr` | String | Unset | The communication server address for the frontend and datanode to connect to metasrv.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
|
||||
| `grpc.max_recv_message_size` | String | `512MB` | The maximum receive message size for gRPC server. |
|
||||
| `grpc.max_send_message_size` | String | `512MB` | The maximum send message size for gRPC server. |
|
||||
@@ -481,7 +486,7 @@
|
||||
| `datanode` | -- | -- | Datanode options. |
|
||||
| `datanode.client` | -- | -- | Datanode client options. |
|
||||
| `datanode.client.timeout` | String | `10s` | Operation timeout. |
|
||||
| `datanode.client.connect_timeout` | String | `10s` | Connect server timeout. |
|
||||
| `datanode.client.connect_timeout` | String | 1s | Connect server timeout. Defaults to `1s`; this example sets it to `10s`. |
|
||||
| `datanode.client.tcp_nodelay` | Bool | `true` | `TCP_NODELAY` option for accepted connections. |
|
||||
| `datanode.client.max_recv_message_size` | String | `512MB` | The maximum receive message size for the gRPC client. |
|
||||
| `datanode.client.max_send_message_size` | String | `512MB` | The maximum send message size for the gRPC client. |
|
||||
@@ -503,7 +508,7 @@
|
||||
| `wal.create_topic_timeout` | String | `30s` | The timeout for creating a Kafka topic.<br/>**It's only used when the provider is `kafka`**. |
|
||||
| `event_recorder` | -- | -- | Configuration options for the event recorder. |
|
||||
| `event_recorder.ttl` | String | `90d` | TTL for the events table that will be used to store the events. Default is `90d`. |
|
||||
| `event_recorder.event_types` | Array | -- | Event types to record. Current available event types: `region_migration`,<br/>`create_database`, `alter_database`, `drop_database`, `create_flow`,<br/>`drop_flow`, `create_table`, `create_logical_tables`, `alter_table`,<br/>`alter_logical_tables`, `drop_table`, `undrop_table`, `purge_dropped_table`,<br/>`truncate_table`, `create_view`, `drop_view`, `repartition`,<br/>`repartition_group`, `wal_prune`, `batch_gc`, `reconcile_catalog`,<br/>`reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
| `event_recorder.event_types` | Array | All | Event types to record. Current available event types: `region_migration`,<br/>`create_database`, `alter_database`, `drop_database`, `create_flow`,<br/>`drop_flow`, `create_table`, `create_logical_tables`, `alter_table`,<br/>`alter_logical_tables`, `drop_table`, `undrop_table`, `purge_dropped_table`,<br/>`truncate_table`, `create_view`, `drop_view`, `repartition`,<br/>`repartition_group`, `wal_prune`, `batch_gc`, `reconcile_catalog`,<br/>`reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.<br/>When omitted, all current and future event types are recorded.<br/>Set to an empty array to disable event recording. |
|
||||
| `stats_persistence` | -- | -- | Configuration options for the stats persistence. |
|
||||
| `stats_persistence.ttl` | String | `0s` | TTL for the stats table that will be used to store the stats.<br/>Set to `0s` to disable stats persistence.<br/>Default is `0s`.<br/>If you want to enable stats persistence, set the TTL to a value greater than 0.<br/>It is recommended to set a small value, e.g., `3h`. |
|
||||
| `stats_persistence.interval` | String | `10m` | The interval to persist the stats. Default is `10m`.<br/>The minimum value is `10m`, if the value is less than `10m`, it will be overridden to `10m`. |
|
||||
@@ -548,22 +553,22 @@
|
||||
| `http.body_limit` | String | `64MB` | HTTP request body limit.<br/>The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.<br/>Set to 0 to disable limit. |
|
||||
| `grpc` | -- | -- | The gRPC server options. |
|
||||
| `grpc.bind_addr` | String | `127.0.0.1:3001` | The address to bind the gRPC server. |
|
||||
| `grpc.server_addr` | String | `127.0.0.1:3001` | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.server_addr` | String | Unset | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
|
||||
| `grpc.max_recv_message_size` | String | `512MB` | The maximum receive message size for gRPC server. |
|
||||
| `grpc.max_send_message_size` | String | `512MB` | The maximum send message size for gRPC server. |
|
||||
| `grpc.flight_compression` | String | `arrow_ipc` | Compression mode for datanode side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `none` |
|
||||
| `grpc.flight_compression` | String | `arrow_ipc` | Compression mode for datanode side Arrow IPC service. Available options:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>Default to `arrow_ipc`. |
|
||||
| `grpc.tls` | -- | -- | gRPC server TLS options, see `mysql.tls` section. |
|
||||
| `grpc.tls.mode` | String | `disable` | TLS mode. |
|
||||
| `grpc.tls.cert_path` | String | Unset | Certificate file path. |
|
||||
| `grpc.tls.key_path` | String | Unset | Private key file path. |
|
||||
| `grpc.tls.watch` | Bool | `false` | Watch for Certificate and key file change and auto reload.<br/>For now, gRPC tls config does not support auto reload. |
|
||||
| `runtime` | -- | -- | The runtime options. |
|
||||
| `runtime.global_rt_size` | Integer | `8` | The number of threads to execute the runtime for global read operations. |
|
||||
| `runtime.compact_rt_size` | Integer | `4` | The number of threads to execute compact operations. |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | `4` | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.query_rt_size` | Integer | `7` | The number of threads to execute datanode query operations.<br/>Defaults to max(num_cpus - 1, 2). |
|
||||
| `runtime.ingest_rt_size` | Integer | `8` | The number of threads to execute datanode ingestion operations. |
|
||||
| `runtime.global_rt_size` | Integer | Auto | The number of threads to execute the runtime for global read operations.<br/>Defaults to max(num_cpus, 2). |
|
||||
| `runtime.compact_rt_size` | Integer | Auto | The number of threads to execute compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.compact_rt_max_blocking_threads` | Integer | Auto | The maximum number of blocking threads for compact operations.<br/>Defaults to max(num_cpus / 2, 2). |
|
||||
| `runtime.query_rt_size` | Integer | Auto | The number of threads to execute datanode query operations.<br/>Defaults to max(num_cpus - 1, 2). |
|
||||
| `runtime.ingest_rt_size` | Integer | Auto | The number of threads to execute datanode ingestion operations.<br/>Defaults to max(num_cpus, 2). |
|
||||
| `runtime.experimental_workload_scheduler` | -- | -- | Experimental weighted, work-conserving query/write task scheduler. |
|
||||
| `runtime.experimental_workload_scheduler.enable` | Bool | `false` | Enable when concurrent queries and writes interfere with each other—for example, when long-running queries increase ingestion latency.<br/>The weights set their relative runtime shares while both are backlogged. Disabled by default. |
|
||||
| `runtime.experimental_workload_scheduler.query_weight` | Integer | `2` | Relative query share while both query and write workloads are backlogged. |
|
||||
@@ -589,7 +594,7 @@
|
||||
| `wal.enable_log_recycle` | Bool | `true` | Whether to reuse logically truncated log files.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.prefill_log_files` | Bool | `false` | Whether to pre-create log files on start up.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.sync_period` | String | `5s` | Duration for fsyncing log files.<br/>**It's only used when the provider is `raft_engine`**. |
|
||||
| `wal.recovery_parallelism` | Integer | `2` | Parallelism during WAL recovery. |
|
||||
| `wal.recovery_parallelism` | Integer | Auto | Parallelism during WAL recovery. Defaults to the number of CPU cores; this example sets it to 2. |
|
||||
| `wal.broker_endpoints` | Array | -- | The Kafka broker endpoints.<br/>**It's only used when the provider is `kafka`**. |
|
||||
| `wal.connect_timeout` | String | `3s` | The connect timeout for kafka client.<br/>**It's only used when the provider is `kafka`**. |
|
||||
| `wal.timeout` | String | `5s` | The total request timeout for kafka client.<br/>**It's only used when the provider is `kafka`**. |
|
||||
@@ -607,7 +612,7 @@
|
||||
| `wal.on_corrupted_segment` | String | `skip` | What a read does with a segment that still does not decode after a second fetch, because its checksum does not match or its content disagrees with its footer entry.<br/>- `skip`: the segment is skipped and recorded as a WAL hole of its region, a metric is incremented and a warning is logged; the other regions of the object are unaffected (the default).<br/>- `fail`: the read fails, so the region does not open.<br/>**It's only used when the provider is `experimental_object_store`**. |
|
||||
| `query` | -- | -- | The query engine options. |
|
||||
| `query.parallelism` | Integer | `0` | Parallelism of the query engine.<br/>Default to 0, which means the number of CPU cores. |
|
||||
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit (unbounded, default behavior).<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy:<br/>- "greedy" (default): first-come-first-served allocation; preserves current behavior.<br/>- "fair": divides available memory among spillable operators and may spill earlier.<br/>Only effective when `memory_pool_size` is bounded (>0). |
|
||||
| `query.experimental_spill_mode` | String | `default` | Spill mode:<br/>- "default": preserve DataFusion built-in OS temp directory (default).<br/>- "custom": explicitly configure spill path, quota, and compression.<br/>- "disabled": explicitly disable disk spilling.<br/>Set this to "custom" before using the path/quota/compression keys below. |
|
||||
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
|
||||
@@ -640,7 +645,7 @@
|
||||
| `storage.http_client.skip_ssl_validation` | Bool | `false` | To skip the ssl verification<br/>**Security Notice**: Setting `skip_ssl_validation = true` disables certificate verification, making connections vulnerable to man-in-the-middle attacks. Only use this in development or trusted private networks. |
|
||||
| `[[region_engine]]` | -- | -- | The region engine options. You can configure multiple region engines.<br/>Each engine type (mito, file, metric) may appear only once; duplicates cause startup to fail. |
|
||||
| `region_engine.mito` | -- | -- | The Mito engine options. |
|
||||
| `region_engine.mito.num_workers` | Integer | `8` | Number of region workers. |
|
||||
| `region_engine.mito.num_workers` | Integer | Auto | Number of region workers. Defaults to half of the available CPU cores, rounded up. |
|
||||
| `region_engine.mito.worker_channel_size` | Integer | `128` | Request channel size of each worker. |
|
||||
| `region_engine.mito.worker_request_batch_size` | Integer | `64` | Max batch size for a worker to handle requests. |
|
||||
| `region_engine.mito.manifest_checkpoint_distance` | Integer | `10` | Number of meta action updated to trigger a new checkpoint for the manifest. |
|
||||
@@ -757,9 +762,9 @@
|
||||
| `flow.batching_mode.frontend_tls.client_cert_path` | String | Unset | Client Certificate file path. |
|
||||
| `flow.batching_mode.frontend_tls.client_key_path` | String | Unset | Client Private key file path. |
|
||||
| `grpc` | -- | -- | The gRPC server options. |
|
||||
| `grpc.bind_addr` | String | `127.0.0.1:6800` | The address to bind the gRPC server. |
|
||||
| `grpc.server_addr` | String | `127.0.0.1:6800` | The address advertised to the metasrv,<br/>and used for connections from outside the host |
|
||||
| `grpc.runtime_size` | Integer | `2` | The number of server worker threads. |
|
||||
| `grpc.bind_addr` | String | 127.0.0.1:3004 | The address to bind the gRPC server. Defaults to `127.0.0.1:3004`; this example sets it to `127.0.0.1:6800`. |
|
||||
| `grpc.server_addr` | String | Unset | The address advertised to the metasrv, and used for connections from outside the host.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>on the host, with the same port number as the one specified in `grpc.bind_addr`. |
|
||||
| `grpc.runtime_size` | Integer | 8 | The number of server worker threads. Defaults to 8; this example sets it to 2. |
|
||||
| `grpc.max_recv_message_size` | String | `512MB` | The maximum receive message size for gRPC server. |
|
||||
| `grpc.max_send_message_size` | String | `512MB` | The maximum send message size for gRPC server. |
|
||||
| `http` | -- | -- | The HTTP server options. |
|
||||
@@ -793,7 +798,7 @@
|
||||
| `tracing.tokio_console_addr` | String | Unset | The tokio console address. |
|
||||
| `query` | -- | -- | -- |
|
||||
| `query.parallelism` | Integer | `1` | Parallelism of the query engine for query sent by flownode.<br/>Default to 1, so it won't use too much cpu or memory |
|
||||
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "1GB", "2GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit (unbounded, default behavior).<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "1GB", "2GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>NOTE: This does NOT limit memory used by table scans. |
|
||||
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy:<br/>- "greedy" (default): first-come-first-served allocation; preserves current behavior.<br/>- "fair": divides available memory among spillable operators and may spill earlier.<br/>Only effective when `memory_pool_size` is bounded (>0). |
|
||||
| `query.experimental_spill_mode` | String | `default` | Spill mode:<br/>- "default": preserve DataFusion built-in OS temp directory (default).<br/>- "custom": explicitly configure spill path, quota, and compression.<br/>- "disabled": explicitly disable disk spilling.<br/>Set this to "custom" before using the path/quota/compression keys below. |
|
||||
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
|
||||
|
||||
@@ -45,6 +45,7 @@ bind_addr = "127.0.0.1:3001"
|
||||
## The address advertised to the metasrv, and used for connections from outside the host.
|
||||
## If left empty or unset, the server will automatically use the IP address of the first network interface
|
||||
## on the host, with the same port number as the one specified in `grpc.bind_addr`.
|
||||
## @toml2docs:none-default
|
||||
server_addr = "127.0.0.1:3001"
|
||||
## The number of server worker threads.
|
||||
runtime_size = 8
|
||||
@@ -57,7 +58,7 @@ max_send_message_size = "512MB"
|
||||
## - `transport`: only enable gRPC transport compression (zstd)
|
||||
## - `arrow_ipc`: only enable Arrow IPC compression (lz4)
|
||||
## - `all`: enable all compression.
|
||||
## Default to `none`
|
||||
## Default to `arrow_ipc`.
|
||||
flight_compression = "arrow_ipc"
|
||||
|
||||
## gRPC server TLS options, see `mysql.tls` section.
|
||||
@@ -80,16 +81,24 @@ watch = false
|
||||
## The runtime options.
|
||||
#+ [runtime]
|
||||
## The number of threads to execute the runtime for global read operations.
|
||||
## Defaults to max(num_cpus, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ global_rt_size = 8
|
||||
## The number of threads to execute compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_size = 4
|
||||
## The maximum number of blocking threads for compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_max_blocking_threads = 4
|
||||
## The number of threads to execute datanode query operations.
|
||||
## Defaults to max(num_cpus - 1, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ query_rt_size = 7
|
||||
## The number of threads to execute datanode ingestion operations.
|
||||
## Defaults to max(num_cpus, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ ingest_rt_size = 8
|
||||
|
||||
## Experimental weighted, work-conserving query/write task scheduler.
|
||||
@@ -176,7 +185,8 @@ prefill_log_files = false
|
||||
## **It's only used when the provider is `raft_engine`**.
|
||||
sync_period = "5s"
|
||||
|
||||
## Parallelism during WAL recovery.
|
||||
## Parallelism during WAL recovery. Defaults to the number of CPU cores; this example sets it to 2.
|
||||
## @toml2docs:none-default="Auto"
|
||||
recovery_parallelism = 2
|
||||
|
||||
## The Kafka broker endpoints.
|
||||
@@ -330,9 +340,10 @@ parallelism = 0
|
||||
|
||||
## Memory pool size for query execution operators (aggregation, sorting, join).
|
||||
## Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").
|
||||
## Setting it to 0 disables the limit (unbounded, default behavior).
|
||||
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
|
||||
## When this limit is reached, queries will fail with ResourceExhausted error.
|
||||
## NOTE: This does NOT limit memory used by table scans.
|
||||
## @toml2docs:none-default="0"
|
||||
memory_pool_size = "50%"
|
||||
|
||||
## Experimental memory pool allocation policy:
|
||||
@@ -503,7 +514,8 @@ skip_ssl_validation = false
|
||||
## The Mito engine options.
|
||||
[region_engine.mito]
|
||||
|
||||
## Number of region workers.
|
||||
## Number of region workers. Defaults to half of the available CPU cores, rounded up.
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ num_workers = 8
|
||||
|
||||
## Request channel size of each worker.
|
||||
|
||||
@@ -44,12 +44,16 @@ node_id = 14
|
||||
|
||||
## The gRPC server options.
|
||||
[grpc]
|
||||
## The address to bind the gRPC server.
|
||||
## The address to bind the gRPC server. Defaults to `127.0.0.1:3004`; this example sets it to `127.0.0.1:6800`.
|
||||
## @toml2docs:none-default="127.0.0.1:3004"
|
||||
bind_addr = "127.0.0.1:6800"
|
||||
## The address advertised to the metasrv,
|
||||
## and used for connections from outside the host
|
||||
## The address advertised to the metasrv, and used for connections from outside the host.
|
||||
## If left empty or unset, the server will automatically use the IP address of the first network interface
|
||||
## on the host, with the same port number as the one specified in `grpc.bind_addr`.
|
||||
## @toml2docs:none-default
|
||||
server_addr = "127.0.0.1:6800"
|
||||
## The number of server worker threads.
|
||||
## The number of server worker threads. Defaults to 8; this example sets it to 2.
|
||||
## @toml2docs:none-default="8"
|
||||
runtime_size = 2
|
||||
## The maximum receive message size for gRPC server.
|
||||
max_recv_message_size = "512MB"
|
||||
@@ -153,9 +157,10 @@ parallelism = 1
|
||||
|
||||
## Memory pool size for query execution operators (aggregation, sorting, join).
|
||||
## Supports absolute size (e.g., "1GB", "2GB") or percentage of system memory (e.g., "20%").
|
||||
## Setting it to 0 disables the limit (unbounded, default behavior).
|
||||
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
|
||||
## When this limit is reached, queries will fail with ResourceExhausted error.
|
||||
## NOTE: This does NOT limit memory used by table scans.
|
||||
## @toml2docs:none-default="0"
|
||||
memory_pool_size = "50%"
|
||||
|
||||
## Experimental memory pool allocation policy:
|
||||
|
||||
@@ -20,8 +20,8 @@ default_column_prefix = "greptime"
|
||||
## Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",
|
||||
## "mysql_native_password:<hex_sha1_sha1_password>",
|
||||
## "pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"
|
||||
## "pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but cannot authenticate over MySQL's
|
||||
## native password handshake; a MySQL client must send the password in cleartext for such users.
|
||||
## "pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but such users cannot log in over MySQL:
|
||||
## `static_user_provider` and `watch_file_user_provider` only negotiate `mysql_native_password`, which cannot verify these formats.
|
||||
## "mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.
|
||||
## PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or
|
||||
## "mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.
|
||||
@@ -33,22 +33,26 @@ default_column_prefix = "greptime"
|
||||
|
||||
## Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).
|
||||
## Set to 0 to disable the limit. Default: "0" (unlimited)
|
||||
## @toml2docs:none-default
|
||||
## @toml2docs:none-default="0"
|
||||
#+ max_in_flight_write_bytes = "1GB"
|
||||
|
||||
## Policy when write bytes quota is exhausted.
|
||||
## Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail"
|
||||
## @toml2docs:none-default
|
||||
#+ write_bytes_exhausted_policy = "wait"
|
||||
|
||||
## The runtime options.
|
||||
#+ [runtime]
|
||||
## The number of threads to execute the runtime for global read operations.
|
||||
## Defaults to max(num_cpus, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ global_rt_size = 8
|
||||
## The number of threads to execute compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_size = 4
|
||||
## The maximum number of blocking threads for compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_max_blocking_threads = 4
|
||||
|
||||
## The HTTP server options.
|
||||
@@ -86,6 +90,7 @@ bind_addr = "127.0.0.1:4001"
|
||||
## The address advertised to the metasrv, and used for connections from outside the host.
|
||||
## If left empty or unset, the server will automatically use the IP address of the first network interface
|
||||
## on the host, with the same port number as the one specified in `grpc.bind_addr`.
|
||||
## @toml2docs:none-default
|
||||
server_addr = "127.0.0.1:4001"
|
||||
## The number of server worker threads.
|
||||
runtime_size = 8
|
||||
@@ -94,7 +99,7 @@ runtime_size = 8
|
||||
## - `transport`: only enable gRPC transport compression (zstd)
|
||||
## - `arrow_ipc`: only enable Arrow IPC compression (lz4)
|
||||
## - `all`: enable all compression.
|
||||
## Default to `none`
|
||||
## Default to `arrow_ipc`.
|
||||
flight_compression = "arrow_ipc"
|
||||
## The maximum connection age for gRPC connection.
|
||||
## The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.
|
||||
@@ -120,12 +125,15 @@ key_path = ""
|
||||
watch = false
|
||||
|
||||
## The internal gRPC server options. Internal gRPC port for nodes inside cluster to access frontend.
|
||||
## The internal gRPC server starts only when this section is set.
|
||||
[internal_grpc]
|
||||
## The address to bind the gRPC server.
|
||||
## If unset, it falls back to `127.0.0.1:4001`, the same address as `grpc.bind_addr`.
|
||||
## @toml2docs:none-default
|
||||
bind_addr = "127.0.0.1:4010"
|
||||
## The address advertised to the metasrv, and used for connections from outside the host.
|
||||
## If left empty or unset, the server will automatically use the IP address of the first network interface
|
||||
## on the host, with the same port number as the one specified in `internal_grpc.bind_addr`.
|
||||
## Unlike `grpc.server_addr`, it is not detected automatically; set it explicitly.
|
||||
## @toml2docs:none-default
|
||||
server_addr = "127.0.0.1:4010"
|
||||
## The number of server worker threads.
|
||||
runtime_size = 8
|
||||
@@ -134,7 +142,7 @@ runtime_size = 8
|
||||
## - `transport`: only enable gRPC transport compression (zstd)
|
||||
## - `arrow_ipc`: only enable Arrow IPC compression (lz4)
|
||||
## - `all`: enable all compression.
|
||||
## Default to `none`
|
||||
## Default to `arrow_ipc`.
|
||||
flight_compression = "arrow_ipc"
|
||||
|
||||
## internal gRPC server TLS options, see `mysql.tls` section.
|
||||
@@ -352,9 +360,10 @@ allow_query_fallback = false
|
||||
|
||||
## Memory pool size for query execution operators (aggregation, sorting, join).
|
||||
## Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%").
|
||||
## Setting it to 0 disables the limit (unbounded, default behavior).
|
||||
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
|
||||
## When this limit is reached, queries will fail with ResourceExhausted error.
|
||||
## NOTE: This does NOT limit memory used by table scans (only applies to datanodes).
|
||||
## @toml2docs:none-default="0"
|
||||
memory_pool_size = "50%"
|
||||
|
||||
## Experimental memory pool allocation policy:
|
||||
@@ -381,10 +390,24 @@ memory_pool_size = "50%"
|
||||
#+ experimental_spill_compression = "uncompressed"
|
||||
# --- End spill-to-disk ---
|
||||
|
||||
## Retry retryable distributed write failures.
|
||||
## Backoff grows exponentially, with jitter to spread concurrent retries.
|
||||
[write_retry]
|
||||
## Maximum retries after the initial write attempt. Set to 0 to disable retries.
|
||||
max_retries = 3
|
||||
## Initial backoff before jitter is applied. Must be positive.
|
||||
initial_backoff = "1500ms"
|
||||
## Maximum backoff before jitter is applied. Must be at least initial_backoff.
|
||||
max_backoff = "10s"
|
||||
## Backoff multiplier. Must be finite and at least 1.
|
||||
backoff_multiplier = 2.0
|
||||
|
||||
## Datanode options.
|
||||
[datanode]
|
||||
## Datanode client options.
|
||||
[datanode.client]
|
||||
## Connect server timeout. Defaults to `1s`; this example sets it to `10s`.
|
||||
## @toml2docs:none-default="1s"
|
||||
connect_timeout = "10s"
|
||||
tcp_nodelay = true
|
||||
## The maximum receive message size for the gRPC client.
|
||||
@@ -489,4 +512,5 @@ ttl = "90d"
|
||||
## Event types to record. Current available event type: `admin_function`.
|
||||
## When omitted, all current and future event types are recorded.
|
||||
## Set to an empty array to disable event recording.
|
||||
## @toml2docs:none-default="All"
|
||||
#+ event_types = ["admin_function"]
|
||||
|
||||
@@ -35,6 +35,8 @@ meta_table_name = "greptime_metakv"
|
||||
## When PostgreSQL public schema is not writable (e.g., PostgreSQL 15+ with restricted public),
|
||||
## set this to a writable schema. GreptimeDB will use `meta_schema_name`.`meta_table_name`.
|
||||
## **Only used when backend is `postgres_store`.**
|
||||
## Unset by default; this example sets it to `greptime_schema`.
|
||||
## @toml2docs:none-default
|
||||
meta_schema_name = "greptime_schema"
|
||||
|
||||
## Automatically create PostgreSQL schema if it doesn't exist.
|
||||
@@ -88,11 +90,16 @@ node_max_idle_time = "24hours"
|
||||
## The runtime options.
|
||||
#+ [runtime]
|
||||
## The number of threads to execute the runtime for global read operations.
|
||||
## Defaults to max(num_cpus, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ global_rt_size = 8
|
||||
## The number of threads to execute compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_size = 4
|
||||
## The maximum number of blocking threads for compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_max_blocking_threads = 4
|
||||
|
||||
## TLS configuration for kv store backend (applicable for etcd, PostgreSQL, and MySQL backends)
|
||||
@@ -140,6 +147,7 @@ bind_addr = "127.0.0.1:3002"
|
||||
## The communication server address for the frontend and datanode to connect to metasrv.
|
||||
## If left empty or unset, the server will automatically use the IP address of the first network interface
|
||||
## on the host, with the same port number as the one specified in `grpc.bind_addr`.
|
||||
## @toml2docs:none-default
|
||||
server_addr = "127.0.0.1:3002"
|
||||
## The number of server worker threads.
|
||||
runtime_size = 8
|
||||
@@ -206,7 +214,8 @@ acceptable_heartbeat_pause = "10000ms"
|
||||
## Operation timeout.
|
||||
timeout = "10s"
|
||||
|
||||
## Connect server timeout.
|
||||
## Connect server timeout. Defaults to `1s`; this example sets it to `10s`.
|
||||
## @toml2docs:none-default="1s"
|
||||
connect_timeout = "10s"
|
||||
|
||||
## `TCP_NODELAY` option for accepted connections.
|
||||
@@ -332,6 +341,7 @@ ttl = "90d"
|
||||
## `reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.
|
||||
## When omitted, all current and future event types are recorded.
|
||||
## Set to an empty array to disable event recording.
|
||||
## @toml2docs:none-default="All"
|
||||
#+ event_types = ["region_migration"]
|
||||
|
||||
## Configuration options for the stats persistence.
|
||||
|
||||
@@ -20,8 +20,8 @@ default_column_prefix = "greptime"
|
||||
## Password verifier formats: "plain:<password>", "pbkdf2_sha256:<iterations>:<hex_salt>:<hex_hash>",
|
||||
## "mysql_native_password:<hex_sha1_sha1_password>",
|
||||
## "pg_scram_sha256:<iterations>:<hex_salt>:<hex_stored_key>:<hex_server_key>"
|
||||
## "pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but cannot authenticate over MySQL's
|
||||
## native password handshake; a MySQL client must send the password in cleartext for such users.
|
||||
## "pbkdf2_sha256" and "pg_scram_sha256" protect passwords at rest, but such users cannot log in over MySQL:
|
||||
## `static_user_provider` and `watch_file_user_provider` only negotiate `mysql_native_password`, which cannot verify these formats.
|
||||
## "mysql_native_password" is MySQL-specific and cannot authenticate over PostgreSQL at all.
|
||||
## PostgreSQL SCRAM only covers "plain" and "pg_scram_sha256" users; if any user is "pbkdf2_sha256" or
|
||||
## "mysql_native_password", PostgreSQL falls back to cleartext password auth for every user.
|
||||
@@ -33,12 +33,11 @@ default_column_prefix = "greptime"
|
||||
|
||||
## Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).
|
||||
## Set to 0 to disable the limit. Default: "0" (unlimited)
|
||||
## @toml2docs:none-default
|
||||
## @toml2docs:none-default="0"
|
||||
#+ max_in_flight_write_bytes = "1GB"
|
||||
|
||||
## Policy when write bytes quota is exhausted.
|
||||
## Options: "wait" (default, 10s timeout), "wait(<duration>)" (e.g., "wait(30s)"), "fail"
|
||||
## @toml2docs:none-default
|
||||
#+ write_bytes_exhausted_policy = "wait"
|
||||
|
||||
## Initialize all regions in the background during the startup.
|
||||
@@ -48,20 +47,22 @@ init_regions_in_background = false
|
||||
## Parallelism of initializing regions.
|
||||
init_regions_parallelism = 16
|
||||
|
||||
## The maximum concurrent queries allowed to be executed. Zero means unlimited.
|
||||
max_concurrent_queries = 0
|
||||
|
||||
## Enable telemetry to collect anonymous usage data. Enabled by default.
|
||||
#+ enable_telemetry = true
|
||||
|
||||
## The runtime options.
|
||||
#+ [runtime]
|
||||
## The number of threads to execute the runtime for global read operations.
|
||||
## Defaults to max(num_cpus, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ global_rt_size = 8
|
||||
## The number of threads to execute compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_size = 4
|
||||
## The maximum number of blocking threads for compact operations.
|
||||
## Defaults to max(num_cpus / 2, 2).
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ compact_rt_max_blocking_threads = 4
|
||||
|
||||
## Experimental weighted, work-conserving query/write task scheduler.
|
||||
@@ -339,7 +340,8 @@ prefill_log_files = false
|
||||
## **It's only used when the provider is `raft_engine`**.
|
||||
sync_period = "5s"
|
||||
|
||||
## Parallelism during WAL recovery.
|
||||
## Parallelism during WAL recovery. Defaults to the number of CPU cores; this example sets it to 2.
|
||||
## @toml2docs:none-default="Auto"
|
||||
recovery_parallelism = 2
|
||||
|
||||
## The Kafka broker endpoints.
|
||||
@@ -533,9 +535,10 @@ parallelism = 0
|
||||
|
||||
## Memory pool size for query execution operators (aggregation, sorting, join).
|
||||
## Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").
|
||||
## Setting it to 0 disables the limit (unbounded, default behavior).
|
||||
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
|
||||
## When this limit is reached, queries will fail with ResourceExhausted error.
|
||||
## NOTE: This does NOT limit memory used by table scans.
|
||||
## @toml2docs:none-default="0"
|
||||
memory_pool_size = "50%"
|
||||
|
||||
## Experimental memory pool allocation policy:
|
||||
@@ -715,7 +718,8 @@ skip_ssl_validation = false
|
||||
## The Mito engine options.
|
||||
[region_engine.mito]
|
||||
|
||||
## Number of region workers.
|
||||
## Number of region workers. Defaults to half of the available CPU cores, rounded up.
|
||||
## @toml2docs:none-default="Auto"
|
||||
#+ num_workers = 8
|
||||
|
||||
## Request channel size of each worker.
|
||||
@@ -1057,19 +1061,21 @@ default_ratio = 1.0
|
||||
## The slow query log options.
|
||||
[slow_query]
|
||||
## Whether to enable slow query log.
|
||||
#+ enable = false
|
||||
enable = true
|
||||
|
||||
## The record type of slow queries. It can be `system_table` or `log`.
|
||||
## @toml2docs:none-default
|
||||
#+ record_type = "system_table"
|
||||
## If `system_table` is selected, the slow queries will be recorded in a system table `greptime_private.slow_queries`.
|
||||
## If `log` is selected, the slow queries will be logged in a log file `greptimedb-slow-queries.*`.
|
||||
record_type = "system_table"
|
||||
|
||||
## The threshold of slow query.
|
||||
## @toml2docs:none-default
|
||||
#+ threshold = "10s"
|
||||
## The threshold of slow query. It can be human readable time string, for example: `10s`, `100ms`, `1s`.
|
||||
threshold = "30s"
|
||||
|
||||
## The sampling ratio of slow query log. The value should be in the range of (0, 1].
|
||||
## @toml2docs:none-default
|
||||
#+ sample_ratio = 1.0
|
||||
## The sampling ratio of slow query log. The value should be in the range of (0, 1]. For example, `0.1` means 10% of the slow queries will be logged and `1.0` means all slow queries will be logged.
|
||||
sample_ratio = 1.0
|
||||
|
||||
## The TTL of the `slow_queries` system table. Default is `90d` when `record_type` is `system_table`.
|
||||
ttl = "90d"
|
||||
|
||||
## The tracing options. Only effect when compiled with `tokio-console` feature.
|
||||
#+ [tracing]
|
||||
@@ -1094,6 +1100,7 @@ ttl = "90d"
|
||||
## `reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.
|
||||
## When omitted, all current and future event types are recorded.
|
||||
## Set to an empty array to disable event recording.
|
||||
## @toml2docs:none-default="All"
|
||||
#+ event_types = ["create_database"]
|
||||
|
||||
## The memory options.
|
||||
|
||||
Generated
+9
-9
@@ -8,11 +8,11 @@
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785314864,
|
||||
"narHash": "sha256-imz9J5iMNersDeQ1ymenFg35N6C2R3MCaGg2i0XCIrE=",
|
||||
"lastModified": 1790642863,
|
||||
"narHash": "sha256-JIwE6EnebvxW7QFFEitWHuZDXwN6Qcf6ZC8DIvYW3y8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "594418b2c9ea0731bee6988236ef5bfd97e81dca",
|
||||
"rev": "a8f386c482e78e9a48be5abecdc17a9b098a95a1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785133411,
|
||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||
"lastModified": 1790533781,
|
||||
"narHash": "sha256-F+b8u43BVF5DlM0pz7JQSbtbwKy5PrHIT5zQUekXsD0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||
"rev": "cf5e76507c6e23b59f7e0ffcc7baa2a39ddd8442",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -65,11 +65,11 @@
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1785261141,
|
||||
"narHash": "sha256-sA+DHPejWD68mLA7gtTiHGd5T41F6W+NKf0g+ibFCW4=",
|
||||
"lastModified": 1790532271,
|
||||
"narHash": "sha256-j8y/AO3AMDF9p+11aIDhnA21JnNj5PjvcejTI43vI8Q=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "bec66814323579659ffd77c909b3d963af118ece",
|
||||
"rev": "03fcb77246f2568adb0e9b2fa60d19c6cc1686f4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
lib = nixpkgs.lib;
|
||||
rustToolchain = fenix.packages.${system}.fromToolchainName {
|
||||
name = (lib.importTOML ./rust-toolchain.toml).toolchain.channel;
|
||||
sha256 = "sha256-h+t2xTBz5yt2YIO+1VMIIGlCU7gyp2LYOFvaV1nwOXU=";
|
||||
sha256 = "sha256-p8h3Sl/YRByZfZTAKXdsvF6xEenXKrXSVvpphmZENH4=";
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.96.1"
|
||||
channel = "1.98.1"
|
||||
|
||||
@@ -192,6 +192,10 @@ impl KvBackend for CachedKvBackend {
|
||||
}
|
||||
}
|
||||
|
||||
if miss_keys.is_empty() {
|
||||
return Ok(BatchGetResponse { kvs });
|
||||
}
|
||||
|
||||
let batch_get_req = BatchGetRequest::new().with_keys(miss_keys.clone());
|
||||
|
||||
let pre_version = self.version();
|
||||
@@ -503,6 +507,7 @@ mod tests {
|
||||
pub struct SimpleKvBackend {
|
||||
inner_map: DashMap<Vec<u8>, Vec<u8>>,
|
||||
get_execute_times: Arc<AtomicU32>,
|
||||
batch_get_execute_times: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl TxnService for SimpleKvBackend {
|
||||
@@ -520,6 +525,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn batch_get(&self, req: BatchGetRequest) -> Result<BatchGetResponse, Self::Error> {
|
||||
self.batch_get_execute_times.fetch_add(1, Ordering::SeqCst);
|
||||
let mut kvs = Vec::with_capacity(req.keys.len());
|
||||
for key in req.keys.iter() {
|
||||
if let Some(kv) = self.get(key).await? {
|
||||
@@ -571,6 +577,7 @@ mod tests {
|
||||
async fn test_cached_kv_backend() {
|
||||
let simple_kv = Arc::new(SimpleKvBackend::default());
|
||||
let get_execute_times = simple_kv.get_execute_times.clone();
|
||||
let batch_get_execute_times = simple_kv.batch_get_execute_times.clone();
|
||||
let cached_kv = CachedKvBackend::wrap(simple_kv);
|
||||
|
||||
add_some_vals(&cached_kv).await;
|
||||
@@ -582,9 +589,11 @@ mod tests {
|
||||
assert_eq!(get_execute_times.load(Ordering::SeqCst), 0);
|
||||
|
||||
for _ in 0..10 {
|
||||
let _batch_get_resp = cached_kv.batch_get(batch_get_req.clone()).await.unwrap();
|
||||
let batch_get_resp = cached_kv.batch_get(batch_get_req.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(batch_get_resp.kvs.len(), 2);
|
||||
assert_eq!(get_execute_times.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(batch_get_execute_times.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
let batch_get_req = BatchGetRequest {
|
||||
@@ -594,14 +603,29 @@ mod tests {
|
||||
let _batch_get_resp = cached_kv.batch_get(batch_get_req.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(get_execute_times.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(batch_get_execute_times.load(Ordering::SeqCst), 2);
|
||||
|
||||
for _ in 0..10 {
|
||||
let _batch_get_resp = cached_kv.batch_get(batch_get_req.clone()).await.unwrap();
|
||||
let batch_get_resp = cached_kv.batch_get(batch_get_req.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(batch_get_resp.kvs.len(), 3);
|
||||
assert_eq!(get_execute_times.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(batch_get_execute_times.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cached_kv_backend_empty_batch_get() {
|
||||
let simple_kv = Arc::new(SimpleKvBackend::default());
|
||||
let cached_kv = CachedKvBackend::wrap(simple_kv.clone());
|
||||
|
||||
let response = cached_kv.batch_get(BatchGetRequest::new()).await.unwrap();
|
||||
|
||||
assert!(response.kvs.is_empty());
|
||||
assert_eq!(simple_kv.batch_get_execute_times.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(simple_kv.get_execute_times.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cached_kv_backend_rejects_writes_with_read_only_inner() {
|
||||
let inner = Arc::new(MemoryKvBackend::<common_meta::error::Error>::new());
|
||||
|
||||
@@ -263,6 +263,51 @@ impl InformationSchemaPartitionsBuilder {
|
||||
|
||||
let predicates = Predicates::from_scan_request(&request);
|
||||
|
||||
// Fast path: when the predicates pin both the schema and the table name
|
||||
// with equality, resolve the table directly instead of enumerating all
|
||||
// tables in all schemas and fetching partition metadata for each of them.
|
||||
if let (Some(schema_name), Some(table_name)) = (
|
||||
predicates
|
||||
.find_eq_value(TABLE_SCHEMA)
|
||||
.and_then(Value::as_string),
|
||||
predicates
|
||||
.find_eq_value(TABLE_NAME)
|
||||
.and_then(Value::as_string),
|
||||
) {
|
||||
if let Some(table) = catalog_manager
|
||||
.table(&catalog_name, &schema_name, &table_name, None)
|
||||
.await?
|
||||
{
|
||||
let table_info = table.table_info();
|
||||
|
||||
// The enumeration path below skips temporary tables, keep the
|
||||
// behavior identical.
|
||||
if table_info.table_type != TableType::Temporary {
|
||||
let mut table_partitions = partition_manager
|
||||
.batch_find_table_partitions(&[table_info.table_id()])
|
||||
.await
|
||||
.context(FindPartitionsSnafu)?;
|
||||
|
||||
let partitions = table_partitions
|
||||
.remove(&table_info.table_id())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.add_partitions(
|
||||
&predicates,
|
||||
&table_info,
|
||||
&catalog_name,
|
||||
&schema_name,
|
||||
&table_info.name,
|
||||
&partitions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The pinned table fully determines the result: the table above was
|
||||
// found (or not) unambiguously, so no other table can contribute rows.
|
||||
return self.finish();
|
||||
}
|
||||
|
||||
for schema_name in catalog_manager.schema_names(&catalog_name, None).await? {
|
||||
let table_info_stream = catalog_manager
|
||||
.tables(&catalog_name, &schema_name, None)
|
||||
@@ -430,3 +475,253 @@ impl DfPartitionStream for InformationSchemaPartitions {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use api::v1::meta::Peer;
|
||||
use cache::{build_fundamental_cache_registry, with_default_composite_cache_registry};
|
||||
use common_catalog::consts::{DEFAULT_CATALOG_NAME, INFORMATION_SCHEMA_NAME};
|
||||
use common_meta::cache::{CacheRegistryBuilder, LayeredCacheRegistryBuilder};
|
||||
use common_meta::key::schema_name::SchemaNameKey;
|
||||
use common_meta::key::table_route::TableRouteValue;
|
||||
use common_meta::kv_backend::memory::MemoryKvBackend;
|
||||
use common_meta::rpc::router::{Region, RegionRoute};
|
||||
use common_meta::wal_provider::RegionWalOptions;
|
||||
use datafusion::logical_expr::{BinaryExpr, Expr, Operator, col, lit};
|
||||
use futures::TryStreamExt;
|
||||
use store_api::storage::RegionId;
|
||||
|
||||
use super::*;
|
||||
use crate::information_schema::NoopInformationExtension;
|
||||
use crate::kvbackend::KvBackendCatalogManagerBuilder;
|
||||
|
||||
const SCHEMA_1: &str = "partitions_schema_1";
|
||||
const SCHEMA_2: &str = "partitions_schema_2";
|
||||
const PARTITIONED_TABLE: &str = "partitioned_table";
|
||||
const MISSING_TABLE: &str = "missing_table";
|
||||
const TABLE_ID_1: TableId = 1;
|
||||
const TABLE_ID_2: TableId = 2;
|
||||
const REGION_NUMBER_1: u32 = 1;
|
||||
const REGION_NUMBER_2: u32 = 2;
|
||||
const REGION_NUMBER_7: u32 = 7;
|
||||
|
||||
fn kv_catalog_manager() -> Arc<KvBackendCatalogManager> {
|
||||
let backend = Arc::new(MemoryKvBackend::default());
|
||||
let layered_cache_builder = LayeredCacheRegistryBuilder::default()
|
||||
.add_cache_registry(CacheRegistryBuilder::default().build());
|
||||
let fundamental_cache_registry = build_fundamental_cache_registry(backend.clone());
|
||||
let layered_cache_registry = Arc::new(
|
||||
with_default_composite_cache_registry(
|
||||
layered_cache_builder.add_cache_registry(fundamental_cache_registry),
|
||||
)
|
||||
.unwrap()
|
||||
.build(),
|
||||
);
|
||||
|
||||
KvBackendCatalogManagerBuilder::new(
|
||||
Arc::new(NoopInformationExtension),
|
||||
backend,
|
||||
layered_cache_registry,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn region_route(table_id: TableId, region_number: u32) -> RegionRoute {
|
||||
RegionRoute {
|
||||
region: Region {
|
||||
id: RegionId::new(table_id, region_number),
|
||||
..Default::default()
|
||||
},
|
||||
leader_peer: Some(Peer {
|
||||
id: 1,
|
||||
addr: "127.0.0.1:3001".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn table_info(schema: &str, table: &str, table_id: TableId) -> TableInfo {
|
||||
// The catalog defaults to the default catalog name.
|
||||
let mut table_info =
|
||||
common_meta::key::test_utils::new_test_table_info_with_name(table_id, table);
|
||||
table_info.schema_name = schema.to_string();
|
||||
table_info
|
||||
}
|
||||
|
||||
/// Creates a catalog with two partitioned tables named `PARTITIONED_TABLE`
|
||||
/// in two different schemas:
|
||||
///
|
||||
/// - `SCHEMA_1.PARTITIONED_TABLE` has two regions.
|
||||
/// - `SCHEMA_2.PARTITIONED_TABLE` has one region.
|
||||
async fn setup_catalog() -> Arc<KvBackendCatalogManager> {
|
||||
let manager = kv_catalog_manager();
|
||||
let metadata = manager.table_metadata_manager_ref().clone();
|
||||
|
||||
for schema in [SCHEMA_1, SCHEMA_2] {
|
||||
metadata
|
||||
.schema_manager()
|
||||
.create(SchemaNameKey::new(DEFAULT_CATALOG_NAME, schema), None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for (table_id, schema, region_numbers) in [
|
||||
(TABLE_ID_1, SCHEMA_1, vec![REGION_NUMBER_1, REGION_NUMBER_2]),
|
||||
(TABLE_ID_2, SCHEMA_2, vec![REGION_NUMBER_7]),
|
||||
] {
|
||||
let routes = region_numbers
|
||||
.into_iter()
|
||||
.map(|region_number| region_route(table_id, region_number))
|
||||
.collect();
|
||||
metadata
|
||||
.create_table_metadata(
|
||||
table_info(schema, PARTITIONED_TABLE, table_id),
|
||||
TableRouteValue::physical(routes),
|
||||
RegionWalOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// Scans `information_schema.partitions` and returns the total row count
|
||||
/// and the pretty printed batches.
|
||||
async fn scan_partitions(
|
||||
manager: &Arc<dyn CatalogManager>,
|
||||
filters: Vec<Expr>,
|
||||
) -> (usize, String) {
|
||||
let table = manager
|
||||
.table(
|
||||
DEFAULT_CATALOG_NAME,
|
||||
INFORMATION_SCHEMA_NAME,
|
||||
PARTITIONS,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stream = table
|
||||
.scan_to_stream(ScanRequest {
|
||||
filters,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let batches = stream.try_collect::<Vec<_>>().await.unwrap();
|
||||
let rows = batches.iter().map(|batch| batch.num_rows()).sum();
|
||||
let output = batches
|
||||
.iter()
|
||||
.map(|batch| batch.pretty_print())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(rows, output)
|
||||
}
|
||||
|
||||
fn eq_filter(column: &str, value: &str) -> Expr {
|
||||
Expr::BinaryExpr(BinaryExpr::new(
|
||||
Box::new(col(column)),
|
||||
Operator::Eq,
|
||||
Box::new(lit(value)),
|
||||
))
|
||||
}
|
||||
|
||||
fn not_eq_filter(column: &str, value: &str) -> Expr {
|
||||
Expr::BinaryExpr(BinaryExpr::new(
|
||||
Box::new(col(column)),
|
||||
Operator::NotEq,
|
||||
Box::new(lit(value)),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scan_pinned_table_skips_catalog_enumeration() {
|
||||
let manager: Arc<dyn CatalogManager> = setup_catalog().await;
|
||||
|
||||
// No predicates: every partitioned table is listed.
|
||||
let (rows, output) = scan_partitions(&manager, vec![]).await;
|
||||
assert_eq!(3, rows, "{output}");
|
||||
|
||||
// Pins both the schema and the table name, so the scan resolves the
|
||||
// table directly instead of enumerating all catalog entries.
|
||||
let (rows, output) = scan_partitions(
|
||||
&manager,
|
||||
vec![
|
||||
eq_filter(TABLE_SCHEMA, SCHEMA_1),
|
||||
eq_filter(TABLE_NAME, PARTITIONED_TABLE),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(2, rows, "{output}");
|
||||
assert!(output.contains(SCHEMA_1), "{output}");
|
||||
assert!(!output.contains(SCHEMA_2), "{output}");
|
||||
assert!(
|
||||
output.contains(
|
||||
&RegionId::new(TABLE_ID_1, REGION_NUMBER_1)
|
||||
.as_u64()
|
||||
.to_string()
|
||||
),
|
||||
"{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains(
|
||||
&RegionId::new(TABLE_ID_1, REGION_NUMBER_2)
|
||||
.as_u64()
|
||||
.to_string()
|
||||
),
|
||||
"{output}"
|
||||
);
|
||||
|
||||
// The fallback enumeration path must return the identical result. The
|
||||
// inequality on `table_name` doesn't pin a single name, so no direct
|
||||
// lookup happens here.
|
||||
let (fallback_rows, fallback_output) = scan_partitions(
|
||||
&manager,
|
||||
vec![
|
||||
eq_filter(TABLE_SCHEMA, SCHEMA_1),
|
||||
not_eq_filter(TABLE_NAME, MISSING_TABLE),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rows, fallback_rows, "{fallback_output}");
|
||||
assert_eq!(output, fallback_output);
|
||||
|
||||
// Only the table name is pinned: the schema can't be resolved and both
|
||||
// tables of that name are kept.
|
||||
let (rows, output) =
|
||||
scan_partitions(&manager, vec![eq_filter(TABLE_NAME, PARTITIONED_TABLE)]).await;
|
||||
assert_eq!(3, rows, "{output}");
|
||||
assert!(output.contains(SCHEMA_1), "{output}");
|
||||
assert!(output.contains(SCHEMA_2), "{output}");
|
||||
|
||||
// An unknown table in a pinned schema yields no rows.
|
||||
let (rows, output) = scan_partitions(
|
||||
&manager,
|
||||
vec![
|
||||
eq_filter(TABLE_SCHEMA, SCHEMA_1),
|
||||
eq_filter(TABLE_NAME, MISSING_TABLE),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(0, rows, "{output}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scan_equality_under_disjunction_falls_back_to_enumeration() {
|
||||
let manager: Arc<dyn CatalogManager> = setup_catalog().await;
|
||||
|
||||
// `table_schema = SCHEMA_1 OR table_name = PARTITIONED_TABLE` matches
|
||||
// the tables in both schemas: equality nested under a disjunction must
|
||||
// not pin a single table.
|
||||
let filter = Expr::BinaryExpr(BinaryExpr::new(
|
||||
Box::new(eq_filter(TABLE_SCHEMA, SCHEMA_1)),
|
||||
Operator::Or,
|
||||
Box::new(eq_filter(TABLE_NAME, PARTITIONED_TABLE)),
|
||||
));
|
||||
let (rows, output) = scan_partitions(&manager, vec![filter]).await;
|
||||
assert_eq!(3, rows, "{output}");
|
||||
assert!(output.contains(SCHEMA_1), "{output}");
|
||||
assert!(output.contains(SCHEMA_2), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,22 @@ impl Predicate {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the values that constrain `column` to be equal to them at this
|
||||
/// predicate's top-level conjunctions.
|
||||
///
|
||||
/// Constraints under `Or`/`Not` are ignored because they don't guarantee
|
||||
/// that `column` equals a single value.
|
||||
fn collect_eq_values<'a>(&'a self, column: &str, values: &mut Vec<&'a Value>) {
|
||||
match self {
|
||||
Predicate::Eq(c, v) if c == column => values.push(v),
|
||||
Predicate::And(left, right) => {
|
||||
left.collect_eq_values(column, values);
|
||||
right.collect_eq_values(column, values);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform SQL left LIKE right, return `None` if fail to evaluate.
|
||||
@@ -269,6 +285,25 @@ impl Predicates {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the single equality value that constrains `column` in the
|
||||
/// top-level conjunctions of the predicates.
|
||||
///
|
||||
/// Returns `None` if there is no equality constraint on `column`, or if
|
||||
/// the predicates constrain it with multiple distinct values.
|
||||
pub fn find_eq_value(&self, column: &str) -> Option<&Value> {
|
||||
let mut values = Vec::new();
|
||||
for predicate in &self.predicates {
|
||||
predicate.collect_eq_values(column, &mut values);
|
||||
}
|
||||
|
||||
let (first, rest) = values.split_first()?;
|
||||
if rest.iter().all(|v| v == first) {
|
||||
Some(*first)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the predicates with the row.
|
||||
/// returns true when all the predicates are satisfied or can't be evaluated.
|
||||
pub fn eval(&self, row: &[(&str, &Value)]) -> bool {
|
||||
@@ -556,6 +591,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_eq_value() {
|
||||
fn eq(name: &str, value: &str) -> Predicate {
|
||||
Predicate::Eq(name.to_string(), Value::from(value))
|
||||
}
|
||||
|
||||
fn and(left: Predicate, right: Predicate) -> Predicate {
|
||||
Predicate::And(Box::new(left), Box::new(right))
|
||||
}
|
||||
|
||||
fn or(left: Predicate, right: Predicate) -> Predicate {
|
||||
Predicate::Or(Box::new(left), Box::new(right))
|
||||
}
|
||||
|
||||
fn predicates(predicates: Vec<Predicate>) -> Predicates {
|
||||
Predicates { predicates }
|
||||
}
|
||||
|
||||
// No predicates, no equality value.
|
||||
assert!(predicates(vec![]).find_eq_value("a").is_none());
|
||||
|
||||
// No equality on the column.
|
||||
let p = predicates(vec![Predicate::NotEq("a".to_string(), Value::from("x"))]);
|
||||
assert!(p.find_eq_value("a").is_none());
|
||||
assert!(p.find_eq_value("b").is_none());
|
||||
|
||||
// Simple equality.
|
||||
let p = predicates(vec![eq("a", "x")]);
|
||||
let v = p.find_eq_value("a").unwrap();
|
||||
assert!(match_string_value(v, "x"));
|
||||
assert!(p.find_eq_value("b").is_none());
|
||||
|
||||
// Duplicated equality on the same value.
|
||||
let p = predicates(vec![and(eq("a", "x"), eq("a", "x"))]);
|
||||
let v = p.find_eq_value("a").unwrap();
|
||||
assert!(match_string_value(v, "x"));
|
||||
|
||||
// Conflicting equalities reject the lookup.
|
||||
let p = predicates(vec![and(eq("a", "x"), eq("a", "y"))]);
|
||||
assert!(p.find_eq_value("a").is_none());
|
||||
|
||||
// Conflicting equalities in separate predicates reject the lookup too.
|
||||
let p = predicates(vec![eq("a", "x"), eq("a", "y")]);
|
||||
assert!(p.find_eq_value("a").is_none());
|
||||
|
||||
// Equality nested in deeper conjunctions.
|
||||
let p = predicates(vec![and(and(eq("b", "y"), eq("a", "x")), eq("a", "x"))]);
|
||||
let v = p.find_eq_value("a").unwrap();
|
||||
assert!(match_string_value(v, "x"));
|
||||
|
||||
// Equality under a disjunction or a negation doesn't constrain the column.
|
||||
let p = predicates(vec![or(eq("a", "x"), eq("a", "x"))]);
|
||||
assert!(p.find_eq_value("a").is_none());
|
||||
let p = predicates(vec![Predicate::Not(Box::new(eq("a", "x")))]);
|
||||
assert!(p.find_eq_value("a").is_none());
|
||||
|
||||
// Equality value in a top-level conjunction composed of separate predicates.
|
||||
let p = predicates(vec![
|
||||
eq("a", "x"),
|
||||
Predicate::Like("b".to_string(), "%y".to_string(), false),
|
||||
]);
|
||||
let v = p.find_eq_value("a").unwrap();
|
||||
assert!(match_string_value(v, "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicates_eval_row() {
|
||||
let wrong_row = [
|
||||
|
||||
@@ -86,7 +86,13 @@ pub struct StoreConfig {
|
||||
|
||||
/// Automatically create PostgreSQL schema if it doesn't exist (default: true).
|
||||
#[cfg(feature = "pg_kvbackend")]
|
||||
#[clap(long, default_value_t = true)]
|
||||
#[clap(
|
||||
long,
|
||||
default_value_t = true,
|
||||
default_missing_value = "true",
|
||||
num_args = 0..=1,
|
||||
action = clap::ArgAction::Set
|
||||
)]
|
||||
pub auto_create_schema: bool,
|
||||
|
||||
/// TLS mode for backend store connections (etcd, PostgreSQL, MySQL)
|
||||
@@ -223,3 +229,27 @@ impl StoreConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "pg_kvbackend"))]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::StoreConfig;
|
||||
|
||||
#[test]
|
||||
fn test_auto_create_schema_accepts_explicit_value() {
|
||||
let config =
|
||||
StoreConfig::try_parse_from(["store", "--auto-create-schema", "false"]).unwrap();
|
||||
assert!(!config.auto_create_schema);
|
||||
|
||||
let config =
|
||||
StoreConfig::try_parse_from(["store", "--auto-create-schema", "true"]).unwrap();
|
||||
assert!(config.auto_create_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_create_schema_defaults_to_true() {
|
||||
let config = StoreConfig::try_parse_from(["store"]).unwrap();
|
||||
assert!(config.auto_create_schema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,4 @@ substrait.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream = { workspace = true, features = ["net"] }
|
||||
tonic.workspace = true
|
||||
tonic-prost.workspace = true
|
||||
|
||||
@@ -20,7 +20,6 @@ use api::v1::HealthCheckRequest;
|
||||
use api::v1::flow::flow_client::FlowClient as PbFlowClient;
|
||||
use api::v1::health_check_client::HealthCheckClient;
|
||||
use api::v1::prometheus_gateway_client::PrometheusGatewayClient;
|
||||
use api::v1::region::region_client::RegionClient as PbRegionClient;
|
||||
use arrow_flight::flight_service_client::FlightServiceClient;
|
||||
use common_grpc::channel_manager::{
|
||||
ChannelConfig, ChannelManager, ClientTlsOption, load_client_tls_config,
|
||||
@@ -526,23 +525,12 @@ impl Client {
|
||||
Ok(FlightClient { addr, client })
|
||||
}
|
||||
|
||||
pub(crate) fn raw_region_client(&self) -> Result<(String, PbRegionClient<Channel>)> {
|
||||
/// Creates the region gRPC transport with the configured message limits.
|
||||
pub(crate) fn raw_region_client(&self) -> Result<(String, tonic::client::Grpc<Channel>)> {
|
||||
let (addr, channel) = self.find_channel()?;
|
||||
let client = PbRegionClient::new(channel)
|
||||
.max_decoding_message_size(
|
||||
self.inner
|
||||
.control_channel_manager
|
||||
.config()
|
||||
.max_recv_message_size
|
||||
.as_bytes() as usize,
|
||||
)
|
||||
.max_encoding_message_size(
|
||||
self.inner
|
||||
.control_channel_manager
|
||||
.config()
|
||||
.max_send_message_size
|
||||
.as_bytes() as usize,
|
||||
);
|
||||
let client = tonic::client::Grpc::new(channel)
|
||||
.max_decoding_message_size(self.max_grpc_recv_message_size())
|
||||
.max_encoding_message_size(self.max_grpc_send_message_size());
|
||||
Ok((addr, client))
|
||||
}
|
||||
|
||||
|
||||
+303
-45
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -55,6 +55,7 @@ use futures::future;
|
||||
use futures_util::{Stream, StreamExt, TryStreamExt};
|
||||
use prost::Message;
|
||||
use snafu::{IntoError, ResultExt};
|
||||
use tokio::sync::Notify;
|
||||
use tonic::metadata::{AsciiMetadataKey, AsciiMetadataValue, MetadataMap, MetadataValue};
|
||||
use tonic::transport::Channel;
|
||||
|
||||
@@ -70,20 +71,47 @@ type FlightDataStream = Pin<Box<dyn Stream<Item = FlightData> + Send>>;
|
||||
type DoPutResponseStream = Pin<Box<dyn Stream<Item = Result<DoPutResponse>>>>;
|
||||
|
||||
const HINTS_METADATA_KEY: &str = "x-greptime-hints";
|
||||
/// Maximum time to wait for the optional trailing metrics message after
|
||||
/// affected rows have already been delivered.
|
||||
const FLIGHT_TRAILING_METRICS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Terminal metrics associated with a query output.
|
||||
///
|
||||
/// For streaming outputs, metrics are only final after the stream is fully
|
||||
/// drained and [`Self::is_ready`] returns `true`.
|
||||
/// drained and [`Self::is_ready`] returns `true`. Affected-row outputs may
|
||||
/// briefly await a compatibility trailing metrics message.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OutputMetrics {
|
||||
inner: Arc<OutputMetricsInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
struct OutputMetricsInner {
|
||||
metrics: RwLock<Option<RecordBatchMetrics>>,
|
||||
completion_error: RwLock<Option<String>>,
|
||||
ready: AtomicBool,
|
||||
ready_notify: Notify,
|
||||
compatibility_task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
}
|
||||
|
||||
impl Default for OutputMetricsInner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
metrics: RwLock::new(None),
|
||||
completion_error: RwLock::new(None),
|
||||
ready: AtomicBool::new(false),
|
||||
ready_notify: Notify::new(),
|
||||
compatibility_task: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OutputMetricsInner {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.compatibility_task.get_mut().unwrap().take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputMetrics {
|
||||
@@ -98,10 +126,45 @@ impl OutputMetrics {
|
||||
|
||||
/// Marks the terminal metrics as final for this output.
|
||||
pub fn mark_ready(&self) {
|
||||
let _ = self
|
||||
if self
|
||||
.inner
|
||||
.ready
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire);
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
self.inner.ready_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until terminal metrics are final.
|
||||
pub async fn wait_ready(&self) {
|
||||
loop {
|
||||
let notified = self.inner.ready_notify.notified();
|
||||
if self.is_ready() {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an error encountered while completing the output, if any.
|
||||
pub fn completion_error(&self) -> Option<String> {
|
||||
self.inner.completion_error.read().unwrap().clone()
|
||||
}
|
||||
|
||||
fn set_completion_error(&self, error: impl Into<String>) {
|
||||
*self.inner.completion_error.write().unwrap() = Some(error.into());
|
||||
}
|
||||
|
||||
fn set_compatibility_task(&self, handle: tokio::task::AbortHandle) {
|
||||
let mut task = self.inner.compatibility_task.lock().unwrap();
|
||||
if !self.is_ready() {
|
||||
*task = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_compatibility_task(&self) -> Option<tokio::task::AbortHandle> {
|
||||
self.inner.compatibility_task.lock().unwrap().take()
|
||||
}
|
||||
|
||||
/// Returns whether terminal metrics are final.
|
||||
@@ -148,7 +211,8 @@ impl OutputMetrics {
|
||||
///
|
||||
/// The contained [`OutputMetrics`] lets callers read stream terminal metrics
|
||||
/// after consuming `output`. For non-stream outputs, metrics are ready
|
||||
/// immediately.
|
||||
/// immediately. Flight affected-row outputs without inline metrics require
|
||||
/// [`OutputMetrics::wait_ready`] before compatibility trailing metrics can be read.
|
||||
#[derive(Debug)]
|
||||
pub struct OutputWithMetrics {
|
||||
pub output: Output,
|
||||
@@ -194,6 +258,55 @@ fn parse_terminal_metrics(metrics_json: &str) -> Result<RecordBatchMetrics> {
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_affected_rows_trailing_metrics_task<S>(
|
||||
terminal_metrics: &OutputMetrics,
|
||||
mut reader: FlightMessageReader<S>,
|
||||
) where
|
||||
S: Stream<Item = Result<FlightMessage>> + Send + Unpin + 'static,
|
||||
{
|
||||
let metrics_ref = Arc::downgrade(&terminal_metrics.inner);
|
||||
let remote_addr = reader.remote_addr().to_string();
|
||||
let task = common_runtime::spawn_global(async move {
|
||||
let result =
|
||||
tokio::time::timeout(FLIGHT_TRAILING_METRICS_TIMEOUT, reader.read_next()).await;
|
||||
let Some(inner) = metrics_ref.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let metrics = OutputMetrics { inner };
|
||||
match result {
|
||||
Ok(Ok(Some(FlightMessage::Metrics(s)))) => match parse_terminal_metrics(&s) {
|
||||
Ok(metrics_json) => metrics.update(Some(metrics_json)),
|
||||
Err(error) => {
|
||||
metrics.set_completion_error(error.to_string());
|
||||
warn!(
|
||||
"Failed to decode trailing Flight metrics from {}: {}",
|
||||
remote_addr, error
|
||||
);
|
||||
}
|
||||
},
|
||||
Ok(Ok(None)) => {}
|
||||
Ok(Ok(Some(other))) => {
|
||||
let error = format!("Unexpected trailing Flight message: {other:?}");
|
||||
metrics.set_completion_error(error.clone());
|
||||
warn!("{} from {}", error, remote_addr);
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let error = flight_stream_error(&remote_addr, error);
|
||||
metrics.set_completion_error(error.to_string());
|
||||
warn!("{}", error);
|
||||
}
|
||||
Err(_) => {
|
||||
let error = "Timed out waiting for trailing Flight metrics";
|
||||
metrics.set_completion_error(error);
|
||||
warn!("{} from {}", error, remote_addr);
|
||||
}
|
||||
}
|
||||
metrics.mark_ready();
|
||||
metrics.take_compatibility_task();
|
||||
});
|
||||
terminal_metrics.set_compatibility_task(task.abort_handle());
|
||||
}
|
||||
|
||||
struct StreamWithMetrics {
|
||||
stream: common_recordbatch::SendableRecordBatchStream,
|
||||
metrics: OutputMetrics,
|
||||
@@ -233,6 +346,9 @@ impl Stream for StreamWithMetrics {
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let polled = Pin::new(&mut self.stream).poll_next(cx);
|
||||
if let Poll::Ready(Some(Err(error))) = &polled {
|
||||
self.metrics.set_completion_error(error.to_string());
|
||||
}
|
||||
if let Poll::Ready(None) = &polled {
|
||||
self.sync_terminal_metrics();
|
||||
self.metrics.mark_ready();
|
||||
@@ -280,32 +396,12 @@ where
|
||||
FlightMessage::AffectedRows { rows, metrics } => {
|
||||
let terminal_metrics = OutputMetrics::new();
|
||||
if let Some(metrics) = metrics {
|
||||
// Inline metrics are authoritative. They complete the output
|
||||
// without touching the rest of the Flight stream.
|
||||
terminal_metrics.update(Some(parse_terminal_metrics(&metrics)?));
|
||||
}
|
||||
let next_message = reader
|
||||
.read_next()
|
||||
.await
|
||||
.map_err(|error| flight_stream_error(reader.remote_addr(), error))?;
|
||||
match next_message {
|
||||
None => terminal_metrics.mark_ready(),
|
||||
Some(FlightMessage::Metrics(s)) if terminal_metrics.get().is_none() => {
|
||||
terminal_metrics.update(Some(parse_terminal_metrics(&s)?));
|
||||
terminal_metrics.mark_ready();
|
||||
}
|
||||
Some(FlightMessage::Metrics(_)) => {
|
||||
return IllegalFlightMessagesSnafu {
|
||||
reason: "'AffectedRows' Flight metadata already carries Metrics and cannot be followed by another Metrics message",
|
||||
}
|
||||
.fail();
|
||||
}
|
||||
Some(other) => {
|
||||
return IllegalFlightMessagesSnafu {
|
||||
reason: format!(
|
||||
"'AffectedRows' Flight message can only be followed by a Metrics message, got {other:?}"
|
||||
),
|
||||
}
|
||||
.fail();
|
||||
}
|
||||
terminal_metrics.mark_ready();
|
||||
} else {
|
||||
spawn_affected_rows_trailing_metrics_task(&terminal_metrics, reader);
|
||||
}
|
||||
Ok(OutputWithMetrics {
|
||||
output: Output::new_with_affected_rows(rows),
|
||||
@@ -749,7 +845,9 @@ impl Database {
|
||||
/// Executes a SQL query and returns the output with terminal metrics.
|
||||
///
|
||||
/// For stream outputs, callers must consume the stream before reading final
|
||||
/// terminal metrics from [`OutputWithMetrics::metrics`].
|
||||
/// terminal metrics from [`OutputWithMetrics::metrics`]. For affected-row
|
||||
/// outputs without inline metrics, call [`OutputMetrics::wait_ready`] when
|
||||
/// compatibility trailing metrics are required.
|
||||
pub async fn sql_with_terminal_metrics<S>(
|
||||
&self,
|
||||
sql: S,
|
||||
@@ -986,6 +1084,7 @@ mod tests {
|
||||
use datatypes::schema::{ColumnSchema, Schema};
|
||||
use datatypes::vectors::Int32Vector;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::sync::oneshot;
|
||||
use tonic::codegen::http::{HeaderMap, HeaderValue};
|
||||
use tonic::metadata::MetadataMap;
|
||||
use tonic::{Code, Status};
|
||||
@@ -1296,11 +1395,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_terminal_metrics_rejects_invalid_json() {
|
||||
assert!(parse_terminal_metrics("{not-json}").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_inline_metrics_are_parsed() {
|
||||
let output = output_from_flight_message_stream(
|
||||
@@ -1322,27 +1416,191 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_inline_metrics_rejects_trailing_metrics() {
|
||||
async fn test_affected_rows_inline_metrics_do_not_poll_trailer() {
|
||||
let metrics_json = terminal_metrics_json();
|
||||
let err = output_from_flight_message_stream(
|
||||
let output = output_from_flight_message_stream(
|
||||
"test-peer".to_string(),
|
||||
futures_util::stream::iter(vec![
|
||||
Ok(FlightMessage::AffectedRows {
|
||||
rows: 3,
|
||||
metrics: Some(metrics_json.clone()),
|
||||
metrics: Some(metrics_json),
|
||||
}),
|
||||
Ok(FlightMessage::Metrics(metrics_json)),
|
||||
Ok(FlightMessage::Metrics(terminal_metrics_json_with_seq(99))),
|
||||
] as Vec<Result<FlightMessage>>),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("already carries Metrics"),
|
||||
"unexpected error: {err:?}"
|
||||
assert!(output.metrics.is_ready());
|
||||
assert_eq!(
|
||||
output.metrics.region_watermark_map(),
|
||||
Some(std::collections::HashMap::from([(7, 42)]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_without_inline_metrics_becomes_ready_after_trailer() {
|
||||
// Hold the trailer until the not-ready state has been observed.
|
||||
let (trailer_tx, trailer_rx) = oneshot::channel();
|
||||
let trailer = futures_util::stream::once(trailer_rx).map(|ready| {
|
||||
ready.unwrap();
|
||||
Ok(FlightMessage::Metrics(terminal_metrics_json()))
|
||||
});
|
||||
let output = output_from_flight_message_stream(
|
||||
"test-peer".to_string(),
|
||||
futures_util::stream::iter(vec![Ok(FlightMessage::AffectedRows {
|
||||
rows: 3,
|
||||
metrics: None,
|
||||
})])
|
||||
.chain(trailer),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.metrics.is_ready());
|
||||
trailer_tx.send(()).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), output.metrics.wait_ready())
|
||||
.await
|
||||
.expect("terminal metrics must become ready once the trailer arrives");
|
||||
assert!(output.metrics.completion_error().is_none());
|
||||
assert_eq!(
|
||||
output.metrics.region_watermark_map(),
|
||||
Some(std::collections::HashMap::from([(7, 42)]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_malformed_trailer_sets_completion_error_and_ready() {
|
||||
let output = output_from_flight_message_stream(
|
||||
"test-peer".to_string(),
|
||||
futures_util::stream::iter(vec![
|
||||
Ok(FlightMessage::AffectedRows {
|
||||
rows: 3,
|
||||
metrics: None,
|
||||
}),
|
||||
Ok(FlightMessage::Metrics("{not-json}".to_string())),
|
||||
] as Vec<Result<FlightMessage>>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
output.metrics.wait_ready().await;
|
||||
let error = output.metrics.completion_error().unwrap();
|
||||
assert!(error.contains("Invalid terminal metrics message"));
|
||||
assert!(output.metrics.is_ready());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_transport_trailer_error_sets_completion_error_and_ready() {
|
||||
let output = output_from_flight_message_stream(
|
||||
"test-peer".to_string(),
|
||||
futures_util::stream::iter(vec![
|
||||
Ok(FlightMessage::AffectedRows {
|
||||
rows: 3,
|
||||
metrics: None,
|
||||
}),
|
||||
Err(Status::unavailable("trailer read failed").into()),
|
||||
] as Vec<Result<FlightMessage>>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
output.metrics.wait_ready().await;
|
||||
let error = output.metrics.completion_error().unwrap();
|
||||
assert!(error.contains("trailer read failed"));
|
||||
assert!(output.metrics.is_ready());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_affected_rows_compatibility_reader_is_cancelled_after_second_poll_begins() {
|
||||
struct DropProbe {
|
||||
first: Option<Result<FlightMessage>>,
|
||||
polled: Option<oneshot::Sender<()>>,
|
||||
dropped: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl Stream for DropProbe {
|
||||
type Item = Result<FlightMessage>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Self::Item>> {
|
||||
if let Some(message) = self.first.take() {
|
||||
return Poll::Ready(Some(message));
|
||||
}
|
||||
if let Some(polled) = self.polled.take() {
|
||||
let _ = polled.send(());
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DropProbe {
|
||||
fn drop(&mut self) {
|
||||
if let Some(dropped) = self.dropped.take() {
|
||||
let _ = dropped.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (polled_tx, polled_rx) = oneshot::channel();
|
||||
let (dropped_tx, dropped_rx) = oneshot::channel();
|
||||
let output = output_from_flight_message_stream(
|
||||
"test-peer".to_string(),
|
||||
DropProbe {
|
||||
first: Some(Ok(FlightMessage::AffectedRows {
|
||||
rows: 3,
|
||||
metrics: None,
|
||||
})),
|
||||
polled: Some(polled_tx),
|
||||
dropped: Some(dropped_tx),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
polled_rx.await.unwrap();
|
||||
drop(output);
|
||||
// Require cancellation before the five-second compatibility timeout.
|
||||
tokio::time::timeout(Duration::from_secs(1), dropped_rx)
|
||||
.await
|
||||
.expect("the compatibility reader must be dropped once the output is dropped")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_schema_record_batch_yields_before_pending_message_stream() {
|
||||
let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
|
||||
"v",
|
||||
ConcreteDataType::int32_datatype(),
|
||||
false,
|
||||
)]));
|
||||
let batch = RecordBatch::new(
|
||||
schema.clone(),
|
||||
vec![Arc::new(Int32Vector::from_slice([1])) as VectorRef],
|
||||
)
|
||||
.unwrap();
|
||||
let messages = futures_util::stream::iter(vec![
|
||||
Ok(FlightMessage::Schema(schema.arrow_schema().clone())),
|
||||
Ok(FlightMessage::RecordBatch(batch.into_df_record_batch())),
|
||||
] as Vec<Result<FlightMessage>>)
|
||||
.chain(futures_util::stream::pending());
|
||||
let output = output_from_flight_message_stream("test-peer".to_string(), messages)
|
||||
.await
|
||||
.unwrap();
|
||||
let OutputData::Stream(mut stream) = output.output.data else {
|
||||
panic!("expected stream output");
|
||||
};
|
||||
|
||||
let batch = tokio::time::timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
assert!(!output.metrics.is_ready());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_terminal_metrics_after_record_batch_yields_batch_then_error() {
|
||||
let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
|
||||
|
||||
+24
-2
@@ -18,7 +18,7 @@ use common_error::define_from_tonic_status;
|
||||
use common_error::ext::{BoxedError, ErrorExt, RetryHint};
|
||||
use common_error::status_code::StatusCode;
|
||||
use common_macro::stack_trace_debug;
|
||||
use snafu::{Location, Snafu};
|
||||
use snafu::{Location, Snafu, location};
|
||||
use tonic::Code;
|
||||
use tonic::metadata::errors::InvalidMetadataValue;
|
||||
|
||||
@@ -191,9 +191,22 @@ impl ErrorExt for Error {
|
||||
Error::Tonic { retry_hint, .. } => *retry_hint,
|
||||
Error::FlightGet { source, .. }
|
||||
| Error::FlightStream { source, .. }
|
||||
| Error::RegionServer { source, .. }
|
||||
| Error::FlowServer { source, .. }
|
||||
| Error::External { source, .. } => source.retry_hint(),
|
||||
Error::RegionServer { code, source, .. } => {
|
||||
let hint = source.retry_hint();
|
||||
// Preserve the transport retry policy previously applied by should_retry().
|
||||
if !hint.is_retryable()
|
||||
&& matches!(
|
||||
code,
|
||||
Code::Unavailable | Code::Cancelled | Code::DeadlineExceeded
|
||||
)
|
||||
{
|
||||
RetryHint::Retryable
|
||||
} else {
|
||||
hint
|
||||
}
|
||||
}
|
||||
Error::ConvertFlightData { source, .. }
|
||||
| Error::CreateChannel { source, .. }
|
||||
| Error::CreateTlsChannel { source, .. } => source.retry_hint(),
|
||||
@@ -205,6 +218,15 @@ impl ErrorExt for Error {
|
||||
|
||||
define_from_tonic_status!(Error, Tonic);
|
||||
|
||||
impl From<Error> for common_meta::error::Error {
|
||||
fn from(error: Error) -> Self {
|
||||
Self::External {
|
||||
source: BoxedError::new(error),
|
||||
location: location!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Returns the gRPC status code if this error is caused by a gRPC request failure.
|
||||
pub fn tonic_code(&self) -> Option<Code> {
|
||||
|
||||
@@ -12,40 +12,18 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use arrow_flight::FlightData;
|
||||
use common_grpc::flight::{FlightDecoder, FlightMessage};
|
||||
use futures_util::stream::Peekable;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use snafu::{OptionExt, ResultExt};
|
||||
|
||||
use crate::Result;
|
||||
use crate::error::{ConvertFlightDataSnafu, Error, IllegalFlightMessagesSnafu};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FlightMessageKind {
|
||||
Schema,
|
||||
RecordBatch,
|
||||
AffectedRows,
|
||||
Metrics,
|
||||
}
|
||||
|
||||
impl From<&FlightMessage> for FlightMessageKind {
|
||||
fn from(message: &FlightMessage) -> Self {
|
||||
match message {
|
||||
FlightMessage::Schema(_) => Self::Schema,
|
||||
FlightMessage::RecordBatch(_) => Self::RecordBatch,
|
||||
FlightMessage::AffectedRows { .. } => Self::AffectedRows,
|
||||
FlightMessage::Metrics(_) => Self::Metrics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FlightMessageReader<S: Stream + Unpin> {
|
||||
/// Remote Flight peer associated with this response stream.
|
||||
remote_addr: String,
|
||||
messages: Peekable<S>,
|
||||
messages: S,
|
||||
}
|
||||
|
||||
impl<S> FlightMessageReader<S>
|
||||
@@ -55,7 +33,7 @@ where
|
||||
pub(crate) fn new(remote_addr: impl Into<String>, messages: S) -> Self {
|
||||
Self {
|
||||
remote_addr: remote_addr.into(),
|
||||
messages: messages.peekable(),
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,21 +50,6 @@ where
|
||||
pub(crate) async fn read_next(&mut self) -> Result<Option<FlightMessage>> {
|
||||
self.messages.next().await.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn peek_next_message_kind(&mut self) -> Result<Option<FlightMessageKind>> {
|
||||
match Pin::new(&mut self.messages).peek().await {
|
||||
Some(Ok(message)) => Ok(Some(message.into())),
|
||||
None => Ok(None),
|
||||
Some(Err(_)) => match self.read_next().await {
|
||||
// `peek` only borrows the error; consume it to preserve the source error.
|
||||
Err(error) => Err(error),
|
||||
Ok(_) => IllegalFlightMessagesSnafu {
|
||||
reason: "Flight stream changed after peek".to_string(),
|
||||
}
|
||||
.fail(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_flight_data(
|
||||
|
||||
+646
-165
@@ -12,6 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod codec;
|
||||
|
||||
use std::borrow::Borrow;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,11 +51,13 @@ use crate::error::{
|
||||
self, FlightGetSnafu, IllegalDatabaseResponseSnafu, IllegalFlightMessagesSnafu,
|
||||
MissingFieldSnafu, Result, ServerSnafu,
|
||||
};
|
||||
use crate::flight::{FlightMessageKind, FlightMessageReader, decode_flight_data};
|
||||
use crate::flight::{FlightMessageReader, decode_flight_data};
|
||||
use crate::region::codec::RegionCodec;
|
||||
use crate::{Client, metrics};
|
||||
|
||||
const FLIGHT_DO_GET_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Sends region operations through a configured gRPC client.
|
||||
#[derive(Debug)]
|
||||
pub struct RegionRequester {
|
||||
client: Client,
|
||||
@@ -62,20 +67,8 @@ pub struct RegionRequester {
|
||||
|
||||
#[async_trait]
|
||||
impl Datanode for RegionRequester {
|
||||
async fn handle(&self, request: RegionRequest) -> MetaResult<RegionResponse> {
|
||||
self.handle_inner(request).await.map_err(|err| {
|
||||
if err.should_retry() {
|
||||
meta_error::Error::RetryLater {
|
||||
source: BoxedError::new(err),
|
||||
clean_poisons: false,
|
||||
}
|
||||
} else {
|
||||
meta_error::Error::External {
|
||||
source: BoxedError::new(err),
|
||||
location: location!(),
|
||||
}
|
||||
}
|
||||
})
|
||||
async fn handle_request(&self, request: Arc<RegionRequest>) -> MetaResult<RegionResponse> {
|
||||
self.handle_inner(request).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn handle_query(&self, request: QueryRequest) -> MetaResult<SendableRecordBatchStream> {
|
||||
@@ -144,11 +137,49 @@ impl RegionRequester {
|
||||
recordbatches_from_flight_message_stream(addr, flight_message_stream).await
|
||||
}
|
||||
|
||||
async fn handle_inner(&self, request: RegionRequest) -> Result<RegionResponse> {
|
||||
/// Sends an owned or shared request without deep-cloning its protobuf payload.
|
||||
///
|
||||
/// Accepts both [`RegionRequest`] and [`Arc<RegionRequest>`]. Callers can retain a
|
||||
/// shared request for another attempt; this method does not retry or determine
|
||||
/// whether replaying the request is safe.
|
||||
pub async fn handle<R>(&self, request: R) -> Result<RegionResponse>
|
||||
where
|
||||
R: Borrow<RegionRequest> + Send + Sync + 'static,
|
||||
{
|
||||
self.handle_inner(request).await
|
||||
}
|
||||
|
||||
/// Sends a remote dynamic filter update to the region service.
|
||||
pub async fn handle_remote_dyn_filter_update(
|
||||
&self,
|
||||
query_id: impl Into<String>,
|
||||
update: RemoteDynFilterUpdate,
|
||||
) -> Result<RegionResponse> {
|
||||
self.handle_inner(build_remote_dyn_filter_update_request(query_id, update))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Unregisters a remote dynamic filter in the region service.
|
||||
pub async fn handle_remote_dyn_filter_unregister(
|
||||
&self,
|
||||
query_id: impl Into<String>,
|
||||
unregister: RemoteDynFilterUnregister,
|
||||
) -> Result<RegionResponse> {
|
||||
self.handle_inner(build_remote_dyn_filter_unregister_request(
|
||||
query_id, unregister,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_inner<R>(&self, request: R) -> Result<RegionResponse>
|
||||
where
|
||||
R: Borrow<RegionRequest> + Send + Sync + 'static,
|
||||
{
|
||||
let request_body = request
|
||||
.borrow()
|
||||
.body
|
||||
.as_ref()
|
||||
.with_context(|| MissingFieldSnafu { field: "body" })?;
|
||||
.context(MissingFieldSnafu { field: "body" })?;
|
||||
let is_insert = matches!(
|
||||
request_body,
|
||||
region_request::Body::Inserts(_) | region_request::Body::BulkInsert(_)
|
||||
@@ -168,48 +199,42 @@ impl RegionRequester {
|
||||
}
|
||||
}
|
||||
|
||||
let response = client
|
||||
.handle(request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let code = e.code();
|
||||
// Uses `Error::RegionServer` instead of `Error::Server`
|
||||
error::Error::RegionServer {
|
||||
addr,
|
||||
code,
|
||||
source: BoxedError::new(error::Error::from(e)),
|
||||
location: location!(),
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
let response = async {
|
||||
client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
|
||||
let mut request = tonic::Request::new(request);
|
||||
request.extensions_mut().insert(tonic::GrpcMethod::new(
|
||||
"greptime.v1.region.Region",
|
||||
"Handle",
|
||||
));
|
||||
client
|
||||
.unary(
|
||||
request,
|
||||
tonic::codegen::http::uri::PathAndQuery::from_static(
|
||||
"/greptime.v1.region.Region/Handle",
|
||||
),
|
||||
RegionCodec::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await
|
||||
.map_err(|status| map_region_error(status, addr))?
|
||||
.into_inner();
|
||||
|
||||
check_response_header(&response.header)?;
|
||||
|
||||
Ok(RegionResponse::from_region_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(&self, request: RegionRequest) -> Result<RegionResponse> {
|
||||
self.handle_inner(request).await
|
||||
}
|
||||
|
||||
pub async fn handle_remote_dyn_filter_update(
|
||||
&self,
|
||||
query_id: impl Into<String>,
|
||||
update: RemoteDynFilterUpdate,
|
||||
) -> Result<RegionResponse> {
|
||||
self.handle_inner(build_remote_dyn_filter_update_request(query_id, update))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle_remote_dyn_filter_unregister(
|
||||
&self,
|
||||
query_id: impl Into<String>,
|
||||
unregister: RemoteDynFilterUnregister,
|
||||
) -> Result<RegionResponse> {
|
||||
self.handle_inner(build_remote_dyn_filter_unregister_request(
|
||||
query_id, unregister,
|
||||
))
|
||||
.await
|
||||
fn map_region_error(status: tonic::Status, addr: String) -> error::Error {
|
||||
error::Error::RegionServer {
|
||||
addr,
|
||||
code: status.code(),
|
||||
source: BoxedError::new(error::Error::from(status)),
|
||||
location: location!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,9 +272,7 @@ where
|
||||
"poll_flight_data_stream"
|
||||
));
|
||||
|
||||
let mut stream_ended = false;
|
||||
|
||||
while !stream_ended {
|
||||
loop {
|
||||
let flight_message = match reader.read_next().await {
|
||||
Ok(Some(message)) => message,
|
||||
Ok(None) => break,
|
||||
@@ -262,59 +285,27 @@ where
|
||||
|
||||
match flight_message {
|
||||
FlightMessage::RecordBatch(record_batch) => {
|
||||
let result_to_yield =
|
||||
RecordBatch::from_df_record_batch(schema_cloned.clone(), record_batch);
|
||||
|
||||
// Metrics follow a batch so MergeScan can observe them before yielding it.
|
||||
match reader.peek_next_message_kind().await {
|
||||
Ok(Some(FlightMessageKind::Metrics)) => {
|
||||
let metrics_message = match reader.read_next().await {
|
||||
Ok(Some(FlightMessage::Metrics(metrics))) => metrics,
|
||||
Ok(Some(_) | None) => {
|
||||
yield IllegalFlightMessagesSnafu {
|
||||
reason: "Flight stream changed after peek",
|
||||
}
|
||||
.fail()
|
||||
.map_err(BoxedError::new)
|
||||
.context(ExternalSnafu);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
yield Err(BoxedError::new(flight_stream_error(
|
||||
&stream_addr,
|
||||
error,
|
||||
)))
|
||||
.context(ExternalSnafu);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let metrics = serde_json::from_str(&metrics_message).ok().map(Arc::new);
|
||||
metrics_ref.swap(metrics);
|
||||
}
|
||||
Ok(Some(FlightMessageKind::RecordBatch)) => {}
|
||||
Ok(Some(FlightMessageKind::Schema | FlightMessageKind::AffectedRows)) => {
|
||||
yield IllegalFlightMessagesSnafu {
|
||||
reason: "A RecordBatch message can only be succeeded by a Metrics message or another RecordBatch message"
|
||||
}
|
||||
.fail()
|
||||
.map_err(BoxedError::new)
|
||||
.context(ExternalSnafu);
|
||||
break;
|
||||
}
|
||||
Ok(None) => stream_ended = true,
|
||||
Err(error) => {
|
||||
yield Err(BoxedError::new(flight_stream_error(&stream_addr, error)))
|
||||
.context(ExternalSnafu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(result_to_yield);
|
||||
// Deliver each batch immediately. In particular, do not
|
||||
// wait for a possible following Metrics message; it is
|
||||
// consumed on the next poll of this stream.
|
||||
yield Ok(RecordBatch::from_df_record_batch(
|
||||
schema_cloned.clone(),
|
||||
record_batch,
|
||||
));
|
||||
}
|
||||
FlightMessage::Metrics(s) => {
|
||||
// Metrics may arrive before the next RecordBatch.
|
||||
let m = serde_json::from_str(&s).ok().map(Arc::new);
|
||||
metrics_ref.swap(m);
|
||||
match serde_json::from_str(&s) {
|
||||
Ok(metrics) => {
|
||||
metrics_ref.swap(Some(Arc::new(metrics)));
|
||||
}
|
||||
Err(error) => {
|
||||
common_telemetry::warn!(
|
||||
"Failed to decode region Flight metrics: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
@@ -419,13 +410,22 @@ pub fn check_response_header(header: &Option<ResponseHeader>) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(deprecated)]
|
||||
mod test {
|
||||
use api::v1::Status as PbStatus;
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use api::v1::region::region_server::{Region, RegionServer};
|
||||
use api::v1::region::{
|
||||
BulkInsertRequest, RegionResponse as PbRegionResponse, RemoteDynFilterUnregister,
|
||||
RemoteDynFilterUpdate, region_request, remote_dyn_filter_request,
|
||||
BulkInsertRequest, InsertRequest, InsertRequests, RegionResponse as PbRegionResponse,
|
||||
RemoteDynFilterUnregister, RemoteDynFilterUpdate, region_request,
|
||||
remote_dyn_filter_request,
|
||||
};
|
||||
use api::v1::value::ValueData;
|
||||
use api::v1::{
|
||||
ColumnDataType, ColumnSchema as PbColumnSchema, Row, Rows, Status as PbStatus, Value,
|
||||
};
|
||||
use common_error::ext::RetryHint;
|
||||
use common_grpc::channel_manager::{ChannelConfig, ChannelManager};
|
||||
use common_recordbatch::adapter::RecordBatchMetrics;
|
||||
use datatypes::arrow::array::Int32Array;
|
||||
use datatypes::prelude::{ConcreteDataType, VectorRef};
|
||||
@@ -433,74 +433,526 @@ mod test {
|
||||
use datatypes::vectors::Int32Vector;
|
||||
use futures_util::stream;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
use tonic::codec::CompressionEncoding;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use super::*;
|
||||
use crate::Error::{self, IllegalDatabaseResponse, Server};
|
||||
use crate::Error;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CompressionRecordingRegionService {
|
||||
zstd_headers: Arc<std::sync::Mutex<Vec<(bool, bool)>>>,
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingRegionService {
|
||||
requests: Arc<Mutex<Vec<(RegionRequest, bool, bool)>>>,
|
||||
responses: Arc<Mutex<VecDeque<std::result::Result<PbRegionResponse, Status>>>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Region for CompressionRecordingRegionService {
|
||||
impl Region for RecordingRegionService {
|
||||
async fn handle(
|
||||
&self,
|
||||
request: Request<RegionRequest>,
|
||||
) -> std::result::Result<Response<PbRegionResponse>, Status> {
|
||||
let metadata = request.metadata();
|
||||
let sends_zstd = metadata
|
||||
.get("grpc-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|value| value == "zstd");
|
||||
let sends_zstd = metadata.get("grpc-encoding").is_some_and(|v| v == "zstd");
|
||||
let accepts_zstd = metadata
|
||||
.get("grpc-accept-encoding")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.split(',').any(|encoding| encoding == "zstd"));
|
||||
self.zstd_headers
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.split(',').any(|encoding| encoding == "zstd"));
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((sends_zstd, accepts_zstd));
|
||||
.push((request.into_inner(), sends_zstd, accepts_zstd));
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Ok(build_response(StatusCode::Success)))
|
||||
.map(Response::new)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Response::new(PbRegionResponse {
|
||||
header: Some(ResponseHeader {
|
||||
status: Some(PbStatus {
|
||||
status_code: StatusCode::Success as u32,
|
||||
..Default::default()
|
||||
}),
|
||||
struct RegionTestServer {
|
||||
addr: String,
|
||||
service: RecordingRegionService,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl RegionTestServer {
|
||||
async fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let service = RecordingRegionService::default();
|
||||
let server_service = service.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(
|
||||
RegionServer::new(server_service)
|
||||
.accept_compressed(CompressionEncoding::Zstd)
|
||||
.send_compressed(CompressionEncoding::Zstd),
|
||||
)
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self {
|
||||
addr,
|
||||
service,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_requester(
|
||||
&self,
|
||||
config: ChannelConfig,
|
||||
send: bool,
|
||||
accept: bool,
|
||||
) -> RegionRequester {
|
||||
RegionRequester::new(
|
||||
Client::with_manager_and_urls(
|
||||
ChannelManager::with_config(config, None),
|
||||
[self.addr.clone()],
|
||||
),
|
||||
send,
|
||||
accept,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RegionTestServer {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_response(code: StatusCode) -> PbRegionResponse {
|
||||
PbRegionResponse {
|
||||
header: Some(ResponseHeader {
|
||||
status: Some(PbStatus {
|
||||
status_code: code as u32,
|
||||
..Default::default()
|
||||
}),
|
||||
}),
|
||||
affected_rows: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_insert_request() -> RegionRequest {
|
||||
RegionRequest {
|
||||
header: Some(RegionRequestHeader {
|
||||
dbname: "shared-request-test".to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
}),
|
||||
body: Some(region_request::Body::Inserts(InsertRequests {
|
||||
requests: vec![InsertRequest {
|
||||
region_id: 42,
|
||||
rows: Some(Rows {
|
||||
schema: vec![
|
||||
PbColumnSchema {
|
||||
column_name: "message".to_string(),
|
||||
datatype: ColumnDataType::String as i32,
|
||||
..Default::default()
|
||||
},
|
||||
PbColumnSchema {
|
||||
column_name: "payload".to_string(),
|
||||
datatype: ColumnDataType::Binary as i32,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
rows: vec![Row {
|
||||
values: vec![
|
||||
Value {
|
||||
value_data: Some(ValueData::StringValue(
|
||||
"hello 世界".to_string(),
|
||||
)),
|
||||
},
|
||||
Value {
|
||||
value_data: Some(ValueData::BinaryValue(vec![0, 1, 127, 255])),
|
||||
},
|
||||
],
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
}],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inserts_and_bulk_insert_use_transport_compression() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let zstd_headers = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let service = CompressionRecordingRegionService {
|
||||
zstd_headers: zstd_headers.clone(),
|
||||
};
|
||||
let server = tokio::spawn(async move {
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(
|
||||
RegionServer::new(service)
|
||||
.accept_compressed(CompressionEncoding::Zstd)
|
||||
.send_compressed(CompressionEncoding::Zstd),
|
||||
)
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
async fn test_owned_and_shared_requests_preserve_payload_and_compression() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let shared = Arc::new(build_insert_request());
|
||||
for (send, accept) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let requester = server.create_requester(ChannelConfig::default(), send, accept);
|
||||
let channel =
|
||||
tonic::transport::Endpoint::from_shared(format!("http://{}", server.addr))
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut baseline = api::v1::region::region_client::RegionClient::new(channel);
|
||||
if send {
|
||||
baseline = baseline.send_compressed(CompressionEncoding::Zstd);
|
||||
}
|
||||
if accept {
|
||||
baseline = baseline.accept_compressed(CompressionEncoding::Zstd);
|
||||
}
|
||||
assert_eq!(
|
||||
baseline
|
||||
.handle(build_insert_request())
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.affected_rows,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requester
|
||||
.handle(build_insert_request())
|
||||
.await
|
||||
.unwrap()
|
||||
.affected_rows,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requester
|
||||
.handle(Arc::clone(&shared))
|
||||
.await
|
||||
.unwrap()
|
||||
.affected_rows,
|
||||
1
|
||||
);
|
||||
}
|
||||
let requests = server.service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 12);
|
||||
for ((request, sends, accepts), (send, accept)) in requests.iter().zip(
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
.into_iter()
|
||||
.flat_map(|settings| [settings, settings, settings]),
|
||||
) {
|
||||
assert_eq!(request, shared.as_ref());
|
||||
assert_eq!((*sends, *accepts), (send, accept));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_request_can_be_resent_after_transport_error() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let mut status = Status::unavailable("migrating");
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_RETRY_HINT,
|
||||
RetryHint::Retryable.as_str().parse().unwrap(),
|
||||
);
|
||||
server
|
||||
.service
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(Err(status));
|
||||
let requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
let shared = Arc::new(build_insert_request());
|
||||
let error = requester.handle(Arc::clone(&shared)).await.unwrap_err();
|
||||
assert_eq!(error.retry_hint(), RetryHint::Retryable);
|
||||
assert!(
|
||||
matches!(error, Error::RegionServer { addr, code: tonic::Code::Unavailable, .. } if addr == server.addr)
|
||||
);
|
||||
requester.handle(Arc::clone(&shared)).await.unwrap();
|
||||
let requests = server.service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(&requests[0].0, shared.as_ref());
|
||||
assert_eq!(&requests[1].0, shared.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_hint_wire_compatibility() {
|
||||
for (name, wire_hint, expected_hint) in [
|
||||
("legacy_missing", None, RetryHint::NonRetryable),
|
||||
("legacy_retryable", Some("retryable"), RetryHint::Retryable),
|
||||
(
|
||||
"legacy_non_retryable",
|
||||
Some("non_retryable"),
|
||||
RetryHint::NonRetryable,
|
||||
),
|
||||
(
|
||||
"route_refresh",
|
||||
Some("retry_after_route_refresh"),
|
||||
RetryHint::RetryAfterRouteRefresh,
|
||||
),
|
||||
(
|
||||
"unknown_future_hint",
|
||||
Some("future_retry_hint"),
|
||||
RetryHint::NonRetryable,
|
||||
),
|
||||
] {
|
||||
let mut status = Status::failed_precondition("region unavailable");
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_CODE,
|
||||
(StatusCode::RegionNotReady as u32)
|
||||
.to_string()
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
if let Some(hint) = wire_hint {
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_RETRY_HINT,
|
||||
hint.parse().unwrap(),
|
||||
);
|
||||
}
|
||||
let error: Error = status.into();
|
||||
assert_eq!(error.retry_hint(), expected_hint, "{name}");
|
||||
assert_eq!(error.status_code(), StatusCode::RegionNotReady, "{name}");
|
||||
assert_eq!(
|
||||
error.tonic_code(),
|
||||
Some(tonic::Code::FailedPrecondition),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transport_error_parity_and_datanode_mapping() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
for hint in [
|
||||
RetryHint::Retryable,
|
||||
RetryHint::RetryAfterRouteRefresh,
|
||||
RetryHint::NonRetryable,
|
||||
] {
|
||||
let mut status = Status::failed_precondition("region unavailable");
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_RETRY_HINT,
|
||||
hint.as_str().parse().unwrap(),
|
||||
);
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_CODE,
|
||||
(StatusCode::RegionNotFound as u32)
|
||||
.to_string()
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
server
|
||||
.service
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend((0..4).map(|_| Err(status.clone())));
|
||||
let mut baseline = api::v1::region::region_client::RegionClient::connect(format!(
|
||||
"http://{}",
|
||||
server.addr
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let baseline: Error = baseline
|
||||
.handle(build_insert_request())
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap_err()
|
||||
.into();
|
||||
assert_eq!(baseline.status_code(), StatusCode::RegionNotFound);
|
||||
assert_eq!(baseline.retry_hint(), hint);
|
||||
assert_eq!(baseline.tonic_code(), Some(tonic::Code::FailedPrecondition));
|
||||
let owned = requester.handle(build_insert_request()).await.unwrap_err();
|
||||
let shared = requester
|
||||
.handle(Arc::new(build_insert_request()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
for error in [owned, shared] {
|
||||
assert_eq!(error.status_code(), baseline.status_code());
|
||||
assert_eq!(error.retry_hint(), baseline.retry_hint());
|
||||
assert_eq!(error.tonic_code(), baseline.tonic_code());
|
||||
}
|
||||
let datanode: &dyn Datanode = &requester;
|
||||
let error = datanode.handle(build_insert_request()).await.unwrap_err();
|
||||
assert_eq!(error.status_code(), StatusCode::RegionNotFound);
|
||||
assert_eq!(error.retry_hint(), hint);
|
||||
assert!(matches!(error, meta_error::Error::External { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_datanode_transport_retry_hints() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
let datanode: &dyn Datanode = &requester;
|
||||
let statuses = [
|
||||
tonic::Code::Unavailable,
|
||||
tonic::Code::Cancelled,
|
||||
tonic::Code::DeadlineExceeded,
|
||||
tonic::Code::FailedPrecondition,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|code| {
|
||||
[
|
||||
None,
|
||||
Some(RetryHint::NonRetryable),
|
||||
Some(RetryHint::Retryable),
|
||||
Some(RetryHint::RetryAfterRouteRefresh),
|
||||
]
|
||||
.into_iter()
|
||||
.map(move |hint| (code, hint))
|
||||
});
|
||||
let client = Client::with_urls([addr.to_string()]);
|
||||
let requester = RegionRequester::new(client.clone(), true, true);
|
||||
let send_only_requester = RegionRequester::new(client.clone(), true, false);
|
||||
let accept_only_requester = RegionRequester::new(client.clone(), false, true);
|
||||
let disabled_requester = RegionRequester::new(client, false, false);
|
||||
for (code, hint) in statuses {
|
||||
for body in [
|
||||
region_request::Body::Inserts(Default::default()),
|
||||
region_request::Body::Deletes(Default::default()),
|
||||
region_request::Body::BulkInsert(Default::default()),
|
||||
region_request::Body::Create(Default::default()),
|
||||
region_request::Body::RemoteDynFilter(Default::default()),
|
||||
] {
|
||||
let request_type = body.as_ref().to_string();
|
||||
let mut status = Status::new(code, "request failed");
|
||||
// Metadata status is deliberately different from the transport status.
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_CODE,
|
||||
(StatusCode::RegionNotReady as u32)
|
||||
.to_string()
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
if let Some(hint) = hint {
|
||||
status.metadata_mut().insert(
|
||||
common_error::GREPTIME_DB_HEADER_ERROR_RETRY_HINT,
|
||||
hint.as_str().parse().unwrap(),
|
||||
);
|
||||
}
|
||||
let original = Error::from(status.clone());
|
||||
let expected = if original.should_retry() && !original.retry_hint().is_retryable() {
|
||||
RetryHint::Retryable
|
||||
} else {
|
||||
original.retry_hint()
|
||||
};
|
||||
server
|
||||
.service
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([Err(status.clone()), Err(status)]);
|
||||
let request = RegionRequest {
|
||||
header: None,
|
||||
body: Some(body),
|
||||
};
|
||||
let shared = Arc::new(request);
|
||||
let shared_error = datanode.handle(Arc::clone(&shared)).await.unwrap_err();
|
||||
assert_eq!(Arc::strong_count(&shared), 1);
|
||||
let request = Arc::try_unwrap(shared).unwrap();
|
||||
let owned_error = datanode.handle(request).await.unwrap_err();
|
||||
for error in [owned_error, shared_error] {
|
||||
assert_eq!(
|
||||
error.retry_hint(),
|
||||
expected,
|
||||
"{code:?}, {hint:?}, {request_type}"
|
||||
);
|
||||
assert_eq!(error.retry_hint().is_retryable(), original.should_retry());
|
||||
assert_eq!(error.status_code(), StatusCode::RegionNotReady);
|
||||
let meta_error::Error::External { source, .. } = error else {
|
||||
panic!("expected an external error");
|
||||
};
|
||||
assert_eq!(source.retry_hint(), expected);
|
||||
let Error::RegionServer {
|
||||
source,
|
||||
code: actual_code,
|
||||
..
|
||||
} = source.as_any().downcast_ref::<Error>().unwrap()
|
||||
else {
|
||||
panic!("expected a region server error");
|
||||
};
|
||||
assert_eq!(*actual_code, code);
|
||||
assert_eq!(source.retry_hint(), original.retry_hint());
|
||||
assert_eq!(source.to_string(), original.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_request_preserves_response_header_errors() {
|
||||
let server = RegionTestServer::start().await;
|
||||
server.service.responses.lock().unwrap().extend([
|
||||
Ok(PbRegionResponse::default()),
|
||||
Ok(build_response(StatusCode::RegionNotFound)),
|
||||
]);
|
||||
let requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
let shared = Arc::new(build_insert_request());
|
||||
assert!(matches!(
|
||||
requester.handle(Arc::clone(&shared)).await.unwrap_err(),
|
||||
Error::IllegalDatabaseResponse { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
requester.handle(shared).await.unwrap_err(),
|
||||
Error::Server {
|
||||
code: StatusCode::RegionNotFound,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_request_respects_configured_message_limits() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let shared = Arc::new(build_insert_request());
|
||||
for send_limit in [true, false] {
|
||||
let mut config = ChannelConfig::default();
|
||||
if send_limit {
|
||||
config.max_send_message_size = "1B".parse().unwrap();
|
||||
} else {
|
||||
config.max_recv_message_size = "1B".parse().unwrap();
|
||||
}
|
||||
let mut baseline = api::v1::region::region_client::RegionClient::connect(format!(
|
||||
"http://{}",
|
||||
server.addr
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.max_encoding_message_size(config.max_send_message_size.as_bytes() as usize)
|
||||
.max_decoding_message_size(config.max_recv_message_size.as_bytes() as usize);
|
||||
let baseline: Error = baseline
|
||||
.handle(build_insert_request())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into();
|
||||
if !send_limit {
|
||||
assert_eq!(
|
||||
baseline.tonic_code(),
|
||||
Some(tonic::Code::OutOfRange),
|
||||
"{baseline:?}"
|
||||
);
|
||||
}
|
||||
let requester = server.create_requester(config, false, false);
|
||||
let error = requester.handle(Arc::clone(&shared)).await.unwrap_err();
|
||||
assert_eq!(
|
||||
error.tonic_code(),
|
||||
baseline.tonic_code(),
|
||||
"shared: {error:?}; generated: {baseline:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
error.status_code(),
|
||||
baseline.status_code(),
|
||||
"shared: {error:?}; generated: {baseline:?}"
|
||||
);
|
||||
let Error::RegionServer { source, .. } = &error else {
|
||||
panic!("expected a region server error: {error:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
source.retry_hint(),
|
||||
baseline.retry_hint(),
|
||||
"shared: {error:?}; generated: {baseline:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
server.service.requests.lock().unwrap().len(),
|
||||
if send_limit { 0 } else { 2 }
|
||||
);
|
||||
}
|
||||
let requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
requester.handle(shared).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inserts_and_bulk_insert_use_transport_compression() {
|
||||
let server = RegionTestServer::start().await;
|
||||
let requester = server.create_requester(ChannelConfig::default(), true, true);
|
||||
let send_only_requester = server.create_requester(ChannelConfig::default(), true, false);
|
||||
let accept_only_requester = server.create_requester(ChannelConfig::default(), false, true);
|
||||
let disabled_requester = server.create_requester(ChannelConfig::default(), false, false);
|
||||
let inserts = || RegionRequest {
|
||||
body: Some(region_request::Body::Inserts(Default::default())),
|
||||
..Default::default()
|
||||
@@ -536,9 +988,15 @@ mod test {
|
||||
(false, false),
|
||||
(false, false)
|
||||
],
|
||||
*zstd_headers.lock().unwrap()
|
||||
server
|
||||
.service
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(_, send, accept)| (*send, *accept))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -600,13 +1058,13 @@ mod test {
|
||||
let result = check_response_header(&None);
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
IllegalDatabaseResponse { .. }
|
||||
Error::IllegalDatabaseResponse { .. }
|
||||
));
|
||||
|
||||
let result = check_response_header(&Some(ResponseHeader { status: None }));
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
IllegalDatabaseResponse { .. }
|
||||
Error::IllegalDatabaseResponse { .. }
|
||||
));
|
||||
|
||||
let result = check_response_header(&Some(ResponseHeader {
|
||||
@@ -625,7 +1083,7 @@ mod test {
|
||||
}));
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
IllegalDatabaseResponse { .. }
|
||||
Error::IllegalDatabaseResponse { .. }
|
||||
));
|
||||
|
||||
let result = check_response_header(&Some(ResponseHeader {
|
||||
@@ -634,7 +1092,7 @@ mod test {
|
||||
err_msg: "blabla".to_string(),
|
||||
}),
|
||||
}));
|
||||
let Server { code, msg, .. } = result.unwrap_err() else {
|
||||
let Error::Server { code, msg, .. } = result.unwrap_err() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(code, StatusCode::Internal);
|
||||
@@ -719,7 +1177,34 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_batch_stream_updates_following_metrics_before_yielding_batch() {
|
||||
async fn test_record_batch_is_yielded_without_waiting_for_next_message() {
|
||||
let schema = test_schema();
|
||||
let batch = RecordBatch::new(
|
||||
schema.clone(),
|
||||
vec![Arc::new(Int32Vector::from_slice([1])) as VectorRef],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let messages = stream::iter(vec![
|
||||
Ok(FlightMessage::Schema(schema.arrow_schema().clone())),
|
||||
Ok(FlightMessage::RecordBatch(batch.into_df_record_batch())),
|
||||
])
|
||||
.chain(stream::pending::<Result<FlightMessage>>());
|
||||
let mut recordbatches =
|
||||
recordbatches_from_flight_message_stream("test-peer".to_string(), messages)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let batch = tokio::time::timeout(Duration::from_secs(1), recordbatches.next())
|
||||
.await
|
||||
.expect("the first batch must not wait for lookahead")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_malformed_region_metrics_are_non_fatal() {
|
||||
let schema = test_schema();
|
||||
let batch = RecordBatch::new(
|
||||
schema.clone(),
|
||||
@@ -730,23 +1215,19 @@ mod test {
|
||||
"test-peer".to_string(),
|
||||
stream::iter(vec![
|
||||
Ok(FlightMessage::Schema(schema.arrow_schema().clone())),
|
||||
Ok(FlightMessage::Metrics("{not-json}".to_string())),
|
||||
Ok(FlightMessage::RecordBatch(batch.into_df_record_batch())),
|
||||
Ok(FlightMessage::Metrics(test_metrics_json())),
|
||||
]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let batch = recordbatches.next().await.unwrap().unwrap();
|
||||
assert_eq!(batch.num_rows(), 1);
|
||||
|
||||
let metrics = recordbatches.metrics().unwrap();
|
||||
assert_eq!(metrics.elapsed_compute, 7);
|
||||
assert_eq!(recordbatches.next().await.unwrap().unwrap().num_rows(), 1);
|
||||
assert!(recordbatches.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_batch_stream_preserves_peeked_record_batch() {
|
||||
async fn test_record_batch_stream_preserves_following_record_batch() {
|
||||
let schema = test_schema();
|
||||
let first_batch = RecordBatch::new(
|
||||
schema.clone(),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::borrow::Borrow;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use api::v1::region::{RegionRequest, RegionResponse};
|
||||
use prost::Message;
|
||||
use tonic::Status;
|
||||
use tonic::codec::{Codec, EncodeBuf, Encoder};
|
||||
use tonic_prost::ProstDecoder;
|
||||
|
||||
/// Encodes owned or shared region requests using the existing protobuf wire format.
|
||||
pub(crate) struct RegionCodec<R>(PhantomData<R>);
|
||||
|
||||
impl<R> Default for RegionCodec<R> {
|
||||
fn default() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> Codec for RegionCodec<R>
|
||||
where
|
||||
R: Borrow<RegionRequest> + Send + 'static,
|
||||
{
|
||||
type Encode = R;
|
||||
type Decode = RegionResponse;
|
||||
type Encoder = RegionEncoder<R>;
|
||||
type Decoder = ProstDecoder<RegionResponse>;
|
||||
|
||||
fn encoder(&mut self) -> Self::Encoder {
|
||||
RegionEncoder(PhantomData)
|
||||
}
|
||||
|
||||
fn decoder(&mut self) -> Self::Decoder {
|
||||
ProstDecoder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrows the protobuf payload while consuming its owned or shared container.
|
||||
pub(crate) struct RegionEncoder<R>(PhantomData<R>);
|
||||
|
||||
impl<R: Borrow<RegionRequest>> Encoder for RegionEncoder<R> {
|
||||
type Item = R;
|
||||
type Error = Status;
|
||||
|
||||
fn encode(&mut self, item: R, buf: &mut EncodeBuf<'_>) -> Result<(), Status> {
|
||||
item.borrow()
|
||||
.encode(buf)
|
||||
.map_err(|error| Status::internal(error.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,261 @@ fn test_load_standalone_example_config() {
|
||||
similar_asserts::assert_eq!(options, expected);
|
||||
}
|
||||
|
||||
/// Renders an example config with its `#+` options enabled, as `config.md`
|
||||
/// lists them. With `drop_none_default`, options annotated with
|
||||
/// `@toml2docs:none-default` are removed, because `config.md` does not present
|
||||
/// their example value as the default.
|
||||
fn render_example(example: &str, drop_none_default: bool) -> String {
|
||||
let mut rendered = String::new();
|
||||
let mut none_default = false;
|
||||
for line in example.lines() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("##") {
|
||||
none_default |= line.contains("@toml2docs:none-default");
|
||||
continue;
|
||||
}
|
||||
let line = line.strip_prefix("#+").map_or(line, str::trim_start);
|
||||
let is_option = !line.starts_with('[') && line.contains('=');
|
||||
if !(is_option && drop_none_default && none_default) {
|
||||
rendered.push_str(line);
|
||||
rendered.push('\n');
|
||||
}
|
||||
none_default = false;
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
/// Flattens leaf values into dotted paths. Arrays of tables such as
|
||||
/// `region_engine` are merged, since each element is keyed by its engine name.
|
||||
fn leaf_values(value: &toml::Value, prefix: &str, leaves: &mut Vec<(String, toml::Value)>) {
|
||||
match value {
|
||||
toml::Value::Table(table) => {
|
||||
for (key, value) in table {
|
||||
let path = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix}.{key}")
|
||||
};
|
||||
leaf_values(value, &path, leaves);
|
||||
}
|
||||
}
|
||||
toml::Value::Array(array)
|
||||
if array.iter().all(toml::Value::is_table) && !array.is_empty() =>
|
||||
{
|
||||
for element in array {
|
||||
leaf_values(element, prefix, leaves);
|
||||
}
|
||||
}
|
||||
_ => leaves.push((prefix.to_string(), value.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the option at a dotted path produced by [`leaf_values`].
|
||||
fn remove_leaf(value: &mut toml::Value, path: &str) -> bool {
|
||||
match value {
|
||||
toml::Value::Array(array) => array.iter_mut().any(|element| remove_leaf(element, path)),
|
||||
toml::Value::Table(table) => match path.split_once('.') {
|
||||
None => table.remove(path).is_some(),
|
||||
Some((head, rest)) => table
|
||||
.get_mut(head)
|
||||
.is_some_and(|value| remove_leaf(value, rest)),
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_as_leaves<T>(
|
||||
config: Option<&str>,
|
||||
) -> common_config::error::Result<std::collections::HashMap<String, toml::Value>>
|
||||
where
|
||||
T: Configurable + serde::Serialize,
|
||||
{
|
||||
let file = config.map(|config| {
|
||||
let mut file = create_named_temp_file();
|
||||
file.write_all(config.as_bytes()).unwrap();
|
||||
file
|
||||
});
|
||||
let options = GreptimeOptions::<T>::load_layered_options(
|
||||
file.as_ref().map(|file| file.path().to_str().unwrap()),
|
||||
"",
|
||||
)?;
|
||||
let mut leaves = Vec::new();
|
||||
leaf_values(&toml::Value::try_from(&options).unwrap(), "", &mut leaves);
|
||||
Ok(leaves.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Returns the default of an option inside the example's other settings, so
|
||||
/// that options in sections absent by default still have one to compare with.
|
||||
/// Removes the closest enclosing table that still loads without the option.
|
||||
fn default_within_example<T>(full: &toml::Value, path: &str) -> Option<toml::Value>
|
||||
where
|
||||
T: Configurable + serde::Serialize,
|
||||
{
|
||||
let mut removed = path;
|
||||
loop {
|
||||
let mut without = full.clone();
|
||||
assert!(remove_leaf(&mut without, removed));
|
||||
if let Ok(defaults) = load_as_leaves::<T>(Some(&toml::to_string(&without).unwrap())) {
|
||||
return defaults.get(path).cloned();
|
||||
}
|
||||
removed = removed
|
||||
.rsplit_once('.')
|
||||
.unwrap_or_else(|| panic!("the example does not load without `{path}`"))
|
||||
.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that every option in an example config exists, and that every value
|
||||
/// `config.md` presents as a default is the actual default.
|
||||
///
|
||||
/// `variants` lists tagged sections, e.g. `("wal", "provider", ["kafka"])`,
|
||||
/// whose options only deserialize under a specific tag value.
|
||||
fn check_example_defaults<T>(name: &str, variants: &[(&str, &str, &[&str])]) -> Vec<String>
|
||||
where
|
||||
T: Configurable + serde::Serialize,
|
||||
{
|
||||
let example = std::fs::read_to_string(common_test_util::find_workspace_path(&format!(
|
||||
"config/{name}"
|
||||
)))
|
||||
.unwrap();
|
||||
let full: toml::Value = toml::from_str(&render_example(&example, false)).unwrap();
|
||||
let documented: toml::Value = toml::from_str(&render_example(&example, true)).unwrap();
|
||||
|
||||
// (options loaded from the example, options loaded without config) per variant
|
||||
let mut loaded = vec![(
|
||||
load_as_leaves::<T>(Some(&toml::to_string(&full).unwrap())).unwrap(),
|
||||
load_as_leaves::<T>(None).unwrap(),
|
||||
)];
|
||||
for (section, tag, values) in variants {
|
||||
for value in *values {
|
||||
let mut variant = full.clone();
|
||||
variant[section][tag] = toml::Value::String(value.to_string());
|
||||
loaded.push((
|
||||
load_as_leaves::<T>(Some(&toml::to_string(&variant).unwrap())).unwrap(),
|
||||
load_as_leaves::<T>(Some(&format!("[{section}]\n{tag} = \"{value}\"\n"))).unwrap(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize-only fields, invisible in the serialized options.
|
||||
const SKIP_SERIALIZING: &[&str] = &[
|
||||
"storage.access_key_id",
|
||||
"storage.secret_access_key",
|
||||
"storage.access_key_secret",
|
||||
"storage.account_name",
|
||||
"storage.account_key",
|
||||
"storage.credential_path",
|
||||
"storage.credential",
|
||||
"region_engine.mito.inverted_index.intermediate_path",
|
||||
];
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut full_leaves = Vec::new();
|
||||
leaf_values(&full, "", &mut full_leaves);
|
||||
for (path, _) in full_leaves {
|
||||
if SKIP_SERIALIZING.contains(&path.as_str())
|
||||
|| (path == "tracing.tokio_console_addr" && !cfg!(feature = "tokio-console"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !loaded
|
||||
.iter()
|
||||
.any(|(example, _)| example.contains_key(&path))
|
||||
{
|
||||
errors.push(format!("{name}: `{path}` is not a known option"));
|
||||
}
|
||||
}
|
||||
|
||||
// Options left unset by default and resolved where they are used.
|
||||
let resolved_at_runtime: [(&str, toml::Value); 7] = [
|
||||
(
|
||||
"logging.dir",
|
||||
format!("{DEFAULT_DATA_HOME}/{DEFAULT_LOGGING_DIR}").into(),
|
||||
),
|
||||
("logging.otlp_endpoint", DEFAULT_OTLP_HTTP_ENDPOINT.into()),
|
||||
("logging.otlp_export_protocol", "http".into()),
|
||||
("logging.tracing_sample_ratio.default_ratio", 1.0.into()),
|
||||
("wal.sync_period", "5s".into()),
|
||||
(
|
||||
"storage.copy_root",
|
||||
format!("{DEFAULT_DATA_HOME}/copy").into(),
|
||||
),
|
||||
("flow.batching_mode.frontend_tls.enabled", false.into()),
|
||||
];
|
||||
|
||||
let mut documented_leaves = Vec::new();
|
||||
leaf_values(&documented, "", &mut documented_leaves);
|
||||
for (path, documented_value) in documented_leaves {
|
||||
if SKIP_SERIALIZING.contains(&path.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, resolved)) = resolved_at_runtime.iter().find(|(p, _)| *p == path) {
|
||||
if &documented_value != resolved {
|
||||
errors.push(format!(
|
||||
"{name}: `{path}` is documented as default `{documented_value}`, but resolves to `{resolved}`"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut pairs = loaded
|
||||
.iter()
|
||||
.filter_map(|(example, default)| Some((example.get(&path)?, default.get(&path)?)))
|
||||
.map(|(example, default)| (example.clone(), default.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if pairs.is_empty() {
|
||||
// The option sits in a section that is absent without config, or
|
||||
// is itself optional.
|
||||
match default_within_example::<T>(&full, &path) {
|
||||
Some(default) => pairs.push((loaded[0].0[&path].clone(), default)),
|
||||
None => {
|
||||
errors.push(format!(
|
||||
"{name}: `{path}` has no default to compare with; annotate it with \
|
||||
`@toml2docs:none-default` or list its runtime default in this test"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pairs.iter().all(|(example, default)| example != default) {
|
||||
let (example, default) = &pairs[0];
|
||||
errors.push(format!(
|
||||
"{name}: `{path}` is documented as default `{example}`, but the default is `{default}`; \
|
||||
fix the value or annotate it with `@toml2docs:none-default`"
|
||||
));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
// The examples document HDFS storage options.
|
||||
#[cfg(feature = "hdfs-object-store")]
|
||||
#[test]
|
||||
fn test_example_configs_document_actual_defaults() {
|
||||
const WAL: (&str, &str, &[&str]) = ("wal", "provider", &["kafka", "experimental_object_store"]);
|
||||
const STORAGE: (&str, &str, &[&str]) =
|
||||
("storage", "type", &["S3", "Oss", "Azblob", "Gcs", "Hdfs"]);
|
||||
|
||||
let mut errors =
|
||||
check_example_defaults::<StandaloneOptions>("standalone.example.toml", &[WAL, STORAGE]);
|
||||
errors.extend(check_example_defaults::<FrontendOptions>(
|
||||
"frontend.example.toml",
|
||||
&[],
|
||||
));
|
||||
errors.extend(check_example_defaults::<DatanodeOptions>(
|
||||
"datanode.example.toml",
|
||||
&[WAL, STORAGE],
|
||||
));
|
||||
errors.extend(check_example_defaults::<MetasrvOptions>(
|
||||
"metasrv.example.toml",
|
||||
&[("wal", "provider", &["kafka"])],
|
||||
));
|
||||
errors.extend(check_example_defaults::<FlownodeOptions>(
|
||||
"flownode.example.toml",
|
||||
&[],
|
||||
));
|
||||
assert!(errors.is_empty(), "{}", errors.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_removed_histogram_options() {
|
||||
for enabled in [false, true] {
|
||||
|
||||
@@ -14,6 +14,7 @@ async-trait.workspace = true
|
||||
bitvec = "1.0"
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
icu_properties.workspace = true
|
||||
lazy_static.workspace = true
|
||||
pin-project.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod readable_size;
|
||||
pub mod regex_pattern;
|
||||
pub mod secrets;
|
||||
pub mod serde;
|
||||
pub mod term_token;
|
||||
|
||||
pub type AffectedRows = usize;
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Word boundaries shared by `matches_term` and the fulltext bloom index.
|
||||
//!
|
||||
//! Bloom pruning is only sound if the index splits text exactly where
|
||||
//! `matches_term` sees word boundaries, so both use [`classify_char`], and the
|
||||
//! query side derives its probes with [`extract_term_probes`] and [`extract_like_probes`].
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use icu_properties::props::Script;
|
||||
use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
|
||||
|
||||
/// Classes of characters that decide where `matches_term` sees a word boundary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CharClass {
|
||||
/// `[0-9A-Za-z]`.
|
||||
AsciiWord,
|
||||
/// Characters of the Han script, alphanumeric or not.
|
||||
Han,
|
||||
/// Non-ASCII alphanumerics outside the Han script, such as `é` or `д`.
|
||||
UnicodeWord,
|
||||
/// Everything else, including `_`.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Returns the [`CharClass`] of `c`. [`tokenize`] splits text wherever the class
|
||||
/// changes and at every [`CharClass::Other`] character.
|
||||
pub fn classify_char(c: char) -> CharClass {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
CharClass::AsciiWord
|
||||
} else if is_han(c) {
|
||||
CharClass::Han
|
||||
} else if c.is_alphanumeric() {
|
||||
CharClass::UnicodeWord
|
||||
} else {
|
||||
CharClass::Other
|
||||
}
|
||||
}
|
||||
|
||||
static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
|
||||
CodePointMapData::<Script>::new();
|
||||
|
||||
fn is_han(c: char) -> bool {
|
||||
HAN_SCRIPT_DATA.get(c) == Script::Han
|
||||
}
|
||||
|
||||
/// Returns whether lowercasing `text` as a whole can produce tokens that lowercasing
|
||||
/// each token of `text` doesn't, which `matches_term(lower(col), ..)` and
|
||||
/// `lower(col) LIKE ..` rely on. Every other character lowercases to one character
|
||||
/// of the same class, but U+0130 and U+212A change length or class, and `Σ`
|
||||
/// lowercases depending on the characters after it.
|
||||
pub fn has_irregular_lowercase(text: &str) -> bool {
|
||||
!text.is_ascii() && text.contains(['\u{130}', '\u{212A}', 'Σ'])
|
||||
}
|
||||
|
||||
/// Returns the tokens the fulltext bloom index stores for `text`.
|
||||
///
|
||||
/// A token is a maximal run of ASCII alphanumerics or of non-Han alphanumerics;
|
||||
/// `_` and other symbols only separate tokens. A run of Han characters is
|
||||
/// emitted as its bigrams, or as itself when it has a single character, since
|
||||
/// `matches_term` matches Han terms as plain substrings.
|
||||
pub fn tokenize(text: &str) -> Vec<&str> {
|
||||
let mut tokens = Vec::new();
|
||||
for_each_word_run(text, |class, range| {
|
||||
let run = &text[range];
|
||||
if class == CharClass::Han {
|
||||
push_han_tokens(run, &mut tokens);
|
||||
} else {
|
||||
tokens.push(run);
|
||||
}
|
||||
});
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Calls `f` on every maximal run of word characters of the same class.
|
||||
fn for_each_word_run(text: &str, mut f: impl FnMut(CharClass, Range<usize>)) {
|
||||
if text.is_ascii() {
|
||||
// ASCII text only has `AsciiWord` and `Other` characters, so scan bytes
|
||||
// instead of decoding chars. Most log lines take this path.
|
||||
let mut start = None;
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
match (b.is_ascii_alphanumeric(), start) {
|
||||
(true, None) => start = Some(i),
|
||||
(false, Some(s)) => {
|
||||
f(CharClass::AsciiWord, s..i);
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
f(CharClass::AsciiWord, s..text.len());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut run: Option<(CharClass, usize)> = None;
|
||||
for (i, c) in text.char_indices() {
|
||||
let class = classify_char(c);
|
||||
if let Some((run_class, start)) = run {
|
||||
if run_class == class {
|
||||
continue;
|
||||
}
|
||||
f(run_class, start..i);
|
||||
run = None;
|
||||
}
|
||||
if class != CharClass::Other {
|
||||
run = Some((class, i));
|
||||
}
|
||||
}
|
||||
if let Some((run_class, start)) = run {
|
||||
f(run_class, start..text.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_han_tokens<'a>(run: &'a str, tokens: &mut Vec<&'a str>) {
|
||||
let mut starts = run.char_indices().map(|(i, _)| i).skip(1);
|
||||
let Some(mut second) = starts.next() else {
|
||||
tokens.push(run);
|
||||
return;
|
||||
};
|
||||
let mut first = 0;
|
||||
for next in starts.chain(std::iter::once(run.len())) {
|
||||
tokens.push(&run[first..next]);
|
||||
first = second;
|
||||
second = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns tokens that every text satisfying `matches_term(text, term)` contains,
|
||||
/// as produced by [`tokenize`].
|
||||
pub fn extract_term_probes(term: &str) -> Vec<String> {
|
||||
// Non-Han terms only match between word boundaries that `tokenize` also
|
||||
// splits at, so their edge runs are whole tokens. Han-containing terms match
|
||||
// as plain substrings and give no such guarantee at their edges.
|
||||
let bounded = !term.chars().any(|c| classify_char(c) == CharClass::Han);
|
||||
let mut probes = Vec::new();
|
||||
push_literal_probes(term, bounded, bounded, &mut probes);
|
||||
probes
|
||||
}
|
||||
|
||||
/// Returns tokens that every text satisfying `text LIKE pattern` contains, as
|
||||
/// produced by [`tokenize`]. `\` escapes the next character, following
|
||||
/// arrow's `like` kernel; a trailing `\` is a literal backslash.
|
||||
pub fn extract_like_probes(pattern: &str) -> Vec<String> {
|
||||
let mut probes = Vec::new();
|
||||
let mut literal = String::new();
|
||||
// The pattern start and end are anchors; a wildcard can stand for word
|
||||
// characters, so a run touching it may be part of a longer token.
|
||||
let mut anchored_left = true;
|
||||
let mut chars = pattern.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'%' | '_' => {
|
||||
push_literal_probes(&literal, anchored_left, false, &mut probes);
|
||||
literal.clear();
|
||||
anchored_left = false;
|
||||
}
|
||||
'\\' => literal.push(chars.next().unwrap_or('\\')),
|
||||
c => literal.push(c),
|
||||
}
|
||||
}
|
||||
push_literal_probes(&literal, anchored_left, true, &mut probes);
|
||||
probes
|
||||
}
|
||||
|
||||
/// Pushes the tokens of `literal` that stay whole tokens wherever `literal`
|
||||
/// occurs. A side is bounded when the character beyond it can't extend the
|
||||
/// adjacent run.
|
||||
fn push_literal_probes(
|
||||
literal: &str,
|
||||
left_bounded: bool,
|
||||
right_bounded: bool,
|
||||
probes: &mut Vec<String>,
|
||||
) {
|
||||
for_each_word_run(literal, |class, range| {
|
||||
let whole =
|
||||
(range.start > 0 || left_bounded) && (range.end < literal.len() || right_bounded);
|
||||
let run = &literal[range];
|
||||
if class == CharClass::Han && run.chars().nth(1).is_some() {
|
||||
// Bigrams inside the run occur in every text containing it.
|
||||
let mut tokens = Vec::new();
|
||||
push_han_tokens(run, &mut tokens);
|
||||
probes.extend(tokens.into_iter().map(str::to_string));
|
||||
} else if whole {
|
||||
probes.push(run.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn han_detection_uses_script_not_all_cjk() {
|
||||
assert!(is_han('汉'));
|
||||
assert!(is_han('\u{30000}'));
|
||||
assert!(!is_han('あ'));
|
||||
assert!(!is_han('한'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_script_tokenizer() {
|
||||
let cases: [(&str, &[&str]); 7] = [
|
||||
("hello_world __x__", &["hello", "world", "x"]),
|
||||
("trace_id=abc", &["trace", "id", "abc"]),
|
||||
("错误error日志", &["错误", "error", "日志"]),
|
||||
("连接timeout.5次", &["连接", "timeout", "5", "次"]),
|
||||
("用户user-123登录", &["用户", "user", "123", "登录"]),
|
||||
("中国农业银行", &["中国", "国农", "农业", "业银", "银行"]),
|
||||
("Größe日x", &["Gr", "öß", "e", "日", "x"]),
|
||||
];
|
||||
for (text, expected) in cases {
|
||||
assert_eq!(tokenize(text), expected, "text: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_irregular_lowercase_chars() {
|
||||
for c in (0..=0x10FFFF).filter_map(char::from_u32) {
|
||||
let lower = c.to_lowercase().collect::<Vec<_>>();
|
||||
let regular = lower.len() == 1 && classify_char(lower[0]) == classify_char(c);
|
||||
let expect_regular = !matches!(c, '\u{130}' | '\u{212A}');
|
||||
assert_eq!(regular, expect_regular, "U+{:04X}", c as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_term_probes() {
|
||||
let cases: [(&str, &[&str]); 8] = [
|
||||
("world", &["world"]),
|
||||
("hello world", &["hello", "world"]),
|
||||
("/start", &["start"]),
|
||||
("naïve", &["na", "ï", "ve"]),
|
||||
// Han terms match as substrings: only bigrams and runs enclosed by
|
||||
// other characters of the term are safe.
|
||||
("农业", &["农业"]),
|
||||
("农", &[]),
|
||||
("机号1888", &["机号"]),
|
||||
("x错误y日z", &["错误", "y", "日"]),
|
||||
];
|
||||
for (term, expected) in cases {
|
||||
assert_eq!(extract_term_probes(term), expected, "term: {term}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_like_probes() {
|
||||
let cases: [(&str, &[&str]); 11] = [
|
||||
("%timeout%", &[]),
|
||||
("% timeout %", &["timeout"]),
|
||||
("timeout%", &[]),
|
||||
("timeout: %", &["timeout"]),
|
||||
("%: timeout", &["timeout"]),
|
||||
("a_b c", &["c"]),
|
||||
(r"trace\_id=%", &["trace", "id"]),
|
||||
(r"100\% done", &["100", "done"]),
|
||||
(r"x\\y", &["x", "y"]),
|
||||
("%错误error日%", &["错误", "error"]),
|
||||
("%错误 x%", &["错误"]),
|
||||
];
|
||||
for (pattern, expected) in cases {
|
||||
assert_eq!(extract_like_probes(pattern), expected, "pattern: {pattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ use crate::status_code::StatusCode;
|
||||
pub enum RetryHint {
|
||||
/// The operation may succeed if retried later.
|
||||
Retryable,
|
||||
/// The operation may succeed after refreshing routing and partition information.
|
||||
/// The request may need rebuilding when its partition assignment changes.
|
||||
/// This hint does not imply that a failed operation had no side effects.
|
||||
RetryAfterRouteRefresh,
|
||||
/// Retrying the same operation is not expected to help.
|
||||
///
|
||||
/// This is the default for errors that do not explicitly opt in to retry.
|
||||
@@ -53,16 +57,23 @@ pub enum RetryHint {
|
||||
}
|
||||
|
||||
const RETRY_HINT_RETRYABLE: &str = "retryable";
|
||||
const RETRY_HINT_AFTER_ROUTE_REFRESH: &str = "retry_after_route_refresh";
|
||||
const RETRY_HINT_NON_RETRYABLE: &str = "non_retryable";
|
||||
|
||||
impl RetryHint {
|
||||
/// Returns whether another attempt may succeed, possibly after refreshing routes.
|
||||
pub fn is_retryable(self) -> bool {
|
||||
matches!(self, RetryHint::Retryable)
|
||||
matches!(
|
||||
self,
|
||||
RetryHint::Retryable | RetryHint::RetryAfterRouteRefresh
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the retry hint encoded in gRPC error metadata.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RetryHint::Retryable => RETRY_HINT_RETRYABLE,
|
||||
RetryHint::RetryAfterRouteRefresh => RETRY_HINT_AFTER_ROUTE_REFRESH,
|
||||
RetryHint::NonRetryable => RETRY_HINT_NON_RETRYABLE,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +101,7 @@ impl FromStr for RetryHint {
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
RETRY_HINT_RETRYABLE => Ok(RetryHint::Retryable),
|
||||
RETRY_HINT_AFTER_ROUTE_REFRESH => Ok(RetryHint::RetryAfterRouteRefresh),
|
||||
RETRY_HINT_NON_RETRYABLE => Ok(RetryHint::NonRetryable),
|
||||
_ => Err(()),
|
||||
}
|
||||
@@ -133,7 +145,9 @@ pub trait ErrorExt: StackError {
|
||||
///
|
||||
/// Implementations should return [`RetryHint::Retryable`] only when retrying the
|
||||
/// same operation may succeed without changing the request. The default is
|
||||
/// [`RetryHint::NonRetryable`] to avoid accidental retry loops.
|
||||
/// [`RetryHint::NonRetryable`] to avoid accidental retry loops. Use
|
||||
/// [`RetryHint::RetryAfterRouteRefresh`] when routing or partition information
|
||||
/// must be refreshed before another attempt.
|
||||
fn retry_hint(&self) -> RetryHint {
|
||||
RetryHint::NonRetryable
|
||||
}
|
||||
|
||||
@@ -164,6 +164,15 @@ fn test_transparent_flag() {
|
||||
#[test]
|
||||
fn test_retry_hint_helpers() {
|
||||
assert!(RetryHint::Retryable.is_retryable());
|
||||
assert!(RetryHint::RetryAfterRouteRefresh.is_retryable());
|
||||
assert_eq!(
|
||||
RetryHint::RetryAfterRouteRefresh.as_str(),
|
||||
"retry_after_route_refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
RetryHint::from_str("retry_after_route_refresh").unwrap(),
|
||||
RetryHint::RetryAfterRouteRefresh
|
||||
);
|
||||
assert!(!RetryHint::NonRetryable.is_retryable());
|
||||
|
||||
assert_eq!(RetryHint::Retryable.as_str(), "retryable");
|
||||
|
||||
@@ -46,7 +46,6 @@ geo-types = { version = "0.7", optional = true }
|
||||
geohash = { version = "0.13", optional = true }
|
||||
h3o = { version = "0.6", optional = true }
|
||||
hyperloglogplus = "0.4"
|
||||
icu_properties.workspace = true
|
||||
jsonb.workspace = true
|
||||
memchr = "2.7"
|
||||
mito-codec.workspace = true
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod approximate;
|
||||
pub mod count_hash;
|
||||
pub mod extremum;
|
||||
#[cfg(feature = "geo")]
|
||||
pub mod geo;
|
||||
pub mod vector;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! `foo_merge`'s input arg is the same as `foo_state`'s output, and its output is the same as `foo`'s input.
|
||||
//!
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -183,7 +184,13 @@ impl StateMergeHelper {
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut lower_aggr_exprs = vec![];
|
||||
// (unaliased state expression, output column name)
|
||||
let mut lower_states: Vec<(Expr, String)> = vec![];
|
||||
let mut lower_output_names: HashSet<String> = aggr
|
||||
.group_expr
|
||||
.iter()
|
||||
.map(|e| e.qualified_name().1)
|
||||
.collect();
|
||||
let mut upper_aggr_exprs = vec![];
|
||||
|
||||
// group exprs for upper plan should refer to the output group expr as column from lower plan
|
||||
@@ -218,9 +225,28 @@ impl StateMergeHelper {
|
||||
params: aggr_func.params.clone(),
|
||||
};
|
||||
let expr = Expr::AggregateFunction(expr);
|
||||
let lower_state_output_col_name = expr.schema_name().to_string();
|
||||
|
||||
lower_aggr_exprs.push(expr);
|
||||
// The state column is named after the unaliased state expression, which can
|
||||
// repeat even though the original output names are unique: a name-preserving
|
||||
// rewrite such as `count(*)` -> `count(ts) AS count(*)` can make two aggregates
|
||||
// share one state expression, and schema names hide casts, so
|
||||
// `count(CAST(ts AS Int64))` is named `count(t.ts)` too. Identical states are
|
||||
// computed once; a different state with a taken name gets a numeric suffix.
|
||||
let lower_state_output_col_name =
|
||||
if let Some((_, name)) = lower_states.iter().find(|(e, _)| *e == expr) {
|
||||
name.clone()
|
||||
} else {
|
||||
let base = expr.schema_name().to_string();
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 0;
|
||||
while lower_output_names.contains(&name) {
|
||||
suffix += 1;
|
||||
name = format!("{base}_{suffix}");
|
||||
}
|
||||
lower_output_names.insert(name.clone());
|
||||
lower_states.push((expr, name.clone()));
|
||||
name
|
||||
};
|
||||
|
||||
// then create the merge function using the physical expression of the original aggregate function
|
||||
let (name, human_display) = match aggr_expr {
|
||||
@@ -270,7 +296,16 @@ impl StateMergeHelper {
|
||||
}
|
||||
|
||||
let mut lower = aggr.clone();
|
||||
lower.aggr_expr = lower_aggr_exprs;
|
||||
lower.aggr_expr = lower_states
|
||||
.into_iter()
|
||||
.map(|(expr, name)| {
|
||||
if expr.schema_name().to_string() == name {
|
||||
expr
|
||||
} else {
|
||||
expr.alias(name)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let lower_plan = LogicalPlan::Aggregate(lower);
|
||||
|
||||
// update aggregate's output schema
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! PromQL `min` and `max` aggregation operators.
|
||||
//!
|
||||
//! PromQL ignores NaN samples unless every sample of a group is NaN. DataFusion's
|
||||
//! `min`/`max` order NaN differently depending on the code path (its grouped
|
||||
//! accumulator lets NaN replace the current value and be replaced by the next one),
|
||||
//! so the result depends on the order of the input.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use datafusion::arrow::array::{ArrayRef, AsArray};
|
||||
use datafusion::arrow::datatypes::{DataType, Field, FieldRef, Float64Type};
|
||||
use datafusion::error::Result as DfResult;
|
||||
use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs};
|
||||
use datafusion::logical_expr::{
|
||||
Accumulator, AggregateUDF, AggregateUDFImpl, GroupsAccumulator, Signature, Volatility,
|
||||
};
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_functions_aggregate_common::aggregate::groups_accumulator::prim_op::PrimitiveGroupsAccumulator;
|
||||
|
||||
use crate::function_registry::FunctionRegistry;
|
||||
|
||||
/// `max` or `min` with PromQL NaN semantics.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Extremum {
|
||||
name: &'static str,
|
||||
is_max: bool,
|
||||
signature: Signature,
|
||||
}
|
||||
|
||||
impl Extremum {
|
||||
pub const MAX_NAME: &'static str = "prom_max";
|
||||
pub const MIN_NAME: &'static str = "prom_min";
|
||||
|
||||
/// Registers both functions; the registry also derives their state functions, which
|
||||
/// distributed plans need to split the aggregation between datanodes and the frontend.
|
||||
pub fn register(registry: &FunctionRegistry) {
|
||||
registry.register_aggr(Self::max_udaf());
|
||||
registry.register_aggr(Self::min_udaf());
|
||||
}
|
||||
|
||||
pub fn max_udaf() -> AggregateUDF {
|
||||
AggregateUDF::from(Self::new(Self::MAX_NAME, true))
|
||||
}
|
||||
|
||||
pub fn min_udaf() -> AggregateUDF {
|
||||
AggregateUDF::from(Self::new(Self::MIN_NAME, false))
|
||||
}
|
||||
|
||||
fn new(name: &'static str, is_max: bool) -> Self {
|
||||
Self {
|
||||
name,
|
||||
is_max,
|
||||
signature: Signature::exact(vec![DataType::Float64], Volatility::Immutable),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds `value` into `current` like Prometheus' aggregation: a NaN is replaced by any
|
||||
/// sample, and a NaN sample never replaces a number.
|
||||
fn fold(is_max: bool, current: &mut f64, value: f64) {
|
||||
let better = if is_max {
|
||||
value > *current
|
||||
} else {
|
||||
value < *current
|
||||
};
|
||||
if current.is_nan() || better {
|
||||
*current = value;
|
||||
}
|
||||
}
|
||||
|
||||
impl AggregateUDFImpl for Extremum {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn signature(&self) -> &Signature {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
fn return_type(&self, _arg_types: &[DataType]) -> DfResult<DataType> {
|
||||
Ok(DataType::Float64)
|
||||
}
|
||||
|
||||
fn accumulator(&self, _acc_args: AccumulatorArgs) -> DfResult<Box<dyn Accumulator>> {
|
||||
Ok(Box::new(ExtremumAccumulator {
|
||||
is_max: self.is_max,
|
||||
value: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn state_fields(&self, args: StateFieldsArgs) -> DfResult<Vec<FieldRef>> {
|
||||
Ok(vec![Arc::new(Field::new(
|
||||
format!("{}[{}]", args.name, self.name),
|
||||
DataType::Float64,
|
||||
true,
|
||||
))])
|
||||
}
|
||||
|
||||
fn groups_accumulator_supported(&self, _args: AccumulatorArgs) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn create_groups_accumulator(
|
||||
&self,
|
||||
_args: AccumulatorArgs,
|
||||
) -> DfResult<Box<dyn GroupsAccumulator>> {
|
||||
Ok(groups_accumulator(self.is_max))
|
||||
}
|
||||
}
|
||||
|
||||
fn groups_accumulator(is_max: bool) -> Box<dyn GroupsAccumulator> {
|
||||
Box::new(
|
||||
PrimitiveGroupsAccumulator::<Float64Type, _>::new(&DataType::Float64, move |cur, v| {
|
||||
fold(is_max, cur, v)
|
||||
})
|
||||
.with_starting_value(f64::NAN),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExtremumAccumulator {
|
||||
is_max: bool,
|
||||
value: Option<f64>,
|
||||
}
|
||||
|
||||
impl ExtremumAccumulator {
|
||||
fn update(&mut self, values: &ArrayRef) {
|
||||
for v in values.as_primitive::<Float64Type>().iter().flatten() {
|
||||
match &mut self.value {
|
||||
Some(current) => fold(self.is_max, current, v),
|
||||
None => self.value = Some(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Accumulator for ExtremumAccumulator {
|
||||
fn update_batch(&mut self, values: &[ArrayRef]) -> DfResult<()> {
|
||||
self.update(&values[0]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_batch(&mut self, states: &[ArrayRef]) -> DfResult<()> {
|
||||
self.update(&states[0]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn state(&mut self) -> DfResult<Vec<ScalarValue>> {
|
||||
Ok(vec![ScalarValue::Float64(self.value)])
|
||||
}
|
||||
|
||||
fn evaluate(&mut self) -> DfResult<ScalarValue> {
|
||||
Ok(ScalarValue::Float64(self.value))
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
size_of_val(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use datafusion::arrow::array::Float64Array;
|
||||
use datafusion::logical_expr::EmitTo;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn groups_result(
|
||||
is_max: bool,
|
||||
values: Vec<Option<f64>>,
|
||||
groups: Vec<usize>,
|
||||
) -> Vec<Option<f64>> {
|
||||
let num_groups = groups.iter().max().map_or(0, |g| g + 1);
|
||||
let mut acc = groups_accumulator(is_max);
|
||||
let values: ArrayRef = Arc::new(Float64Array::from(values));
|
||||
acc.update_batch(&[values], &groups, None, num_groups)
|
||||
.unwrap();
|
||||
let out = acc.evaluate(EmitTo::All).unwrap();
|
||||
out.as_primitive::<Float64Type>().iter().collect()
|
||||
}
|
||||
|
||||
fn is_nan(v: Option<f64>) -> bool {
|
||||
v.is_some_and(f64::is_nan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_is_ignored_unless_the_group_is_all_nan() {
|
||||
// Group 0 has NaN on both sides of a number; group 1 is all NaN; group 2 has only NULL.
|
||||
let values = vec![
|
||||
Some(f64::NAN),
|
||||
Some(3.0),
|
||||
Some(f64::NAN),
|
||||
Some(f64::NAN),
|
||||
None,
|
||||
];
|
||||
let groups = vec![0, 0, 0, 1, 2];
|
||||
for is_max in [true, false] {
|
||||
let out = groups_result(is_max, values.clone(), groups.clone());
|
||||
assert_eq!(out[0], Some(3.0));
|
||||
assert!(is_nan(out[1]));
|
||||
assert_eq!(out[2], None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_does_not_depend_on_input_order() {
|
||||
let forward = vec![Some(1.0), Some(f64::NAN), Some(4.0), Some(2.0)];
|
||||
let mut backward = forward.clone();
|
||||
backward.reverse();
|
||||
assert_eq!(
|
||||
groups_result(true, forward.clone(), vec![0; 4]),
|
||||
vec![Some(4.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
groups_result(true, backward.clone(), vec![0; 4]),
|
||||
vec![Some(4.0)]
|
||||
);
|
||||
assert_eq!(groups_result(false, forward, vec![0; 4]), vec![Some(1.0)]);
|
||||
assert_eq!(groups_result(false, backward, vec![0; 4]), vec![Some(1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_partial_states_keep_nan_semantics() {
|
||||
let mut partial_nan = ExtremumAccumulator {
|
||||
is_max: true,
|
||||
value: None,
|
||||
};
|
||||
partial_nan.update(&(Arc::new(Float64Array::from(vec![f64::NAN])) as ArrayRef));
|
||||
let mut partial_num = ExtremumAccumulator {
|
||||
is_max: true,
|
||||
value: None,
|
||||
};
|
||||
partial_num.update(&(Arc::new(Float64Array::from(vec![2.0, 5.0])) as ArrayRef));
|
||||
|
||||
let mut merged = ExtremumAccumulator {
|
||||
is_max: true,
|
||||
value: None,
|
||||
};
|
||||
for partial in [&mut partial_nan, &mut partial_num] {
|
||||
let state = partial.state().unwrap()[0].to_array().unwrap();
|
||||
merged.merge_batch(&[state]).unwrap();
|
||||
}
|
||||
assert_eq!(merged.evaluate().unwrap(), ScalarValue::Float64(Some(5.0)));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use crate::admin::AdminFunction;
|
||||
use crate::aggrs::aggr_wrapper::StateMergeHelper;
|
||||
use crate::aggrs::approximate::ApproximateFunction;
|
||||
use crate::aggrs::count_hash::CountHash;
|
||||
use crate::aggrs::extremum::Extremum;
|
||||
use crate::aggrs::vector::VectorFunction as VectorAggrFunction;
|
||||
use crate::function::{Function, FunctionRef};
|
||||
use crate::function_factory::ScalarFunctionFactory;
|
||||
@@ -260,6 +261,9 @@ pub static FUNCTION_REGISTRY: LazyLock<Arc<FunctionRegistry>> = LazyLock::new(||
|
||||
// CountHash function
|
||||
CountHash::register(&function_registry);
|
||||
|
||||
// PromQL min/max; registered before the state functions are derived.
|
||||
Extremum::register(&function_registry);
|
||||
|
||||
// state function of supported aggregate functions
|
||||
StateMergeHelper::register(&function_registry);
|
||||
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common_base::term_token::{CharClass, classify_char};
|
||||
use datafusion_common::arrow::array::{Array, AsArray, BooleanArray, BooleanBuilder};
|
||||
use datafusion_common::arrow::compute;
|
||||
use datafusion_common::arrow::datatypes::DataType;
|
||||
use datafusion_common::{DataFusionError, ScalarValue};
|
||||
use datafusion_expr::{ColumnarValue, ScalarFunctionArgs, Signature, Volatility};
|
||||
use icu_properties::props::Script;
|
||||
use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
|
||||
use memchr::memmem;
|
||||
|
||||
use crate::function::Function;
|
||||
@@ -231,14 +230,6 @@ pub struct MatchesTermFinder {
|
||||
ends_with_other: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CharClass {
|
||||
AsciiWord,
|
||||
Han,
|
||||
UnicodeWord,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TermKind {
|
||||
AsciiLike,
|
||||
@@ -246,25 +237,6 @@ enum TermKind {
|
||||
HanContaining,
|
||||
}
|
||||
|
||||
fn classify_char(c: char) -> CharClass {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
CharClass::AsciiWord
|
||||
} else if is_han(c) {
|
||||
CharClass::Han
|
||||
} else if c.is_alphanumeric() {
|
||||
CharClass::UnicodeWord
|
||||
} else {
|
||||
CharClass::Other
|
||||
}
|
||||
}
|
||||
|
||||
static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
|
||||
CodePointMapData::<Script>::new();
|
||||
|
||||
fn is_han(c: char) -> bool {
|
||||
HAN_SCRIPT_DATA.get(c) == Script::Han
|
||||
}
|
||||
|
||||
fn classify_term(term: &str) -> TermKind {
|
||||
let mut has_han = false;
|
||||
let mut has_unicode_word = false;
|
||||
@@ -508,12 +480,4 @@ mod tests {
|
||||
assert!(MatchesTermFinder::new("行账号").find("中国农业银行账号"));
|
||||
assert!(MatchesTermFinder::new("登录").find("登录手机号18888888888的动态key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_detection_uses_script_not_all_cjk() {
|
||||
assert!(is_han('汉'));
|
||||
assert!(is_han('\u{30000}'));
|
||||
assert!(!is_han('あ'));
|
||||
assert!(!is_han('한'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,10 @@ pub fn binlit_as_veclit(bytes: &[u8]) -> Result<Cow<'_, [f32]>> {
|
||||
})
|
||||
} else {
|
||||
let v = bytes
|
||||
.chunks_exact(std::mem::size_of::<f32>())
|
||||
.map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap()))
|
||||
.as_chunks::<{ std::mem::size_of::<f32>() }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| f32::from_le_bytes(*chunk))
|
||||
.collect::<Vec<f32>>();
|
||||
Ok(Cow::Owned(v))
|
||||
}
|
||||
|
||||
@@ -41,3 +41,12 @@ cargo nextest run -p common-meta
|
||||
```
|
||||
|
||||
Backend-specific coverage may also need `pg_kvbackend` or `mysql_kvbackend`.
|
||||
|
||||
`test_util::etcd_certs_dir` and `test_util::test_certs_dir` resolve fixture
|
||||
certificates using `CARGO_MANIFEST_DIR` at **run time** (`std::env::var`, not `env!`).
|
||||
`cargo nextest run` sets it; direct test-binary execution requires setting the
|
||||
variable to the crate directory. Run this example from the repository root:
|
||||
|
||||
```bash
|
||||
CARGO_MANIFEST_DIR="$PWD/src/common/meta" ./target/debug/deps/<test_binary>
|
||||
```
|
||||
|
||||
@@ -42,7 +42,7 @@ use crate::cache_invalidator::Context;
|
||||
use crate::ddl::DdlContext;
|
||||
use crate::ddl::event::flow::{CREATE_FLOW_EVENT_TYPE, CreateFlowEventIntent, FlowDdlEvent};
|
||||
use crate::ddl::utils::{add_peer_context_if_needed, map_to_procedure_error};
|
||||
use crate::error::{self, Result, UnexpectedSnafu};
|
||||
use crate::error::{self, InvalidFlowOptionSnafu, Result, UnexpectedSnafu};
|
||||
use crate::instruction::{CacheIdent, CreateFlow, DropFlow};
|
||||
use crate::key::flow::flow_info::{FlowInfoValue, FlowScheduleConfig, FlowStatus};
|
||||
use crate::key::flow::flow_route::FlowRouteValue;
|
||||
@@ -558,9 +558,9 @@ pub fn validate_flow_options(flow_task: &CreateFlowTask) -> Result<()> {
|
||||
| FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY
|
||||
| FlowType::FLOW_TYPE_KEY => {}
|
||||
unknown => {
|
||||
return UnexpectedSnafu {
|
||||
return InvalidFlowOptionSnafu {
|
||||
err_msg: format!(
|
||||
"Unknown flow option '{unknown}', supported user options: {DEFER_ON_MISSING_SOURCE_KEY}, {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}"
|
||||
"unknown flow option '{unknown}', supported options: {DEFER_ON_MISSING_SOURCE_KEY}, {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}"
|
||||
),
|
||||
}
|
||||
.fail();
|
||||
|
||||
@@ -19,6 +19,8 @@ use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common_error::ext::BoxedError;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_event_recorder::Event;
|
||||
use common_procedure::error::{ExternalSnafu, FromJsonSnafu, ToJsonSnafu};
|
||||
use common_procedure::{
|
||||
@@ -53,7 +55,7 @@ use crate::rpc::router::{RegionRoute, operating_leader_region_roles};
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn ensure_retry_later(err: error::Error) -> error::Error {
|
||||
if err.is_retry_later() {
|
||||
if err.retry_hint().is_retryable() {
|
||||
err
|
||||
} else {
|
||||
error::Error::retry_later(err)
|
||||
|
||||
@@ -18,6 +18,8 @@ use std::sync::Arc;
|
||||
|
||||
use api::v1::flow::CreateRequest;
|
||||
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_error::status_code::StatusCode;
|
||||
use common_procedure::{Procedure, Status};
|
||||
use common_procedure_test::execute_procedure_until_done;
|
||||
use table::table_name::TableName;
|
||||
@@ -326,6 +328,26 @@ fn test_validate_flow_options_allows_incremental_read_option() {
|
||||
validate_flow_options(&task).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_flow_options_unknown_option_matches_invalid_sql_error() {
|
||||
let mut task = test_create_flow_task(
|
||||
"my_flow",
|
||||
vec![],
|
||||
TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"),
|
||||
false,
|
||||
);
|
||||
task.flow_options
|
||||
.insert("foo".to_string(), "sensitive-value".to_string());
|
||||
|
||||
let err = validate_flow_options(&task).unwrap_err();
|
||||
assert_eq!(StatusCode::InvalidArguments, err.status_code());
|
||||
assert_eq!(
|
||||
"Invalid SQL, error: unknown flow option 'foo', supported options: defer_on_missing_source, experimental_enable_incremental_read",
|
||||
err.output_msg()
|
||||
);
|
||||
assert!(!err.output_msg().contains("sensitive-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_flow_options_rejects_schedule_and_internal_keys_as_unknown() {
|
||||
for key in [
|
||||
@@ -346,9 +368,10 @@ fn test_validate_flow_options_rejects_schedule_and_internal_keys_as_unknown() {
|
||||
.insert(key.to_string(), "value".to_string());
|
||||
|
||||
let err = validate_flow_options(&task).unwrap_err();
|
||||
assert_eq!(StatusCode::InvalidArguments, err.status_code());
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("Unknown flow option '{key}'")),
|
||||
.contains(&format!("unknown flow option '{key}'")),
|
||||
"unexpected error for {key}: {err}"
|
||||
);
|
||||
}
|
||||
@@ -884,10 +907,10 @@ async fn test_create_flow_rejects_unknown_option_in_meta_task() {
|
||||
let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context);
|
||||
|
||||
let err = procedure.on_prepare().await.unwrap_err();
|
||||
assert_matches!(err, error::Error::Unexpected { .. });
|
||||
assert_eq!(StatusCode::InvalidArguments, err.status_code());
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Unknown flow option 'unknown_option'")
|
||||
.contains("unknown flow option 'unknown_option'")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ use api::v1::region::{
|
||||
use async_trait::async_trait;
|
||||
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
|
||||
use common_procedure::{
|
||||
Context as ProcedureContext, EventContext, EventTrigger, LockKey, Procedure,
|
||||
Result as ProcedureResult, Status,
|
||||
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
|
||||
Procedure, Result as ProcedureResult, Status,
|
||||
};
|
||||
use common_telemetry::tracing_context::TracingContext;
|
||||
use common_telemetry::warn;
|
||||
@@ -156,6 +156,7 @@ impl UndropTableProcedure {
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = restore_result {
|
||||
// Keep this classification aligned with map_restore_error().
|
||||
let should_cleanup_opened_regions = !err.is_retry_later();
|
||||
let err = self.map_restore_metadata_error(err);
|
||||
if should_cleanup_opened_regions
|
||||
@@ -274,6 +275,19 @@ impl UndropTableProcedure {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_restore_error(err: error::Error) -> ProcedureError {
|
||||
// Restore retries keep opened regions, while other failures close them. Preserve
|
||||
// this policy even when the underlying metadata error carries a retry hint:
|
||||
// retrying after cleanup would restore metadata without reopening the regions.
|
||||
if err.is_retry_later() {
|
||||
map_to_procedure_error(err)
|
||||
} else if err.need_clean_poisons() {
|
||||
ProcedureError::external_and_clean_poisons(err)
|
||||
} else {
|
||||
ProcedureError::external(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_restore_metadata_error(&self, err: error::Error) -> error::Error {
|
||||
match err {
|
||||
error::Error::TombstoneTargetAlreadyExists { .. } => {
|
||||
@@ -413,7 +427,12 @@ impl Procedure for UndropTableProcedure {
|
||||
async fn execute(&mut self, _: &ProcedureContext) -> ProcedureResult<Status> {
|
||||
match self.data.state {
|
||||
UndropTableState::Prepare => self.on_prepare().await,
|
||||
UndropTableState::RestoreMetadata => self.on_restore_metadata().await,
|
||||
UndropTableState::RestoreMetadata => {
|
||||
return self
|
||||
.on_restore_metadata()
|
||||
.await
|
||||
.map_err(Self::map_restore_error);
|
||||
}
|
||||
UndropTableState::OpenRegions => self.on_open_regions().await,
|
||||
UndropTableState::InvalidateTableCache => self.on_broadcast().await,
|
||||
}
|
||||
@@ -606,7 +625,8 @@ mod tests {
|
||||
use api::v1::region::region_request;
|
||||
use async_trait::async_trait;
|
||||
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_error::ext::{BoxedError, ErrorExt};
|
||||
use common_error::mock::MockError;
|
||||
use common_error::status_code::StatusCode;
|
||||
use store_api::storage::RegionId;
|
||||
use table::table_name::TableName;
|
||||
@@ -635,6 +655,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_metadata_retry_policy() {
|
||||
let source = error::Error::retry_later(MockError::new(StatusCode::Internal));
|
||||
let err = Err::<(), _>(BoxedError::new(source))
|
||||
.context(error::ExternalSnafu)
|
||||
.unwrap_err();
|
||||
assert!(err.retry_hint().is_retryable());
|
||||
assert!(!err.is_retry_later());
|
||||
let err = UndropTableProcedure::map_restore_error(err);
|
||||
assert!(!err.is_retry_later());
|
||||
assert!(!err.need_clean_poisons());
|
||||
|
||||
for retryable in [false, true] {
|
||||
for clean_poisons in [false, true] {
|
||||
let source = BoxedError::new(MockError::new(StatusCode::Internal));
|
||||
let err = if retryable {
|
||||
error::Error::RetryLater {
|
||||
source,
|
||||
clean_poisons,
|
||||
}
|
||||
} else {
|
||||
Err::<(), _>(source)
|
||||
.context(error::AbortProcedureSnafu { clean_poisons })
|
||||
.unwrap_err()
|
||||
};
|
||||
let err = UndropTableProcedure::map_restore_error(err);
|
||||
assert_eq!(err.is_retry_later(), retryable);
|
||||
assert_eq!(err.need_clean_poisons(), clean_poisons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_restore_metadata_error_without_table_name() {
|
||||
let context = new_ddl_context(Arc::new(MockDatanodeManager::new(())));
|
||||
|
||||
@@ -28,7 +28,7 @@ use api::v1::region::{
|
||||
region_request,
|
||||
};
|
||||
use common_catalog::consts::{METRIC_ENGINE, MITO_ENGINE};
|
||||
use common_error::ext::BoxedError;
|
||||
use common_error::ext::{BoxedError, ErrorExt};
|
||||
use common_procedure::error::Error as ProcedureError;
|
||||
use common_telemetry::tracing_context::TracingContext;
|
||||
use common_telemetry::{error, info, warn};
|
||||
@@ -61,7 +61,7 @@ use crate::wal_provider::RegionWalOptions;
|
||||
pub fn add_peer_context_if_needed(datanode: Peer) -> impl FnOnce(Error) -> Error {
|
||||
move |err| {
|
||||
error!(err; "Failed to operate datanode, peer: {}", datanode);
|
||||
if !err.is_retry_later() {
|
||||
if !err.retry_hint().is_retryable() {
|
||||
return Err::<(), BoxedError>(BoxedError::new(err))
|
||||
.context(OperateDatanodeSnafu { peer: datanode })
|
||||
.unwrap_err();
|
||||
@@ -84,7 +84,7 @@ pub(crate) fn is_metric_engine_logical_table(
|
||||
/// This function determines whether the error should be retried and if poison cleanup is needed,
|
||||
/// then maps it to the appropriate procedure error variant.
|
||||
pub fn map_to_procedure_error(e: Error) -> ProcedureError {
|
||||
match (e.is_retry_later(), e.need_clean_poisons()) {
|
||||
match (e.retry_hint().is_retryable(), e.need_clean_poisons()) {
|
||||
(true, true) => ProcedureError::retry_later_and_clean_poisons(e),
|
||||
(true, false) => ProcedureError::retry_later(e),
|
||||
(false, true) => ProcedureError::external_and_clean_poisons(e),
|
||||
@@ -256,7 +256,7 @@ pub fn handle_multiple_results<T: Debug>(results: Vec<Result<T>>) -> MultipleRes
|
||||
match result {
|
||||
Ok(value) => ok_results.push(value),
|
||||
Err(err) => {
|
||||
if err.is_retry_later() {
|
||||
if err.retry_hint().is_retryable() {
|
||||
retryable_results.push(err);
|
||||
} else {
|
||||
non_retryable_results.push(err);
|
||||
@@ -475,8 +475,74 @@ pub fn extract_column_metadatas(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common_error::mock::MockError;
|
||||
use common_error::status_code::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn external_error(retryable: bool) -> Error {
|
||||
let source = MockError::new(StatusCode::Internal);
|
||||
let source = if retryable {
|
||||
BoxedError::new(Error::retry_later(source))
|
||||
} else {
|
||||
BoxedError::new(source)
|
||||
};
|
||||
Err::<(), _>(source)
|
||||
.context(error::ExternalSnafu)
|
||||
.unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_to_procedure_error() {
|
||||
for retryable in [false, true] {
|
||||
for clean_poisons in [false, true] {
|
||||
let err = if retryable {
|
||||
Error::RetryLater {
|
||||
source: BoxedError::new(external_error(false)),
|
||||
clean_poisons,
|
||||
}
|
||||
} else {
|
||||
Err::<(), _>(BoxedError::new(external_error(true)))
|
||||
.context(error::AbortProcedureSnafu { clean_poisons })
|
||||
.unwrap_err()
|
||||
};
|
||||
let err = map_to_procedure_error(err);
|
||||
assert_eq!(err.is_retry_later(), retryable);
|
||||
assert_eq!(err.need_clean_poisons(), clean_poisons);
|
||||
}
|
||||
let err = map_to_procedure_error(external_error(retryable));
|
||||
assert_eq!(err.is_retry_later(), retryable);
|
||||
assert!(!err.need_clean_poisons());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_multiple_results_retry_hint() {
|
||||
assert!(matches!(
|
||||
handle_multiple_results(vec![Ok(()), Err(external_error(true))]),
|
||||
MultipleResults::PartialRetryable(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_multiple_results::<()>(vec![
|
||||
Err(external_error(true)),
|
||||
Err(external_error(true))
|
||||
]),
|
||||
MultipleResults::AllRetryable(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_multiple_results(vec![
|
||||
Ok(()),
|
||||
Err(external_error(true)),
|
||||
Err(external_error(false))
|
||||
]),
|
||||
MultipleResults::PartialNonRetryable(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_multiple_results::<()>(vec![Err(external_error(false))]),
|
||||
MultipleResults::AllNonRetryable(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_catalog_and_schema() {
|
||||
let test_catalog = "my_catalog";
|
||||
|
||||
@@ -515,6 +515,13 @@ pub enum Error {
|
||||
location: Location,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid SQL, error: {err_msg}"))]
|
||||
InvalidFlowOption {
|
||||
err_msg: String,
|
||||
#[snafu(implicit)]
|
||||
location: Location,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid flow request body: {:?}", body))]
|
||||
InvalidFlowRequestBody {
|
||||
body: Box<Option<api::v1::flow::flow_request::Body>>,
|
||||
@@ -1247,6 +1254,7 @@ impl ErrorExt for Error {
|
||||
|
||||
ProcedureNotFound { .. }
|
||||
| InvalidViewInfo { .. }
|
||||
| InvalidFlowOption { .. }
|
||||
| PrimaryKeyNotFound { .. }
|
||||
| EmptyKey { .. }
|
||||
| AlterLogicalTablesInvalidArguments { .. }
|
||||
@@ -1338,8 +1346,11 @@ impl ErrorExt for Error {
|
||||
use Error::*;
|
||||
|
||||
match self {
|
||||
RetryLater { .. }
|
||||
| GetLatestCacheRetryExceeded { .. }
|
||||
RetryLater { source, .. } => match source.retry_hint() {
|
||||
RetryHint::RetryAfterRouteRefresh => RetryHint::RetryAfterRouteRefresh,
|
||||
RetryHint::Retryable | RetryHint::NonRetryable => RetryHint::Retryable,
|
||||
},
|
||||
GetLatestCacheRetryExceeded { .. }
|
||||
| NoLeader { .. }
|
||||
| ElectionNoLeader { .. }
|
||||
| ElectionLeaderLeaseExpired { .. }
|
||||
@@ -1362,15 +1373,15 @@ impl ErrorExt for Error {
|
||||
| StartProcedureManager { source, .. }
|
||||
| StopProcedureManager { source, .. }
|
||||
| RegisterProcedureLoader { source, .. }
|
||||
| PutPoison { source, .. }
|
||||
| ProcedureStateReceiver { source, .. } => source.retry_hint(),
|
||||
| PutPoison { source, .. } => source.retry_hint(),
|
||||
External { source, .. }
|
||||
| ResponseExceededSizeLimit { source, .. }
|
||||
| OperateDatanode { source, .. }
|
||||
| AbortProcedure { source, .. }
|
||||
| RegisterRepartitionProcedureLoader { source, .. }
|
||||
| CreateRepartitionProcedure { source, .. }
|
||||
| PersistRepartitionGcRequirement { source, .. } => source.retry_hint(),
|
||||
// These errors end an operation; their sources cannot make it retryable.
|
||||
AbortProcedure { .. } | ProcedureStateReceiver { .. } => RetryHint::NonRetryable,
|
||||
Table { source, .. } => source.retry_hint(),
|
||||
ConvertAlterTableRequest { source, .. } => source.retry_hint(),
|
||||
ConvertColumnDef { source, .. } => source.retry_hint(),
|
||||
@@ -1455,6 +1466,7 @@ mod retry_hint_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use common_error::mock::MockError;
|
||||
use snafu::ResultExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1472,6 +1484,42 @@ mod retry_hint_tests {
|
||||
assert_eq!(err.retry_hint(), RetryHint::Retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leader_retry_hints() {
|
||||
for (error, expected) in [
|
||||
(
|
||||
NoLeaderSnafu { table_id: 1_u32 }.build(),
|
||||
RetryHint::Retryable,
|
||||
),
|
||||
(ElectionNoLeaderSnafu.build(), RetryHint::Retryable),
|
||||
(
|
||||
ElectionLeaderLeaseExpiredSnafu.build(),
|
||||
RetryHint::Retryable,
|
||||
),
|
||||
(
|
||||
ElectionLeaderLeaseChangedSnafu.build(),
|
||||
RetryHint::Retryable,
|
||||
),
|
||||
] {
|
||||
assert_eq!(error.retry_hint(), expected, "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_procedure_error_retry_hints() {
|
||||
let source = common_procedure::Error::retry_later(MockError::new(StatusCode::Internal));
|
||||
let err = Err::<(), _>(source).context(PutPoisonSnafu).unwrap_err();
|
||||
assert_eq!(err.retry_hint(), RetryHint::Retryable);
|
||||
|
||||
let source = common_procedure::Error::retry_later(MockError::new(StatusCode::Internal));
|
||||
let err = Err::<(), _>(source)
|
||||
.context(ProcedureStateReceiverSnafu {
|
||||
procedure_id: ProcedureId::random(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(err.retry_hint(), RetryHint::NonRetryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_cache_forwards_retry_hint() {
|
||||
let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
|
||||
|
||||
@@ -49,7 +49,7 @@ pub struct InstructionError {
|
||||
pub message: String,
|
||||
/// Retry hint serialized as a string.
|
||||
#[serde(
|
||||
serialize_with = "RetryHint::serialize_as_str",
|
||||
serialize_with = "InstructionError::serialize_retry_hint",
|
||||
deserialize_with = "RetryHint::deserialize_from_str"
|
||||
)]
|
||||
pub retry_hint: RetryHint,
|
||||
@@ -108,6 +108,18 @@ impl InstructionError {
|
||||
retry_hint: error.retry_hint(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps instruction replies readable by peers that only know the legacy hints.
|
||||
fn serialize_retry_hint<S>(hint: &RetryHint, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let hint = match hint {
|
||||
RetryHint::RetryAfterRouteRefresh => RetryHint::Retryable,
|
||||
hint => *hint,
|
||||
};
|
||||
RetryHint::serialize_as_str(&hint, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for InstructionError {
|
||||
@@ -732,12 +744,12 @@ impl PackedRegionFileRefs {
|
||||
}
|
||||
|
||||
let mut refs = HashSet::new();
|
||||
for bytes in files.chunks_exact(16) {
|
||||
for bytes in files.as_chunks::<16>().0 {
|
||||
let mut id = [0; 16];
|
||||
id.copy_from_slice(bytes);
|
||||
refs.insert(FileRef::new(region, FileId::from_bytes(id), None));
|
||||
}
|
||||
for bytes in indexed.chunks_exact(24) {
|
||||
for bytes in indexed.as_chunks::<24>().0 {
|
||||
let mut id = [0; 16];
|
||||
id.copy_from_slice(&bytes[..16]);
|
||||
let mut version = [0; 8];
|
||||
@@ -1450,6 +1462,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instruction_error_route_refresh_compatibility() {
|
||||
let error = InstructionError::new(
|
||||
StatusCode::RegionNotFound,
|
||||
"region not found",
|
||||
RetryHint::RetryAfterRouteRefresh,
|
||||
);
|
||||
let serialized = serde_json::to_string(&error).unwrap();
|
||||
assert_eq!(
|
||||
r#"{"code":4005,"message":"region not found","retry_hint":"retryable"}"#,
|
||||
serialized
|
||||
);
|
||||
let decoded: InstructionError = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(decoded.code, error.code);
|
||||
assert_eq!(decoded.message, error.message);
|
||||
assert_eq!(decoded.retry_hint, RetryHint::Retryable);
|
||||
assert_eq!(error.retry_hint, RetryHint::RetryAfterRouteRefresh);
|
||||
assert_eq!(error.retry_hint.as_str(), "retry_after_route_refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instruction_error_from_error() {
|
||||
let error = MockError::new(StatusCode::RegionNotFound);
|
||||
|
||||
@@ -82,7 +82,7 @@ impl KvBackend for ChrootKvBackend {
|
||||
|
||||
async fn batch_put(&self, mut req: BatchPutRequest) -> Result<BatchPutResponse, Self::Error> {
|
||||
for kv in req.kvs.iter_mut() {
|
||||
kv.key = self.key_prepend_root(kv.key.drain(..).collect());
|
||||
kv.key = self.key_prepend_root(std::mem::take(&mut kv.key));
|
||||
}
|
||||
let mut res = self.inner.batch_put(req).await?;
|
||||
res.prev_kvs = res
|
||||
|
||||
@@ -28,12 +28,22 @@ use crate::peer::Peer;
|
||||
#[async_trait::async_trait]
|
||||
pub trait Datanode: Send + Sync {
|
||||
/// Handles DML, and DDL requests.
|
||||
async fn handle(&self, request: RegionRequest) -> Result<RegionResponse>;
|
||||
async fn handle_request(&self, request: Arc<RegionRequest>) -> Result<RegionResponse>;
|
||||
|
||||
/// Handles query requests
|
||||
async fn handle_query(&self, request: QueryRequest) -> Result<SendableRecordBatchStream>;
|
||||
}
|
||||
|
||||
impl dyn Datanode + '_ {
|
||||
/// Handles an owned or shared request without cloning its payload.
|
||||
pub async fn handle<R>(&self, request: R) -> Result<RegionResponse>
|
||||
where
|
||||
R: Into<Arc<RegionRequest>> + Send,
|
||||
{
|
||||
self.handle_request(request.into()).await
|
||||
}
|
||||
}
|
||||
|
||||
pub type DatanodeRef = Arc<dyn Datanode>;
|
||||
|
||||
/// The trait for handling requests to flownode
|
||||
|
||||
@@ -22,6 +22,9 @@ use crate::cluster::NodeInfo;
|
||||
use crate::error::Error;
|
||||
use crate::{DatanodeId, FlownodeId};
|
||||
|
||||
/// Shared reference to a [`Peer`].
|
||||
pub type PeerRef = Arc<Peer>;
|
||||
|
||||
/// [`PeerResolver`] provides methods to look up peer information by node ID.
|
||||
///
|
||||
/// This trait allows resolving both datanode and flownode peers, regardless of their current activity status.
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::time::Instant;
|
||||
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_procedure::error::FromJsonSnafu;
|
||||
use common_procedure::{
|
||||
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
|
||||
@@ -191,7 +192,7 @@ impl Procedure for ReconcileCatalogProcedure {
|
||||
Ok(status)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_retry_later() {
|
||||
if e.retry_hint().is_retryable() {
|
||||
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
|
||||
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
|
||||
.inc();
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::fmt::Debug;
|
||||
use std::time::Instant;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
|
||||
use common_procedure::{
|
||||
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
|
||||
@@ -231,7 +232,7 @@ impl Procedure for ReconcileDatabaseProcedure {
|
||||
Ok(status)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_retry_later() {
|
||||
if e.retry_hint().is_retryable() {
|
||||
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
|
||||
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
|
||||
.inc();
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
|
||||
use common_procedure::{
|
||||
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
|
||||
@@ -207,7 +208,7 @@ impl Procedure for ReconcileLogicalTablesProcedure {
|
||||
Ok(status)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_retry_later() {
|
||||
if e.retry_hint().is_retryable() {
|
||||
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
|
||||
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
|
||||
.inc();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(crate) mod update_table_info;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
|
||||
use common_procedure::{
|
||||
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
|
||||
@@ -318,7 +319,7 @@ impl Procedure for ReconcileTableProcedure {
|
||||
Ok(status)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_retry_later() {
|
||||
if e.retry_hint().is_retryable() {
|
||||
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
|
||||
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
|
||||
.inc();
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn region_distribution(region_routes: &[RegionRoute]) -> RegionDistribution
|
||||
.add_follower_region(region_number);
|
||||
}
|
||||
}
|
||||
for (_, region_role_set) in regions_id_map.iter_mut() {
|
||||
for region_role_set in regions_id_map.values_mut() {
|
||||
// Sort the regions in ascending order.
|
||||
region_role_set.sort()
|
||||
}
|
||||
@@ -114,15 +114,25 @@ pub fn convert_to_region_leader_map(region_routes: &[RegionRoute]) -> HashMap<Re
|
||||
.collect::<HashMap<_, _>>()
|
||||
}
|
||||
|
||||
/// Returns the leader of the first route matching the region number.
|
||||
pub fn find_region_leader(
|
||||
region_routes: &[RegionRoute],
|
||||
region_number: RegionNumber,
|
||||
) -> Option<Peer> {
|
||||
find_region_leader_ref(region_routes, region_number).cloned()
|
||||
}
|
||||
|
||||
/// Borrows the leader of the first route matching the region number.
|
||||
///
|
||||
/// Returns `None` if no route matches or the first matching route has no leader.
|
||||
pub fn find_region_leader_ref(
|
||||
region_routes: &[RegionRoute],
|
||||
region_number: RegionNumber,
|
||||
) -> Option<&Peer> {
|
||||
region_routes
|
||||
.iter()
|
||||
.find(|x| x.region.id.region_number() == region_number)
|
||||
.and_then(|r| r.leader_peer.as_ref())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Returns the region numbers of the leader regions on the target datanode.
|
||||
@@ -442,6 +452,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_region_leader() {
|
||||
let mut routes = vec![
|
||||
new_test_region_route(RegionId::new(1, 1)),
|
||||
new_test_region_route(RegionId::new(1, 1)),
|
||||
];
|
||||
routes[1].leader_peer = Some(Peer::new(2, "a2"));
|
||||
|
||||
assert_eq!(
|
||||
find_region_leader_ref(&routes, 1),
|
||||
routes[0].leader_peer.as_ref()
|
||||
);
|
||||
assert_eq!(find_region_leader(&routes, 1), routes[0].leader_peer);
|
||||
assert_eq!(find_region_leader_ref(&routes, 2), None);
|
||||
assert_eq!(find_region_leader(&routes, 2), None);
|
||||
|
||||
routes[0].leader_peer = None;
|
||||
assert_eq!(find_region_leader_ref(&routes, 1), None);
|
||||
assert_eq!(find_region_leader(&routes, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leader_is_downgraded() {
|
||||
let mut region_route = RegionRoute {
|
||||
|
||||
@@ -199,7 +199,7 @@ impl RangeResponse {
|
||||
}
|
||||
|
||||
pub fn take_kvs(&mut self) -> Vec<KeyValue> {
|
||||
self.kvs.drain(..).collect()
|
||||
std::mem::take(&mut self.kvs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +451,7 @@ impl BatchPutResponse {
|
||||
}
|
||||
|
||||
pub fn take_prev_kvs(&mut self) -> Vec<KeyValue> {
|
||||
self.prev_kvs.drain(..).collect()
|
||||
std::mem::take(&mut self.prev_kvs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,7 +780,7 @@ impl DeleteRangeResponse {
|
||||
}
|
||||
|
||||
pub fn take_prev_kvs(&mut self) -> Vec<KeyValue> {
|
||||
self.prev_kvs.drain(..).collect()
|
||||
std::mem::take(&mut self.prev_kvs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ impl Inner {
|
||||
}
|
||||
|
||||
error::NextSequenceSnafu {
|
||||
err_msg: format!("{}.next()", &self.name),
|
||||
err_msg: format!("{}.next()", self.name),
|
||||
}
|
||||
.fail()
|
||||
}
|
||||
@@ -264,7 +264,7 @@ impl Inner {
|
||||
}
|
||||
|
||||
error::NextSequenceSnafu {
|
||||
err_msg: format!("{}.next_range()", &self.name),
|
||||
err_msg: format!("{}.next_range()", self.name),
|
||||
}
|
||||
.fail()
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ struct MockNode<T> {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<T: MockDatanodeHandler> Datanode for MockNode<T> {
|
||||
async fn handle(&self, request: RegionRequest) -> Result<RegionResponse> {
|
||||
async fn handle_request(&self, request: Arc<RegionRequest>) -> Result<RegionResponse> {
|
||||
let request = Arc::try_unwrap(request).expect("mock request must be exclusively owned");
|
||||
self.handler.handle(&self.peer, request).await
|
||||
}
|
||||
|
||||
@@ -322,8 +323,8 @@ macro_rules! maybe_skip_etcd_tls_integration_test {
|
||||
|
||||
/// Returns the directory of the etcd TLS certs.
|
||||
pub fn etcd_certs_dir() -> PathBuf {
|
||||
let project_path = env!("CARGO_MANIFEST_DIR");
|
||||
let project_path = PathBuf::from(project_path);
|
||||
let project_path =
|
||||
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
|
||||
let base = project_path.ancestors().nth(3).unwrap();
|
||||
base.join("tests-integration")
|
||||
.join("fixtures")
|
||||
@@ -332,8 +333,8 @@ pub fn etcd_certs_dir() -> PathBuf {
|
||||
|
||||
/// Returns the directory of the test certs.
|
||||
pub fn test_certs_dir() -> PathBuf {
|
||||
let project_path = env!("CARGO_MANIFEST_DIR");
|
||||
let project_path = PathBuf::from(project_path);
|
||||
let project_path =
|
||||
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
|
||||
let base = project_path.ancestors().nth(3).unwrap();
|
||||
base.join("tests-integration")
|
||||
.join("fixtures")
|
||||
|
||||
@@ -306,7 +306,8 @@ impl ErrorExt for Error {
|
||||
| Error::DeletePoison { source, .. }
|
||||
| Error::GetPoison { source, .. }
|
||||
| Error::CheckStatus { source, .. } => source.retry_hint(),
|
||||
Error::ProcedureExec { source, .. } => source.retry_hint(),
|
||||
// The watcher reports this only after the child has failed or been poisoned.
|
||||
Error::ProcedureExec { .. } => RetryHint::NonRetryable,
|
||||
Error::StartRemoveOutdatedMetaTask { source, .. }
|
||||
| Error::StopRemoveOutdatedMetaTask { source, .. } => source.retry_hint(),
|
||||
Error::DeleteState { error, .. } => retry_hint_from_opendal_error(error),
|
||||
@@ -379,8 +380,11 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use common_error::mock::MockError;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::*;
|
||||
use crate::procedure::ProcedureState;
|
||||
use crate::watcher;
|
||||
|
||||
#[test]
|
||||
fn test_retry_later_hint_is_retryable() {
|
||||
@@ -397,6 +401,21 @@ mod tests {
|
||||
assert_eq!(err.retry_hint(), RetryHint::Retryable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_finished_procedure_hint_is_non_retryable() {
|
||||
let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
|
||||
for state in [
|
||||
ProcedureState::failed(source.clone()),
|
||||
ProcedureState::poisoned(Default::default(), source),
|
||||
] {
|
||||
let (sender, mut receiver) = watch::channel(ProcedureState::Running);
|
||||
sender.send(state).unwrap();
|
||||
let err = watcher::wait(&mut receiver).await.unwrap_err();
|
||||
|
||||
assert_eq!(err.retry_hint(), RetryHint::NonRetryable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_exceeded_hint_is_non_retryable() {
|
||||
let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
|
||||
|
||||
@@ -95,7 +95,7 @@ impl DisplayAs for StreamScanAdapter {
|
||||
f,
|
||||
"StreamScanAdapter: [<SendableRecordBatchStream>], schema: ["
|
||||
)?;
|
||||
write!(f, "{:?}", &self.arrow_schema)?;
|
||||
write!(f, "{:?}", self.arrow_schema)?;
|
||||
write!(f, "]")
|
||||
}
|
||||
}
|
||||
|
||||
+124
-1091
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,944 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Predicate recognition, execution, and precompiled regular expressions.
|
||||
|
||||
use datafusion::logical_expr::{Expr, Operator};
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_common::arrow::array::{ArrayRef, Datum, Scalar};
|
||||
use datafusion_common::arrow::buffer::BooleanBuffer;
|
||||
use datafusion_common::arrow::compute::kernels::cmp;
|
||||
use datafusion_common::cast::as_string_array;
|
||||
use datatypes::arrow::array::{
|
||||
Array, ArrayAccessor, ArrayData, BooleanArray, BooleanBufferBuilder, DictionaryArray,
|
||||
StringArrayType,
|
||||
};
|
||||
use datatypes::arrow::datatypes::{DataType, UInt32Type};
|
||||
use datatypes::arrow::error::ArrowError;
|
||||
use datatypes::compute::or_kleene;
|
||||
use regex::Regex;
|
||||
use snafu::ResultExt;
|
||||
|
||||
use crate::error::{ArrowComputeSnafu, Result, UnsupportedOperationSnafu};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum SimplePredicate {
|
||||
Comparison {
|
||||
literal: Scalar<ArrayRef>,
|
||||
op: Operator,
|
||||
regex: Option<Regex>,
|
||||
regex_negative: bool,
|
||||
},
|
||||
OrEqChain {
|
||||
literals: Vec<Scalar<ArrayRef>>,
|
||||
},
|
||||
IsNull {
|
||||
negated: bool,
|
||||
},
|
||||
Constant(Option<bool>),
|
||||
And(Box<Self>, Box<Self>),
|
||||
Or(Box<Self>, Box<Self>),
|
||||
}
|
||||
|
||||
impl SimplePredicate {
|
||||
pub(crate) fn try_new(expr: &Expr, column_type: Option<&DataType>) -> Option<Self> {
|
||||
match expr {
|
||||
Expr::BinaryExpr(binary) if matches!(binary.op, Operator::And | Operator::Or) => {
|
||||
let left = Self::try_new(&binary.left, column_type)?;
|
||||
let right = Self::try_new(&binary.right, column_type)?;
|
||||
if binary.op == Operator::And {
|
||||
return Some(Self::And(Box::new(left), Box::new(right)));
|
||||
}
|
||||
// Preserve the encoded-key IN-list fast path for pure equality disjunctions.
|
||||
if let (Some(mut lhs), Some(rhs)) =
|
||||
(left.equality_literals(), right.equality_literals())
|
||||
{
|
||||
lhs.extend(rhs);
|
||||
return Some(Self::OrEqChain { literals: lhs });
|
||||
}
|
||||
Some(Self::Or(Box::new(left), Box::new(right)))
|
||||
}
|
||||
Expr::BinaryExpr(binary) => {
|
||||
let mut op = binary.op;
|
||||
if !matches!(
|
||||
op,
|
||||
Operator::Eq
|
||||
| Operator::NotEq
|
||||
| Operator::Lt
|
||||
| Operator::LtEq
|
||||
| Operator::Gt
|
||||
| Operator::GtEq
|
||||
| Operator::RegexMatch
|
||||
| Operator::RegexIMatch
|
||||
| Operator::RegexNotMatch
|
||||
| Operator::RegexNotIMatch
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let literal = if let Expr::Literal(literal, _) = &*binary.right {
|
||||
Self::check_column(&binary.left, column_type)?;
|
||||
literal
|
||||
} else if let Expr::Literal(literal, _) = &*binary.left {
|
||||
Self::check_column(&binary.right, column_type)?;
|
||||
op = op.swap()?;
|
||||
literal
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (regex, regex_negative) = Self::maybe_build_regex(op, literal).ok()?;
|
||||
Some(Self::Comparison {
|
||||
literal: literal.to_scalar().ok()?,
|
||||
op,
|
||||
regex,
|
||||
regex_negative,
|
||||
})
|
||||
}
|
||||
Expr::IsNull(column) | Expr::IsNotNull(column) => {
|
||||
Self::check_column(column, column_type)?;
|
||||
Some(Self::IsNull {
|
||||
negated: matches!(expr, Expr::IsNotNull(_)),
|
||||
})
|
||||
}
|
||||
Expr::Literal(ScalarValue::Boolean(value), _) => Some(Self::Constant(*value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_column(expr: &Expr, column_type: Option<&DataType>) -> Option<()> {
|
||||
match expr {
|
||||
Expr::Column(_) => Some(()),
|
||||
Expr::Cast(cast) if cast.field.data_type() == &DataType::Utf8 => {
|
||||
let Expr::Column(_) = &*cast.expr else {
|
||||
return None;
|
||||
};
|
||||
match column_type? {
|
||||
DataType::Utf8 => Some(()),
|
||||
DataType::Dictionary(key, value)
|
||||
if **key == DataType::UInt32 && **value == DataType::Utf8 =>
|
||||
{
|
||||
Some(())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn equality_literals(&self) -> Option<Vec<Scalar<ArrayRef>>> {
|
||||
match self {
|
||||
Self::Comparison {
|
||||
literal,
|
||||
op: Operator::Eq,
|
||||
..
|
||||
} => Some(vec![literal.clone()]),
|
||||
Self::OrEqChain { literals } => Some(literals.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate(&self, input: &impl Datum, len: usize) -> Result<BooleanArray> {
|
||||
match self {
|
||||
Self::Comparison {
|
||||
literal,
|
||||
op,
|
||||
regex,
|
||||
regex_negative,
|
||||
} => match op {
|
||||
Operator::Eq => cmp::eq(input, literal),
|
||||
Operator::NotEq => cmp::neq(input, literal),
|
||||
Operator::Lt => cmp::lt(input, literal),
|
||||
Operator::LtEq => cmp::lt_eq(input, literal),
|
||||
Operator::Gt => cmp::gt(input, literal),
|
||||
Operator::GtEq => cmp::gt_eq(input, literal),
|
||||
Operator::RegexMatch
|
||||
| Operator::RegexIMatch
|
||||
| Operator::RegexNotMatch
|
||||
| Operator::RegexNotIMatch => {
|
||||
Self::regex_match(input, regex.as_ref(), *regex_negative)
|
||||
}
|
||||
_ => {
|
||||
return UnsupportedOperationSnafu {
|
||||
reason: format!("{op:?}"),
|
||||
}
|
||||
.fail();
|
||||
}
|
||||
}
|
||||
.context(ArrowComputeSnafu),
|
||||
Self::OrEqChain { literals } => {
|
||||
let mut result = BooleanArray::from(vec![false; len]);
|
||||
for literal in literals {
|
||||
let rhs = cmp::eq(input, literal).context(ArrowComputeSnafu)?;
|
||||
result = or_kleene(&result, &rhs).context(ArrowComputeSnafu)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Self::IsNull { negated } => {
|
||||
let values = match input.get().0.logical_nulls() {
|
||||
Some(nulls) if *negated => nulls.inner().clone(),
|
||||
Some(nulls) => !nulls.inner(),
|
||||
None if *negated => BooleanBuffer::new_set(len),
|
||||
None => BooleanBuffer::new_unset(len),
|
||||
};
|
||||
Ok(BooleanArray::new(values, None))
|
||||
}
|
||||
Self::Constant(value) => Ok(BooleanArray::from(vec![*value; len])),
|
||||
Self::And(left, right) => datatypes::compute::and_kleene(
|
||||
&left.evaluate(input, len)?,
|
||||
&right.evaluate(input, len)?,
|
||||
)
|
||||
.context(ArrowComputeSnafu),
|
||||
Self::Or(left, right) => {
|
||||
or_kleene(&left.evaluate(input, len)?, &right.evaluate(input, len)?)
|
||||
.context(ArrowComputeSnafu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a regex pattern from a scalar value and operator.
|
||||
/// Returns the `(regex, negative)` and if successful.
|
||||
///
|
||||
/// Returns `Err` if
|
||||
/// - the value is not a string
|
||||
/// - the regex pattern is invalid
|
||||
///
|
||||
/// The regex is `None` if
|
||||
/// - the operator is not a regex operator
|
||||
/// - the pattern is empty
|
||||
fn maybe_build_regex(
|
||||
operator: Operator,
|
||||
value: &ScalarValue,
|
||||
) -> Result<(Option<Regex>, bool), ArrowError> {
|
||||
let (ignore_case, negative) = match operator {
|
||||
Operator::RegexMatch => (false, false),
|
||||
Operator::RegexIMatch => (true, false),
|
||||
Operator::RegexNotMatch => (false, true),
|
||||
Operator::RegexNotIMatch => (true, true),
|
||||
_ => return Ok((None, false)),
|
||||
};
|
||||
let flag = if ignore_case { Some("i") } else { None };
|
||||
let regex = value
|
||||
.try_as_str()
|
||||
.ok_or_else(|| ArrowError::CastError(format!("Cannot cast {:?} to str", value)))?
|
||||
.ok_or_else(|| ArrowError::CastError("Regex should not be null".to_string()))?;
|
||||
let pattern = match flag {
|
||||
Some(flag) => format!("(?{flag}){regex}"),
|
||||
None => regex.to_string(),
|
||||
};
|
||||
if pattern.is_empty() {
|
||||
Ok((None, negative))
|
||||
} else {
|
||||
Regex::new(pattern.as_str())
|
||||
.map_err(|e| {
|
||||
ArrowError::ComputeError(format!("Regular expression did not compile: {e:?}"))
|
||||
})
|
||||
.map(|regex| (Some(regex), negative))
|
||||
}
|
||||
}
|
||||
|
||||
fn regex_match(
|
||||
input: &impl Datum,
|
||||
regex: Option<&Regex>,
|
||||
negative: bool,
|
||||
) -> std::result::Result<BooleanArray, ArrowError> {
|
||||
let array = input.get().0;
|
||||
|
||||
// Try to cast to StringArray first
|
||||
if let Ok(string_array) = as_string_array(array) {
|
||||
let mut result = regexp_is_match_scalar(string_array, regex)?;
|
||||
if negative {
|
||||
result = datatypes::compute::not(&result)?;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Try to cast to StringDictionaryArray
|
||||
if let Some(dict_array) = array.as_any().downcast_ref::<DictionaryArray<UInt32Type>>() {
|
||||
let mut result = regexp_is_match_dictionary(dict_array, regex)?;
|
||||
if negative {
|
||||
result = datatypes::compute::not(&result)?;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
Err(ArrowError::CastError(format!(
|
||||
"Cannot cast {:?} to StringArray or StringDictionaryArray",
|
||||
array.data_type()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The same as arrow [regexp_is_match_scalar()](datatypes::compute::kernels::regexp::regexp_is_match_scalar())
|
||||
/// with pre-compiled regex.
|
||||
/// See <https://github.com/apache/arrow-rs/blob/54.2.0/arrow-string/src/regexp.rs#L204-L246> for the implementation details.
|
||||
pub fn regexp_is_match_scalar<'a, S>(
|
||||
array: &'a S,
|
||||
regex: Option<&Regex>,
|
||||
) -> Result<BooleanArray, ArrowError>
|
||||
where
|
||||
&'a S: StringArrayType<'a>,
|
||||
{
|
||||
let null_bit_buffer = array.nulls().map(|x| x.inner().sliced());
|
||||
let mut result = BooleanBufferBuilder::new(array.len());
|
||||
|
||||
if let Some(re) = regex {
|
||||
for i in 0..array.len() {
|
||||
let value = array.value(i);
|
||||
result.append(re.is_match(value));
|
||||
}
|
||||
} else {
|
||||
result.append_n(array.len(), true);
|
||||
}
|
||||
|
||||
let buffer = result.into();
|
||||
let data = unsafe {
|
||||
ArrayData::new_unchecked(
|
||||
DataType::Boolean,
|
||||
array.len(),
|
||||
None,
|
||||
null_bit_buffer,
|
||||
0,
|
||||
vec![buffer],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
Ok(BooleanArray::from(data))
|
||||
}
|
||||
|
||||
/// Similar to [regexp_is_match_scalar] but for StringDictionaryArray.
|
||||
/// Iterates through dictionary keys to get string values and applies regex matching.
|
||||
pub fn regexp_is_match_dictionary(
|
||||
dict_array: &DictionaryArray<UInt32Type>,
|
||||
regex: Option<&Regex>,
|
||||
) -> Result<BooleanArray, ArrowError> {
|
||||
// Get the string values from the dictionary
|
||||
let string_values = dict_array
|
||||
.values()
|
||||
.as_any()
|
||||
.downcast_ref::<datatypes::arrow::array::StringArray>()
|
||||
.ok_or_else(|| {
|
||||
ArrowError::CastError("Dictionary values must be StringArray".to_string())
|
||||
})?;
|
||||
|
||||
// Dictionary logical nulls include both null keys and keys whose dictionary value is null.
|
||||
let logical_nulls = dict_array.logical_nulls();
|
||||
let null_bit_buffer = logical_nulls.as_ref().map(|x| x.inner().sliced());
|
||||
let mut result = BooleanBufferBuilder::new(dict_array.len());
|
||||
|
||||
if let Some(re) = regex {
|
||||
let keys = dict_array.keys().values();
|
||||
for i in 0..dict_array.len() {
|
||||
if logical_nulls.as_ref().is_some_and(|nulls| nulls.is_null(i)) {
|
||||
result.append(false);
|
||||
} else {
|
||||
let key = keys[i] as usize;
|
||||
let string_value = string_values.value(key);
|
||||
result.append(re.is_match(string_value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.append_n(dict_array.len(), true);
|
||||
}
|
||||
|
||||
let buffer = result.into();
|
||||
let data = unsafe {
|
||||
ArrayData::new_unchecked(
|
||||
DataType::Boolean,
|
||||
dict_array.len(),
|
||||
None,
|
||||
null_bit_buffer,
|
||||
0,
|
||||
vec![buffer],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
Ok(BooleanArray::from(data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use datafusion::execution::context::ExecutionProps;
|
||||
use datafusion::logical_expr::physical_planning_context::PhysicalPlanningContext;
|
||||
use datafusion::logical_expr::{BinaryExpr, Expr, Literal, Operator, col, lit};
|
||||
use datafusion::physical_expr::create_physical_expr;
|
||||
use datafusion_common::arrow::array::ArrayRef;
|
||||
use datafusion_common::arrow::buffer::BooleanBuffer;
|
||||
use datafusion_common::{Column, DFSchema, ScalarValue};
|
||||
use datatypes::arrow::array::{Array, DictionaryArray, RecordBatch};
|
||||
use datatypes::arrow::datatypes::{DataType, Field, Schema, TimeUnit, UInt32Type};
|
||||
use datatypes::value::Value;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::filter::predicate::{SimplePredicate, regexp_is_match_dictionary};
|
||||
use crate::filter::{SimpleFilterEvaluator, batch_filter};
|
||||
|
||||
#[test]
|
||||
fn unsupported_filter_op() {
|
||||
// `+` is not supported
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Plus,
|
||||
right: Box::new(1.lit()),
|
||||
});
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none());
|
||||
|
||||
// two literal is not supported
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(1.lit()),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(1.lit()),
|
||||
});
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none());
|
||||
|
||||
// two column is not supported
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(Expr::Column(Column::from_name("bar"))),
|
||||
});
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none());
|
||||
|
||||
// compound expr is not supported
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(1.lit()),
|
||||
})),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(1.lit()),
|
||||
});
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none());
|
||||
for negated in [false, true] {
|
||||
let membership = col("label").in_list(vec![lit(""), lit("lo")], negated);
|
||||
assert!(SimpleFilterEvaluator::try_new(&membership).is_none());
|
||||
assert!(
|
||||
SimpleFilterEvaluator::try_new(&col("label").is_null().or(membership)).is_none()
|
||||
);
|
||||
}
|
||||
for expr in [
|
||||
col("other").is_null().or(col("label").not_eq(lit("lo"))),
|
||||
col("a.label")
|
||||
.is_null()
|
||||
.or(col("b.label").not_eq(lit("lo"))),
|
||||
col("label").is_null().or(col("label").eq(col("other"))),
|
||||
col("label")
|
||||
.is_null()
|
||||
.or(Expr::Cast(datafusion::logical_expr::Cast::new(
|
||||
Box::new(col("label")),
|
||||
DataType::Int64,
|
||||
))
|
||||
.eq(lit("lo"))),
|
||||
] {
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none(), "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_cast_requires_matching_column_type() {
|
||||
let cast = Expr::Cast(datafusion::logical_expr::Cast::new(
|
||||
Box::new(col("label")),
|
||||
DataType::Utf8,
|
||||
));
|
||||
let expr = col("label").is_null().or(cast.not_eq(lit("lo")));
|
||||
for data_type in [
|
||||
DataType::Int64,
|
||||
DataType::Timestamp(TimeUnit::Millisecond, None),
|
||||
DataType::Binary,
|
||||
] {
|
||||
assert!(
|
||||
SimpleFilterEvaluator::try_new_with_column_type(&expr, &|_| Some(
|
||||
data_type.clone()
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(SimpleFilterEvaluator::try_new(&expr).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_column_predicates_match_datafusion() {
|
||||
use datafusion::prelude::SessionContext;
|
||||
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
|
||||
use datatypes::arrow::array::{Int32Array, StringArray, UInt32Array};
|
||||
|
||||
let values = vec![
|
||||
None,
|
||||
Some(""),
|
||||
Some("lo"),
|
||||
Some("eth0"),
|
||||
Some("a\nb"),
|
||||
Some("服务"),
|
||||
None,
|
||||
];
|
||||
let strings: ArrayRef = Arc::new(StringArray::from(values));
|
||||
let dictionary: ArrayRef = Arc::new(DictionaryArray::<UInt32Type>::new(
|
||||
UInt32Array::from(vec![
|
||||
None,
|
||||
Some(0),
|
||||
Some(1),
|
||||
Some(2),
|
||||
Some(3),
|
||||
Some(4),
|
||||
Some(5),
|
||||
]),
|
||||
Arc::new(StringArray::from(vec![
|
||||
Some(""),
|
||||
Some("lo"),
|
||||
Some("eth0"),
|
||||
Some("a\nb"),
|
||||
Some("服务"),
|
||||
None,
|
||||
])),
|
||||
));
|
||||
let null = || lit(ScalarValue::Utf8(None));
|
||||
let mut predicates = vec![
|
||||
col("label").is_null(),
|
||||
col("label").is_not_null(),
|
||||
col("label")
|
||||
.is_null()
|
||||
.or(col("label").eq(lit("")))
|
||||
.and(col("label").is_not_null()),
|
||||
col("label")
|
||||
.not_eq(lit("lo"))
|
||||
.and(col("label").not_eq(lit("eth0"))),
|
||||
col("label")
|
||||
.eq(lit("lo"))
|
||||
.or(col("label").eq(lit("eth0")))
|
||||
.or(col("label").eq(lit(""))),
|
||||
col("label").eq(null()).or(col("label").eq(lit("eth0"))),
|
||||
col("label").gt(lit("a")).and(col("label").lt(lit("z"))),
|
||||
lit("lo").not_eq(col("label")),
|
||||
];
|
||||
for (op, literal) in [
|
||||
(Operator::Eq, ""),
|
||||
(Operator::NotEq, "lo"),
|
||||
(Operator::RegexNotMatch, "^(?:lo|vfat)$"),
|
||||
(Operator::RegexMatch, "^(?:|eth0)$"),
|
||||
] {
|
||||
let comparison = Expr::BinaryExpr(BinaryExpr::new(
|
||||
Box::new(col("label")),
|
||||
op,
|
||||
Box::new(lit(literal)),
|
||||
));
|
||||
predicates.extend([
|
||||
col("label").is_null().or(comparison.clone()),
|
||||
comparison.or(col("label").is_null()),
|
||||
]);
|
||||
}
|
||||
for input in [strings, dictionary] {
|
||||
let ctx = SessionContext::new();
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("label", input.data_type().clone(), true),
|
||||
Field::new("id", DataType::Int32, false),
|
||||
]));
|
||||
ctx.register_batch(
|
||||
"labels",
|
||||
RecordBatch::try_new(
|
||||
schema,
|
||||
vec![
|
||||
input.clone(),
|
||||
Arc::new(Int32Array::from_iter_values(0..input.len() as i32)),
|
||||
],
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for expr in &predicates {
|
||||
let df = ctx
|
||||
.table("labels")
|
||||
.await
|
||||
.unwrap()
|
||||
.filter(expr.clone())
|
||||
.unwrap();
|
||||
let plan = df.clone().into_optimized_plan().unwrap();
|
||||
let expected = df
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|batch| {
|
||||
batch
|
||||
.column_by_name("id")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<Int32Array>()
|
||||
.unwrap()
|
||||
.values()
|
||||
.to_vec()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let evaluate = |expr: &Expr| {
|
||||
let evaluator =
|
||||
SimpleFilterEvaluator::try_new_with_column_type(expr, &|name| {
|
||||
(name == "label").then(|| input.data_type().clone())
|
||||
})
|
||||
.unwrap_or_else(|| panic!("unsupported: {expr}"));
|
||||
let mask = evaluator.evaluate_array(&input).unwrap();
|
||||
for (i, matched) in mask.iter().enumerate() {
|
||||
let scalar = ScalarValue::try_from_array(&input, i).unwrap();
|
||||
assert_eq!(
|
||||
evaluator.evaluate_scalar(&scalar).unwrap(),
|
||||
matched,
|
||||
"{expr}, row={i}"
|
||||
);
|
||||
}
|
||||
mask
|
||||
};
|
||||
let selected = |mask: BooleanBuffer| {
|
||||
mask.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, matched)| matched.then_some(i as i32))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(selected(evaluate(expr)), expected, "{expr}");
|
||||
let mut filters = Vec::new();
|
||||
plan.apply(|plan| {
|
||||
if let datafusion::logical_expr::LogicalPlan::Filter(filter) = plan {
|
||||
filters.push(filter.predicate.clone());
|
||||
}
|
||||
Ok(TreeNodeRecursion::Continue)
|
||||
})
|
||||
.unwrap();
|
||||
if !filters.is_empty() {
|
||||
let mut mask = BooleanBuffer::new_set(input.len());
|
||||
for filter in filters {
|
||||
mask = &mask & &evaluate(&filter);
|
||||
}
|
||||
assert_eq!(selected(mask), expected, "optimized: {plan}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_filter_op() {
|
||||
// equal
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(1.lit()),
|
||||
});
|
||||
let _ = SimpleFilterEvaluator::try_new(&expr).unwrap();
|
||||
|
||||
// swap operands
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(1.lit()),
|
||||
op: Operator::Lt,
|
||||
right: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
});
|
||||
let evaluator = SimpleFilterEvaluator::try_new(&expr).unwrap();
|
||||
assert!(evaluator.is_gt());
|
||||
assert_eq!(evaluator.column_name, "foo".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_on_array() {
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Eq,
|
||||
right: Box::new(1i64.lit()),
|
||||
});
|
||||
let evaluator = SimpleFilterEvaluator::try_new(&expr).unwrap();
|
||||
|
||||
let input_1 = Arc::new(datatypes::arrow::array::Int64Array::from(vec![1, 2, 3])) as _;
|
||||
let result = evaluator.evaluate_array(&input_1).unwrap();
|
||||
assert_eq!(result, BooleanBuffer::from(vec![true, false, false]));
|
||||
|
||||
let input_2 = Arc::new(datatypes::arrow::array::Int64Array::from(vec![1, 1, 1])) as _;
|
||||
let result = evaluator.evaluate_array(&input_2).unwrap();
|
||||
assert_eq!(result, BooleanBuffer::from(vec![true, true, true]));
|
||||
|
||||
let input_3 = Arc::new(datatypes::arrow::array::Int64Array::new_null(0)) as _;
|
||||
let result = evaluator.evaluate_array(&input_3).unwrap();
|
||||
assert_eq!(result, BooleanBuffer::from(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_on_scalar() {
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("foo"))),
|
||||
op: Operator::Lt,
|
||||
right: Box::new(1i64.lit()),
|
||||
});
|
||||
let evaluator = SimpleFilterEvaluator::try_new(&expr).unwrap();
|
||||
|
||||
let input_1 = ScalarValue::Int64(Some(1));
|
||||
let result = evaluator.evaluate_scalar(&input_1).unwrap();
|
||||
assert!(!result);
|
||||
|
||||
let input_2 = ScalarValue::Int64(Some(0));
|
||||
let result = evaluator.evaluate_scalar(&input_2).unwrap();
|
||||
assert!(result);
|
||||
|
||||
let input_3 = ScalarValue::Int64(None);
|
||||
let result = evaluator.evaluate_scalar(&input_3).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_filter_expression() {
|
||||
// Create an expression tree for: col = 'B' OR col = 'C' OR col = 'D'
|
||||
let col_eq_b = col("col").eq(lit("B"));
|
||||
let col_eq_c = col("col").eq(lit("C"));
|
||||
let col_eq_d = col("col").eq(lit("D"));
|
||||
|
||||
// Build the OR chain
|
||||
let col_or_expr = col_eq_b.or(col_eq_c).or(col_eq_d);
|
||||
|
||||
// Check that SimpleFilterEvaluator can handle OR chain
|
||||
let or_evaluator = SimpleFilterEvaluator::try_new(&col_or_expr).unwrap();
|
||||
assert_eq!(or_evaluator.column_name, "col");
|
||||
assert!(or_evaluator.is_or_eq_chain());
|
||||
assert_eq!(
|
||||
or_evaluator.literal_list_values().unwrap(),
|
||||
vec![Value::from("B"), Value::from("C"), Value::from("D")]
|
||||
);
|
||||
|
||||
// Create a schema and batch for testing
|
||||
let schema = Schema::new(vec![Field::new("col", DataType::Utf8, false)]);
|
||||
let df_schema = DFSchema::try_from(schema.clone()).unwrap();
|
||||
let props = ExecutionProps::new();
|
||||
let physical_expr = create_physical_expr(
|
||||
&col_or_expr,
|
||||
&df_schema,
|
||||
&props,
|
||||
&PhysicalPlanningContext::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create test data
|
||||
let col_data = Arc::new(datatypes::arrow::array::StringArray::from(vec![
|
||||
"B", "C", "E", "B", "C", "D", "F",
|
||||
]));
|
||||
let batch = RecordBatch::try_new(Arc::new(schema), vec![col_data]).unwrap();
|
||||
let expected = datatypes::arrow::array::StringArray::from(vec!["B", "C", "B", "C", "D"]);
|
||||
|
||||
// Filter the batch
|
||||
let filtered_batch = batch_filter(&batch, &physical_expr).unwrap();
|
||||
|
||||
// Expected: rows with col in ("B", "C", "D")
|
||||
// That would be rows 0, 1, 3, 4, 5
|
||||
assert_eq!(filtered_batch.num_rows(), 5);
|
||||
|
||||
let col_filtered = filtered_batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<datatypes::arrow::array::StringArray>()
|
||||
.unwrap();
|
||||
assert_eq!(col_filtered, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maybe_build_regex() {
|
||||
// Test case for RegexMatch (case sensitive, non-negative)
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexMatch,
|
||||
&ScalarValue::Utf8(Some("a.*b".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_some());
|
||||
assert!(!negative);
|
||||
assert!(regex.unwrap().is_match("axxb"));
|
||||
|
||||
// Test case for RegexIMatch (case insensitive, non-negative)
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexIMatch,
|
||||
&ScalarValue::Utf8(Some("a.*b".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_some());
|
||||
assert!(!negative);
|
||||
assert!(regex.unwrap().is_match("AxxB"));
|
||||
|
||||
// Test case for RegexNotMatch (case sensitive, negative)
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexNotMatch,
|
||||
&ScalarValue::Utf8(Some("a.*b".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_some());
|
||||
assert!(negative);
|
||||
|
||||
// Test case for RegexNotIMatch (case insensitive, negative)
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexNotIMatch,
|
||||
&ScalarValue::Utf8(Some("a.*b".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_some());
|
||||
assert!(negative);
|
||||
|
||||
// Test with empty regex pattern
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexMatch,
|
||||
&ScalarValue::Utf8(Some("".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_none());
|
||||
assert!(!negative);
|
||||
|
||||
// Test with non-regex operator
|
||||
let (regex, negative) = SimplePredicate::maybe_build_regex(
|
||||
Operator::Eq,
|
||||
&ScalarValue::Utf8(Some("a.*b".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(regex.is_none());
|
||||
assert!(!negative);
|
||||
|
||||
// Test with invalid regex pattern
|
||||
let result = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexMatch,
|
||||
&ScalarValue::Utf8(Some("a(b".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
// Test with non-string value
|
||||
let result = SimplePredicate::maybe_build_regex(
|
||||
Operator::RegexMatch,
|
||||
&ScalarValue::Int64(Some(123)),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
// Test with null value
|
||||
let result =
|
||||
SimplePredicate::maybe_build_regex(Operator::RegexMatch, &ScalarValue::Utf8(None));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_match_dictionary_array() {
|
||||
use datatypes::arrow::array::StringDictionaryBuilder;
|
||||
|
||||
// Create a StringDictionaryArray
|
||||
let mut builder = StringDictionaryBuilder::<UInt32Type>::new();
|
||||
builder.append("apple").unwrap();
|
||||
builder.append("banana").unwrap();
|
||||
builder.append("apple").unwrap();
|
||||
builder.append("cherry").unwrap();
|
||||
let dict_array = builder.finish();
|
||||
|
||||
// Test regex that matches "apple"
|
||||
let regex = regex::Regex::new(r"app.*").unwrap();
|
||||
let result = regexp_is_match_dictionary(&dict_array, Some(®ex)).unwrap();
|
||||
|
||||
// Should match indices 0 and 2 (both "apple")
|
||||
assert_eq!(result.len(), 4);
|
||||
assert!(result.value(0)); // "apple"
|
||||
assert!(!result.value(1)); // "banana"
|
||||
assert!(result.value(2)); // "apple"
|
||||
assert!(!result.value(3)); // "cherry"
|
||||
|
||||
// Test regex that matches "banana"
|
||||
let regex2 = regex::Regex::new(r"ban.*").unwrap();
|
||||
let result2 = regexp_is_match_dictionary(&dict_array, Some(®ex2)).unwrap();
|
||||
|
||||
assert!(!result2.value(0)); // "apple"
|
||||
assert!(result2.value(1)); // "banana"
|
||||
assert!(!result2.value(2)); // "apple"
|
||||
assert!(!result2.value(3)); // "cherry"
|
||||
|
||||
// Test with no regex (should match all)
|
||||
let result3 = regexp_is_match_dictionary(&dict_array, None).unwrap();
|
||||
assert!(result3.value(0));
|
||||
assert!(result3.value(1));
|
||||
assert!(result3.value(2));
|
||||
assert!(result3.value(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_scan_masks_preserve_sql_null_semantics() {
|
||||
let plain = Arc::new(datatypes::arrow::array::StringArray::from(vec![
|
||||
Some("api"),
|
||||
Some("API"),
|
||||
Some("db"),
|
||||
None,
|
||||
])) as ArrayRef;
|
||||
assert_regex_scan_masks(
|
||||
&plain,
|
||||
[
|
||||
vec![true, false, false, false],
|
||||
vec![true, true, false, false],
|
||||
vec![false, true, true, false],
|
||||
vec![false, false, true, false],
|
||||
],
|
||||
);
|
||||
|
||||
let dictionary = DictionaryArray::new(
|
||||
datatypes::arrow::array::UInt32Array::from(vec![
|
||||
Some(0),
|
||||
Some(1),
|
||||
Some(2),
|
||||
None,
|
||||
Some(3),
|
||||
]),
|
||||
Arc::new(datatypes::arrow::array::StringArray::from(vec![
|
||||
Some("api"),
|
||||
Some("API"),
|
||||
Some("db"),
|
||||
None,
|
||||
])),
|
||||
);
|
||||
let raw =
|
||||
regexp_is_match_dictionary(&dictionary, Some(&Regex::new("^api$").unwrap())).unwrap();
|
||||
assert!(raw.is_null(3)); // null dictionary key
|
||||
assert!(raw.is_null(4)); // non-null key referencing a null dictionary value
|
||||
|
||||
let dictionary = Arc::new(dictionary) as ArrayRef;
|
||||
assert_regex_scan_masks(
|
||||
&dictionary,
|
||||
[
|
||||
vec![true, false, false, false, false],
|
||||
vec![true, true, false, false, false],
|
||||
vec![false, true, true, false, false],
|
||||
vec![false, false, true, false, false],
|
||||
],
|
||||
);
|
||||
|
||||
let negative = regex_evaluator(Operator::RegexNotMatch);
|
||||
assert!(!negative.evaluate_scalar(&ScalarValue::Utf8(None)).unwrap());
|
||||
}
|
||||
|
||||
fn assert_regex_scan_masks(input: &ArrayRef, expected: [Vec<bool>; 4]) {
|
||||
for (op, expected) in [
|
||||
Operator::RegexMatch,
|
||||
Operator::RegexIMatch,
|
||||
Operator::RegexNotMatch,
|
||||
Operator::RegexNotIMatch,
|
||||
]
|
||||
.into_iter()
|
||||
.zip(expected)
|
||||
{
|
||||
assert_eq!(
|
||||
BooleanBuffer::from(expected),
|
||||
regex_evaluator(op).evaluate_array(input).unwrap(),
|
||||
"{op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn regex_evaluator(op: Operator) -> SimpleFilterEvaluator {
|
||||
let expr = Expr::BinaryExpr(BinaryExpr {
|
||||
left: Box::new(Expr::Column(Column::from_name("host"))),
|
||||
op,
|
||||
right: Box::new("^api$".lit()),
|
||||
});
|
||||
SimpleFilterEvaluator::try_new(&expr).unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use datafusion::execution::context::ExecutionProps;
|
||||
use datafusion::logical_expr::physical_planning_context::PhysicalPlanningContext;
|
||||
use datafusion::logical_expr::{col, lit};
|
||||
use datafusion::physical_expr::create_physical_expr;
|
||||
use datafusion_common::DFSchema;
|
||||
use datatypes::arrow::datatypes::{DataType, Field, Schema};
|
||||
|
||||
use datatypes::arrow::array::{BooleanArray, RecordBatch};
|
||||
|
||||
use crate::filter::{batch_filter, boolean_array_to_scan_mask};
|
||||
|
||||
#[test]
|
||||
fn batch_filter_test() {
|
||||
let expr = col("ts").gt(lit(123456u64));
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("a", DataType::Int32, true),
|
||||
Field::new("ts", DataType::UInt64, false),
|
||||
]);
|
||||
let df_schema = DFSchema::try_from(schema.clone()).unwrap();
|
||||
let props = ExecutionProps::new();
|
||||
let physical_expr = create_physical_expr(
|
||||
&expr,
|
||||
&df_schema,
|
||||
&props,
|
||||
&PhysicalPlanningContext::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::new(schema),
|
||||
vec![
|
||||
Arc::new(datatypes::arrow::array::Int32Array::from(vec![4, 5, 6])),
|
||||
Arc::new(datatypes::arrow::array::UInt64Array::from(vec![
|
||||
123456, 123457, 123458,
|
||||
])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let new_batch = batch_filter(&batch, &physical_expr).unwrap();
|
||||
assert_eq!(new_batch.num_rows(), 2);
|
||||
let first_column_values = new_batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<datatypes::arrow::array::Int32Array>()
|
||||
.unwrap();
|
||||
let expected = datatypes::arrow::array::Int32Array::from(vec![5, 6]);
|
||||
assert_eq!(first_column_values, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nullable_boolean_predicate_becomes_scan_mask() {
|
||||
let predicate = BooleanArray::from(vec![Some(true), None, Some(false)]);
|
||||
assert_eq!(
|
||||
BooleanArray::from(vec![true, false, false]),
|
||||
boolean_array_to_scan_mask(&predicate)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Timestamp-unit adaptation for predicates evaluated against historical SSTs.
|
||||
|
||||
use common_time::timestamp::div_mod_units;
|
||||
use datafusion::logical_expr::Operator;
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_common::arrow::array::{ArrayRef, Datum, Scalar};
|
||||
use datatypes::arrow::datatypes::TimeUnit;
|
||||
|
||||
use crate::filter::predicate::SimplePredicate;
|
||||
|
||||
/// Builds a tz-naive timestamp literal for `value` in `unit`.
|
||||
pub fn timestamp_scalar_value(value: i64, unit: TimeUnit) -> ScalarValue {
|
||||
match unit {
|
||||
TimeUnit::Second => ScalarValue::TimestampSecond(Some(value), None),
|
||||
TimeUnit::Millisecond => ScalarValue::TimestampMillisecond(Some(value), None),
|
||||
TimeUnit::Microsecond => ScalarValue::TimestampMicrosecond(Some(value), None),
|
||||
TimeUnit::Nanosecond => ScalarValue::TimestampNanosecond(Some(value), None),
|
||||
}
|
||||
}
|
||||
|
||||
impl SimplePredicate {
|
||||
pub(crate) fn cast_timestamp_unit(&self, target_unit: TimeUnit) -> Option<Self> {
|
||||
// Constants represent scan outcomes here. As in the public Matched/Pruned
|
||||
// result, UNKNOWN can be collapsed to false under these AND/OR-only trees.
|
||||
match self {
|
||||
Self::And(left, right) | Self::Or(left, right) => {
|
||||
let left = left.cast_timestamp_unit(target_unit)?;
|
||||
let right = right.cast_timestamp_unit(target_unit)?;
|
||||
let and = matches!(self, Self::And(..));
|
||||
Some(match (left, right) {
|
||||
(Self::Constant(Some(false)), _) | (_, Self::Constant(Some(false))) if and => {
|
||||
Self::Constant(Some(false))
|
||||
}
|
||||
(Self::Constant(Some(true)), _) | (_, Self::Constant(Some(true))) if !and => {
|
||||
Self::Constant(Some(true))
|
||||
}
|
||||
(Self::Constant(Some(true)), other) | (other, Self::Constant(Some(true)))
|
||||
if and =>
|
||||
{
|
||||
other
|
||||
}
|
||||
(Self::Constant(Some(false)), other) | (other, Self::Constant(Some(false)))
|
||||
if !and =>
|
||||
{
|
||||
other
|
||||
}
|
||||
(left, right) => {
|
||||
if and {
|
||||
Self::And(Box::new(left), Box::new(right))
|
||||
} else {
|
||||
Self::Or(Box::new(left), Box::new(right))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Self::IsNull { .. } => Some(self.clone()),
|
||||
Self::Constant(value) => Some(Self::Constant(Some(value.unwrap_or(false)))),
|
||||
Self::OrEqChain { literals } => {
|
||||
let mut converted = Vec::with_capacity(literals.len());
|
||||
for literal in literals {
|
||||
let scalar = ScalarValue::try_from_array(literal.get().0, 0).ok()?;
|
||||
let (value, unit) = timestamp_scalar_parts(&scalar)?;
|
||||
let Some(value) = value else {
|
||||
continue;
|
||||
};
|
||||
let cast = div_mod_units(value, unit.into(), target_unit.into())?;
|
||||
if cast.remainder == 0 {
|
||||
converted.push(timestamp_scalar(cast.quotient, target_unit)?);
|
||||
}
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Some(Self::Constant(Some(false)));
|
||||
}
|
||||
Some(Self::OrEqChain {
|
||||
literals: converted,
|
||||
})
|
||||
}
|
||||
Self::Comparison { literal, op, .. } => {
|
||||
let scalar = ScalarValue::try_from_array(literal.get().0, 0).ok()?;
|
||||
let (value, unit) = timestamp_scalar_parts(&scalar)?;
|
||||
let Some(value) = value else {
|
||||
return Some(Self::Constant(Some(false)));
|
||||
};
|
||||
if unit == target_unit {
|
||||
return Some(self.clone());
|
||||
}
|
||||
let cast = div_mod_units(value, unit.into(), target_unit.into())?;
|
||||
let divisible = cast.remainder == 0;
|
||||
let literal = timestamp_scalar(cast.quotient, target_unit)?;
|
||||
let filter = |op| Self::Comparison {
|
||||
literal: literal.clone(),
|
||||
op,
|
||||
regex: None,
|
||||
regex_negative: false,
|
||||
};
|
||||
Some(match op {
|
||||
Operator::Eq if divisible => filter(Operator::Eq),
|
||||
Operator::Eq => Self::Constant(Some(false)),
|
||||
Operator::NotEq if divisible => filter(Operator::NotEq),
|
||||
Operator::NotEq => Self::Constant(Some(true)),
|
||||
// The quotient is floor(L), including negative timestamps.
|
||||
// v > L is v > quotient whether or not L is representable.
|
||||
Operator::Gt => filter(Operator::Gt),
|
||||
Operator::GtEq if divisible => filter(Operator::GtEq),
|
||||
// Between target values, v >= L is v > quotient.
|
||||
Operator::GtEq => filter(Operator::Gt),
|
||||
Operator::Lt if divisible => filter(Operator::Lt),
|
||||
// Between target values, v < L is v <= quotient.
|
||||
Operator::Lt => filter(Operator::LtEq),
|
||||
Operator::LtEq => filter(Operator::LtEq),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the value and unit from a tz-naive timestamp scalar.
|
||||
fn timestamp_scalar_parts(scalar: &ScalarValue) -> Option<(Option<i64>, TimeUnit)> {
|
||||
let (value, unit, timezone) = match scalar {
|
||||
ScalarValue::TimestampSecond(v, tz) => (*v, TimeUnit::Second, tz),
|
||||
ScalarValue::TimestampMillisecond(v, tz) => (*v, TimeUnit::Millisecond, tz),
|
||||
ScalarValue::TimestampMicrosecond(v, tz) => (*v, TimeUnit::Microsecond, tz),
|
||||
ScalarValue::TimestampNanosecond(v, tz) => (*v, TimeUnit::Nanosecond, tz),
|
||||
_ => return None,
|
||||
};
|
||||
// A timezone-aware literal doesn't compare against a tz-naive column;
|
||||
// leave it to the caller instead of guessing the intended semantics.
|
||||
(timezone.is_none()).then_some((value, unit))
|
||||
}
|
||||
|
||||
/// Builds a one-element scalar array holding `value` in `unit`.
|
||||
fn timestamp_scalar(value: i64, unit: TimeUnit) -> Option<Scalar<ArrayRef>> {
|
||||
timestamp_scalar_value(value, unit).to_scalar().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use datafusion::execution::context::ExecutionProps;
|
||||
use datafusion::logical_expr::physical_planning_context::PhysicalPlanningContext;
|
||||
use datafusion::logical_expr::{Expr, col, lit};
|
||||
use datafusion::physical_expr::create_physical_expr;
|
||||
use datafusion_common::arrow::array::ArrayRef;
|
||||
use datafusion_common::arrow::buffer::BooleanBuffer;
|
||||
use datafusion_common::cast::as_boolean_array;
|
||||
use datafusion_common::{DFSchema, ScalarValue};
|
||||
use datatypes::arrow::array::{
|
||||
RecordBatch, TimestampMillisecondArray, TimestampNanosecondArray,
|
||||
};
|
||||
use datatypes::arrow::datatypes::{Field, Schema};
|
||||
use datatypes::data_type::ConcreteDataType;
|
||||
|
||||
use crate::filter::{SimpleFilterEvaluator, TimestampUnitCast, boolean_array_to_scan_mask};
|
||||
|
||||
#[test]
|
||||
fn compound_timestamp_filters_preserve_unit_conversion() {
|
||||
use datatypes::arrow::array::TimestampMicrosecondArray;
|
||||
|
||||
let null = || lit(ScalarValue::TimestampMicrosecond(None, None));
|
||||
let predicates = [
|
||||
col("ts")
|
||||
.gt(lit(ts_us(-1500)))
|
||||
.and(col("ts").lt(lit(ts_us(500)))),
|
||||
col("ts")
|
||||
.eq(lit(ts_us(500)))
|
||||
.or(col("ts").gt_eq(lit(ts_us(1500)))),
|
||||
col("ts")
|
||||
.is_not_null()
|
||||
.and(col("ts").not_eq(lit(ts_us(500)))),
|
||||
col("ts").eq(null()).or(col("ts").eq(lit(ts_us(-1000)))),
|
||||
col("ts")
|
||||
.eq(lit(ts_us(500)))
|
||||
.and(col("ts").gt(lit(ts_us(0)))),
|
||||
col("ts")
|
||||
.not_eq(lit(ts_us(500)))
|
||||
.or(col("ts").lt(lit(ts_us(0)))),
|
||||
];
|
||||
let native: ArrayRef = Arc::new(TimestampMicrosecondArray::from(vec![
|
||||
-2000, -1000, 0, 1000, 2000,
|
||||
]));
|
||||
let old: ArrayRef = Arc::new(TimestampMillisecondArray::from(vec![-2, -1, 0, 1, 2]));
|
||||
let schema = Arc::new(Schema::new(vec![Field::new(
|
||||
"ts",
|
||||
native.data_type().clone(),
|
||||
false,
|
||||
)]));
|
||||
let batch = RecordBatch::try_new(schema.clone(), vec![native]).unwrap();
|
||||
for expr in predicates {
|
||||
let physical = create_physical_expr(
|
||||
&expr,
|
||||
&DFSchema::try_from(schema.as_ref().clone()).unwrap(),
|
||||
&ExecutionProps::new(),
|
||||
&PhysicalPlanningContext::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = physical
|
||||
.evaluate(&batch)
|
||||
.unwrap()
|
||||
.into_array(batch.num_rows())
|
||||
.unwrap();
|
||||
let expected = boolean_array_to_scan_mask(as_boolean_array(&result).unwrap())
|
||||
.values()
|
||||
.clone();
|
||||
let actual = match cast_to_ms(&expr).unwrap() {
|
||||
TimestampUnitCast::Matched => BooleanBuffer::new_set(old.len()),
|
||||
TimestampUnitCast::Pruned => BooleanBuffer::new_unset(old.len()),
|
||||
TimestampUnitCast::Filter(filter) => filter.evaluate_array(&old).unwrap(),
|
||||
};
|
||||
assert_eq!(actual, expected, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_us(v: i64) -> ScalarValue {
|
||||
ScalarValue::TimestampMicrosecond(Some(v), None)
|
||||
}
|
||||
|
||||
fn cast_to_ms(expr: &Expr) -> Option<TimestampUnitCast> {
|
||||
SimpleFilterEvaluator::try_new(expr)
|
||||
.unwrap()
|
||||
.cast_timestamp_unit(&ConcreteDataType::timestamp_millisecond_datatype())
|
||||
}
|
||||
|
||||
/// Evaluates a cast `Filter` outcome against millisecond values and
|
||||
/// returns the mask.
|
||||
fn eval_ms_mask(cast: Option<TimestampUnitCast>, values: &[i64]) -> Vec<bool> {
|
||||
let filter = match cast.expect("cast must apply") {
|
||||
TimestampUnitCast::Filter(filter) => filter,
|
||||
other => panic!("expected Filter outcome, got {other:?}"),
|
||||
};
|
||||
let array = Arc::new(TimestampMillisecondArray::from(values.to_vec())) as ArrayRef;
|
||||
filter.evaluate_array(&array).unwrap().iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_converts_representable_literal() {
|
||||
// ts = 7_000_000us evaluated against a ms column becomes ts = 7000ms.
|
||||
assert_eq!(
|
||||
vec![false, true, false],
|
||||
eval_ms_mask(
|
||||
cast_to_ms(&col("ts").eq(lit(ts_us(7_000_000)))),
|
||||
&[6_999, 7_000, 7_001]
|
||||
)
|
||||
);
|
||||
// != complements =.
|
||||
assert_eq!(
|
||||
vec![true, false, true],
|
||||
eval_ms_mask(
|
||||
cast_to_ms(&col("ts").not_eq(lit(ts_us(7_000_000)))),
|
||||
&[6_999, 7_000, 7_001]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_prunes_non_representable_equality() {
|
||||
// 7_000_500us is not a whole millisecond: no ms row can equal it,
|
||||
// and every ms row satisfies `!=`.
|
||||
assert!(matches!(
|
||||
cast_to_ms(&col("ts").eq(lit(ts_us(7_000_500)))),
|
||||
Some(TimestampUnitCast::Pruned)
|
||||
));
|
||||
assert!(matches!(
|
||||
cast_to_ms(&col("ts").not_eq(lit(ts_us(7_000_500)))),
|
||||
Some(TimestampUnitCast::Matched)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_strengthens_inequalities() {
|
||||
// 2_500_500us is strictly between 2500ms and 2501ms: the cast must
|
||||
// not round the literal to a boundary the original predicate
|
||||
// excludes (>= 2500ms would wrongly match 2500ms).
|
||||
let values = vec![2_499, 2_500, 2_501];
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").gt(lit(ts_us(2_500_500)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").gt_eq(lit(ts_us(2_500_500)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![true, true, false],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").lt(lit(ts_us(2_500_500)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![true, true, false],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").lt_eq(lit(ts_us(2_500_500)))), &values)
|
||||
);
|
||||
|
||||
// Representable boundary keeps the original operator.
|
||||
let values = vec![2_499, 2_500, 2_501];
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").gt(lit(ts_us(2_500_000)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![false, true, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").gt_eq(lit(ts_us(2_500_000)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![true, false, false],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").lt(lit(ts_us(2_500_000)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![true, true, false],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").lt_eq(lit(ts_us(2_500_000)))), &values)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_handles_negative_instants() {
|
||||
// -2_500_500us floors to -2501ms with remainder 500us: only rows
|
||||
// with instant >= -2.5005ms may match >= / >.
|
||||
let values = vec![-2_502, -2_501, -2_500];
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(
|
||||
cast_to_ms(&col("ts").gt_eq(lit(ts_us(-2_500_500)))),
|
||||
&values
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").gt(lit(ts_us(-2_500_500)))), &values)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![true, true, false],
|
||||
eval_ms_mask(
|
||||
cast_to_ms(&col("ts").lt_eq(lit(ts_us(-2_500_500)))),
|
||||
&values
|
||||
)
|
||||
);
|
||||
// Exactly representable negative literal: -2_500_000us == -2500ms.
|
||||
assert_eq!(
|
||||
vec![false, false, true],
|
||||
eval_ms_mask(cast_to_ms(&col("ts").eq(lit(ts_us(-2_500_000)))), &values)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_or_chain_drops_unrepresentable_literals() {
|
||||
// Only 7_000_000us is a whole millisecond; 7_000_500us cannot match.
|
||||
let expr = col("ts")
|
||||
.eq(lit(ts_us(7_000_500)))
|
||||
.or(col("ts").eq(lit(ts_us(7_000_000))));
|
||||
assert_eq!(
|
||||
vec![false, true],
|
||||
eval_ms_mask(cast_to_ms(&expr), &[6_999, 7_000])
|
||||
);
|
||||
|
||||
// A chain where no literal is representable matches nothing.
|
||||
let expr = col("ts")
|
||||
.eq(lit(ts_us(7_000_500)))
|
||||
.or(col("ts").eq(lit(ts_us(6_000_500))));
|
||||
assert!(matches!(cast_to_ms(&expr), Some(TimestampUnitCast::Pruned)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_null_literal_prunes() {
|
||||
// A NULL literal never compares true.
|
||||
assert!(matches!(
|
||||
cast_to_ms(&col("ts").eq(lit(ScalarValue::TimestampMicrosecond(None, None)))),
|
||||
Some(TimestampUnitCast::Pruned)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_same_unit_returns_filter_directly() {
|
||||
// A literal already in the target unit is returned unchanged.
|
||||
let expr = col("ts").eq(lit(ts_us(7_000_000)));
|
||||
let filter = SimpleFilterEvaluator::try_new(&expr).unwrap();
|
||||
let cast = filter
|
||||
.cast_timestamp_unit(&ConcreteDataType::timestamp_microsecond_datatype())
|
||||
.unwrap();
|
||||
let TimestampUnitCast::Filter(f) = cast else {
|
||||
panic!("expected Filter, got {cast:?}")
|
||||
};
|
||||
assert!(filter.is_eq() && f.is_eq());
|
||||
assert_eq!(filter.column_name(), f.column_name());
|
||||
assert_eq!(filter.literal_value(), f.literal_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_rejects_non_timestamps() {
|
||||
// Non-timestamp target type.
|
||||
assert!(
|
||||
SimpleFilterEvaluator::try_new(&col("ts").gt(lit(ts_us(1))))
|
||||
.unwrap()
|
||||
.cast_timestamp_unit(&ConcreteDataType::int64_datatype())
|
||||
.is_none()
|
||||
);
|
||||
// Non-timestamp literal against a timestamp target.
|
||||
assert!(cast_to_ms(&col("ts").gt(lit(42_i64))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_timestamp_unit_to_finer_unit() {
|
||||
// 5 seconds against a nanosecond column becomes 5_000_000_000ns.
|
||||
let filter = SimpleFilterEvaluator::try_new(
|
||||
&col("ts").eq(lit(ScalarValue::TimestampSecond(Some(5), None))),
|
||||
)
|
||||
.unwrap()
|
||||
.cast_timestamp_unit(&ConcreteDataType::timestamp_nanosecond_datatype())
|
||||
.and_then(|cast| match cast {
|
||||
TimestampUnitCast::Filter(filter) => Some(filter),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let array = Arc::new(TimestampNanosecondArray::from(vec![
|
||||
4_999_999_999,
|
||||
5_000_000_000,
|
||||
])) as ArrayRef;
|
||||
assert_eq!(
|
||||
vec![false, true],
|
||||
filter
|
||||
.evaluate_array(&array)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("cargo:rerun-if-env-changed=RUSTC");
|
||||
println!("cargo:rerun-if-env-changed=GREPTIME_PRODUCT_NAME");
|
||||
println!("cargo:rerun-if-env-changed=CARGO_WORKSPACE_DIR");
|
||||
|
||||
// The "CARGO_WORKSPACE_DIR" is set manually (not by Rust itself) in Cargo config file, to
|
||||
// solve the problem where the "CARGO_MANIFEST_DIR" is not what we want when this repo is
|
||||
// made as a submodule in another repo.
|
||||
let workspace_dir =
|
||||
env::var("CARGO_WORKSPACE_DIR").or_else(|_| env::var("CARGO_MANIFEST_DIR"))?;
|
||||
// Resolve the workspace root from CARGO_MANIFEST_DIR (this crate is
|
||||
// src/common/version, three levels below the root). The path remains absolute
|
||||
// and is used at build time. CARGO_WORKSPACE_DIR remains an explicit override
|
||||
// for submodule builds; see https://github.com/rust-lang/cargo/issues/3946.
|
||||
let workspace_dir = match env::var("CARGO_WORKSPACE_DIR") {
|
||||
Ok(dir) if !dir.is_empty() => dir,
|
||||
_ => env::var("CARGO_MANIFEST_DIR").map(|dir| {
|
||||
PathBuf::from(dir)
|
||||
.join("../../..")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})?,
|
||||
};
|
||||
let workspace_root = PathBuf::from(&workspace_dir);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
|
||||
@@ -554,8 +554,8 @@ impl ErrorExt for Error {
|
||||
use Error::*;
|
||||
|
||||
match self {
|
||||
RegionNotReady { .. } | RegionNotFound { .. } => RetryHint::RetryAfterRouteRefresh,
|
||||
RegionBusy { .. }
|
||||
| RegionNotReady { .. }
|
||||
| ConcurrentQueryLimiterClosed { .. }
|
||||
| ConcurrentQueryLimiterTimeout { .. } => RetryHint::Retryable,
|
||||
NewPlanDecoder { source, .. } | ExecuteLogicalPlan { source, .. } => {
|
||||
@@ -595,14 +595,28 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_region_state_hints_are_retryable() {
|
||||
fn test_region_state_retry_hints() {
|
||||
let region_id = RegionId::new(1024, 1);
|
||||
|
||||
let err = RegionBusySnafu { region_id }.build();
|
||||
assert_eq!(err.retry_hint(), RetryHint::Retryable);
|
||||
|
||||
let err = RegionNotReadySnafu { region_id }.build();
|
||||
assert_eq!(err.retry_hint(), RetryHint::Retryable);
|
||||
for (error, status, hint) in [
|
||||
(
|
||||
RegionBusySnafu { region_id }.build(),
|
||||
StatusCode::RegionBusy,
|
||||
RetryHint::Retryable,
|
||||
),
|
||||
(
|
||||
RegionNotReadySnafu { region_id }.build(),
|
||||
StatusCode::RegionNotReady,
|
||||
RetryHint::RetryAfterRouteRefresh,
|
||||
),
|
||||
(
|
||||
RegionNotFoundSnafu { region_id }.build(),
|
||||
StatusCode::RegionNotFound,
|
||||
RetryHint::RetryAfterRouteRefresh,
|
||||
),
|
||||
] {
|
||||
assert_eq!(error.status_code(), status);
|
||||
assert_eq!(error.retry_hint(), hint);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2566,7 +2566,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert_matches!(&err, error::Error::HandleBatchOpenRequest { .. });
|
||||
assert_eq!(err.status_code(), StatusCode::RegionNotFound);
|
||||
assert_eq!(err.retry_hint(), RetryHint::NonRetryable);
|
||||
assert_eq!(err.retry_hint(), RetryHint::RetryAfterRouteRefresh);
|
||||
}
|
||||
|
||||
struct CurrentEngineTest {
|
||||
|
||||
@@ -86,8 +86,10 @@ pub fn vector_type_value_to_string(val: &[u8], dim: u32) -> Result<String> {
|
||||
}
|
||||
|
||||
let elements = val
|
||||
.chunks_exact(std::mem::size_of::<f32>())
|
||||
.map(|e| f32::from_le_bytes(e.try_into().unwrap()));
|
||||
.as_chunks::<{ std::mem::size_of::<f32>() }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|e| f32::from_le_bytes(*e));
|
||||
|
||||
let mut s = String::from("[");
|
||||
for (i, e) in elements.enumerate() {
|
||||
|
||||
+16
-3
@@ -141,8 +141,9 @@ fn relation_desc_from_output(
|
||||
columns: &[ColumnSchema],
|
||||
lineage: &[Option<usize>],
|
||||
source_primary_key_indices: &[usize],
|
||||
plan: &LogicalPlan,
|
||||
) -> RelationDesc {
|
||||
let keys = source_primary_key_indices
|
||||
let mut keys = source_primary_key_indices
|
||||
.iter()
|
||||
.filter_map(|source_index| {
|
||||
lineage
|
||||
@@ -150,6 +151,14 @@ fn relation_desc_from_output(
|
||||
.position(|index| index == &Some(*source_index))
|
||||
})
|
||||
.collect_vec();
|
||||
// DISTINCT defines identity from every output tuple; the source timestamp participates
|
||||
// separately as the relation time index, so it is excluded from the tag key.
|
||||
if matches!(plan, LogicalPlan::Distinct(Distinct::All(_))) {
|
||||
keys = columns
|
||||
.iter()
|
||||
.positions(|column| !column.is_time_index())
|
||||
.collect();
|
||||
}
|
||||
let time_index = columns.iter().position(ColumnSchema::is_time_index);
|
||||
RelationDesc {
|
||||
typ: crate::repr::RelationType {
|
||||
@@ -849,8 +858,12 @@ impl StreamingEngine {
|
||||
let source_primary_key_indices = source_meta.primary_key_indices;
|
||||
stateless::validate_source_scan(&flow_plan, source_table_id, &source_schema)?;
|
||||
let (inferred_schema, lineage) = output_column_schemas(&flow_plan, &source_schema)?;
|
||||
let inferred_relation =
|
||||
relation_desc_from_output(&inferred_schema, &lineage, &source_primary_key_indices);
|
||||
let inferred_relation = relation_desc_from_output(
|
||||
&inferred_schema,
|
||||
&lineage,
|
||||
&source_primary_key_indices,
|
||||
&flow_plan,
|
||||
);
|
||||
let sink_exists = self.fetch_table_pk_schema(sink_table_name).await?.is_some();
|
||||
if !sink_exists {
|
||||
validate_auto_column_names(&inferred_schema)?;
|
||||
|
||||
@@ -20,7 +20,7 @@ use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, TimeUnit as
|
||||
use datafusion::catalog::MemTable;
|
||||
use datafusion::datasource::provider_as_source;
|
||||
use datafusion_common::TableReference;
|
||||
use datafusion_expr::LogicalPlanBuilder;
|
||||
use datafusion_expr::{LogicalPlan, LogicalPlanBuilder};
|
||||
use datatypes::schema::{ColumnDefaultConstraint, ColumnSchema, Schema, SchemaBuilder};
|
||||
use store_api::storage::{ConcreteDataType, TableId};
|
||||
use table::metadata::{TableInfo, TableInfoBuilder, TableMetaBuilder};
|
||||
@@ -194,10 +194,133 @@ fn stateless_distinct_preserves_direct_column_lineage() {
|
||||
let (output, lineage) = super::output_column_schemas(&plan, &source).unwrap();
|
||||
assert_eq!(output[0].name, "dis");
|
||||
assert_eq!(lineage, vec![Some(0)]);
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, &[0]);
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, &[0], &plan);
|
||||
assert_eq!(relation.typ.keys[0].column_indices, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateless_distinct_output_is_always_the_sink_key() {
|
||||
let source = Arc::new(Schema::new(vec![
|
||||
ColumnSchema::new("v", ConcreteDataType::int32_datatype(), true),
|
||||
ColumnSchema::new("k", ConcreteDataType::int32_datatype(), true),
|
||||
ColumnSchema::new("other", ConcreteDataType::int32_datatype(), true),
|
||||
ColumnSchema::new(
|
||||
"ts",
|
||||
ConcreteDataType::timestamp_millisecond_datatype(),
|
||||
false,
|
||||
)
|
||||
.with_time_index(true),
|
||||
]));
|
||||
// All cases share one provider so the key assertions exercise only plan/output metadata.
|
||||
let provider = Arc::new(
|
||||
MemTable::try_new(
|
||||
Arc::new(datafusion::arrow::datatypes::Schema::new(vec![
|
||||
Field::new("v", ArrowDataType::Int32, true),
|
||||
Field::new("k", ArrowDataType::Int32, true),
|
||||
Field::new("other", ArrowDataType::Int32, true),
|
||||
Field::new(
|
||||
"ts",
|
||||
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
|
||||
false,
|
||||
),
|
||||
])),
|
||||
vec![vec![]],
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let make_plan = |expressions: Vec<datafusion_expr::Expr>, distinct: bool| {
|
||||
let builder = LogicalPlanBuilder::scan(
|
||||
TableReference::bare("source"),
|
||||
provider_as_source(provider.clone()),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.project(expressions)
|
||||
.unwrap();
|
||||
if distinct {
|
||||
builder.distinct().unwrap().build().unwrap()
|
||||
} else {
|
||||
builder.build().unwrap()
|
||||
}
|
||||
};
|
||||
let assert_key = |plan: &LogicalPlan, source_keys: &[usize], expected: &[usize]| {
|
||||
let (output, lineage) = super::output_column_schemas(plan, &source).unwrap();
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, source_keys, plan);
|
||||
assert_eq!(
|
||||
relation
|
||||
.typ
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| key.column_indices.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
if expected.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![expected.to_vec()]
|
||||
}
|
||||
);
|
||||
relation
|
||||
};
|
||||
|
||||
// No source PK: all distinct output fields form the key.
|
||||
assert_key(&make_plan(vec![datafusion_expr::col("v")], true), &[], &[0]);
|
||||
// A surviving subset of a composite source PK cannot identify projected DISTINCT tuples.
|
||||
assert_key(
|
||||
&make_plan(
|
||||
vec![datafusion_expr::col("v"), datafusion_expr::col("other")],
|
||||
true,
|
||||
),
|
||||
&[0, 1],
|
||||
&[0, 1],
|
||||
);
|
||||
// Even a retained source PK needs the additional selected fields in the DISTINCT key.
|
||||
assert_key(
|
||||
&make_plan(
|
||||
vec![datafusion_expr::col("v"), datafusion_expr::col("other")],
|
||||
true,
|
||||
),
|
||||
&[0],
|
||||
&[0, 1],
|
||||
);
|
||||
// Aliases and computed output expressions are included by output position.
|
||||
let alias_plan = make_plan(
|
||||
vec![
|
||||
datafusion_expr::col("v").alias("renamed"),
|
||||
(datafusion_expr::col("v") + datafusion_expr::col("other")).alias("computed"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let (output, lineage) = super::output_column_schemas(&alias_plan, &source).unwrap();
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, &[0], &alias_plan);
|
||||
assert_eq!(
|
||||
output
|
||||
.iter()
|
||||
.map(|column| column.name.as_str())
|
||||
.collect_vec(),
|
||||
["renamed", "computed"]
|
||||
);
|
||||
assert_eq!(relation.typ.keys[0].column_indices, vec![0, 1]);
|
||||
|
||||
// A selected time index remains the relation time index, not part of the tag key.
|
||||
let relation = assert_key(
|
||||
&make_plan(
|
||||
vec![datafusion_expr::col("v"), datafusion_expr::col("ts")],
|
||||
true,
|
||||
),
|
||||
&[0],
|
||||
&[0],
|
||||
);
|
||||
assert_eq!(relation.typ.time_index, Some(1));
|
||||
|
||||
// Non-DISTINCT plans retain only inherited source-key lineage (or no key if none survives).
|
||||
let ordinary = make_plan(
|
||||
vec![datafusion_expr::col("v"), datafusion_expr::col("other")],
|
||||
false,
|
||||
);
|
||||
assert_key(&ordinary, &[0, 1], &[0]);
|
||||
assert_key(&ordinary, &[], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateless_normalizes_dictionary_output_type() {
|
||||
let field = Field::new_dictionary("host", ArrowDataType::UInt32, ArrowDataType::Utf8, true);
|
||||
@@ -220,7 +343,7 @@ fn stateless_normalizes_dictionary_output_type() {
|
||||
assert_eq!(output[0].data_type, ConcreteDataType::string_datatype());
|
||||
assert_eq!(lineage, vec![Some(0)]);
|
||||
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, &[0]);
|
||||
let relation = super::relation_desc_from_output(&output, &lineage, &[0], &plan);
|
||||
assert_eq!(relation.typ.keys[0].column_indices, vec![0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1400,6 +1400,12 @@ impl BatchingTask {
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let (affected_rows, _) = res.output.extract_rows_and_cost();
|
||||
if matches!(&res.output.data, common_query::OutputData::AffectedRows(_)) {
|
||||
res.metrics.wait_ready().await;
|
||||
}
|
||||
if let Some(error) = res.metrics.completion_error() {
|
||||
warn!("Flow {flow_id} completed with terminal metrics error: {error}");
|
||||
}
|
||||
debug!(
|
||||
"Flow {flow_id} executed, affected_rows: {affected_rows:?}, elapsed: {:?}, watermark: {:?}",
|
||||
elapsed,
|
||||
|
||||
@@ -22,7 +22,6 @@ use common_recordbatch::recordbatch::merge_record_batches;
|
||||
use common_recordbatch::{RecordBatch, util};
|
||||
use common_time::Timestamp;
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_common::tree_node::TreeNode as _;
|
||||
use datafusion_expr::{GroupingSet, TableScanBuilder};
|
||||
use datatypes::arrow::array::{Array, AsArray};
|
||||
use datatypes::arrow::datatypes::{Float64Type, Int64Type, UInt64Type};
|
||||
|
||||
@@ -42,7 +42,9 @@ remote datanodes via `operator`/`client`.
|
||||
- **Insert** (`instance/grpc.rs`): `handle_inserts` / `handle_row_inserts` →
|
||||
`check_permission` → `operator`'s `Inserter` (schema validation, optional
|
||||
auto-create, partition routing, meter admission) → local `RegionServer`
|
||||
(standalone) or RPC to datanodes (distributed). Arrow bulk inserts pass the
|
||||
(standalone) or RPC to datanodes (distributed). `FrontendBuilder` configures
|
||||
the operator write dispatcher with frontend retry options for distributed
|
||||
inserts, deletes, and bulk writes. Arrow bulk inserts pass the
|
||||
request channel to `Inserter` and check meter admission for each nonempty batch.
|
||||
- Finite ingestion requests split internally admit their total rows per database
|
||||
before dispatch (`operator::insert::admit_write` / `admit_row_insert_batches`).
|
||||
@@ -96,7 +98,8 @@ cargo nextest run -p frontend
|
||||
`servers`; permissions, planning, and routing live here.
|
||||
- Standalone vs distributed diverge in datanode access (the `standalone` crate's
|
||||
local `RegionServer` adapter vs `NodeClients` RPC), MetaClient usage, and
|
||||
whether heartbeat matters. In standalone, the cache invalidator is a no-op.
|
||||
whether heartbeat matters. The builder defaults local invalidation to its
|
||||
layered cache registry; callers may supply a custom invalidator.
|
||||
|
||||
## Maintenance contract
|
||||
|
||||
|
||||
@@ -323,6 +323,13 @@ pub enum Error {
|
||||
name: String,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid write retry config: {}", reason))]
|
||||
InvalidWriteRetryConfig {
|
||||
reason: &'static str,
|
||||
#[snafu(implicit)]
|
||||
location: Location,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid tls config"))]
|
||||
InvalidTlsConfig {
|
||||
#[snafu(source)]
|
||||
@@ -391,7 +398,8 @@ impl ErrorExt for Error {
|
||||
| Error::IllegalAuthConfig { .. }
|
||||
| Error::ColumnNoneDefaultValue { .. }
|
||||
| Error::IncompleteGrpcRequest { .. }
|
||||
| Error::InvalidTlsConfig { .. } => StatusCode::InvalidArguments,
|
||||
| Error::InvalidTlsConfig { .. }
|
||||
| Error::InvalidWriteRetryConfig { .. } => StatusCode::InvalidArguments,
|
||||
|
||||
Error::NotSupported { .. } => StatusCode::Unsupported,
|
||||
|
||||
|
||||
@@ -36,9 +36,10 @@ use crate::heartbeat::HeartbeatTask;
|
||||
use crate::instance::Instance;
|
||||
use crate::service_config::{
|
||||
InfluxdbOptions, JaegerOptions, MysqlOptions, OpentsdbOptions, OtlpOptions,
|
||||
PendingRowsBatcherOptions, PostgresOptions, PromStoreOptions,
|
||||
PendingRowsBatcherOptions, PostgresOptions, PromStoreOptions, WriteRetryOptions,
|
||||
};
|
||||
|
||||
/// Configures frontend protocols, query execution, and distributed write retries.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct FrontendOptions {
|
||||
@@ -75,6 +76,8 @@ pub struct FrontendOptions {
|
||||
pub meta_client: Option<MetaClientOptions>,
|
||||
pub logging: LoggingOptions,
|
||||
pub datanode: DatanodeClientOptions,
|
||||
/// Retry policy for retryable distributed write failures.
|
||||
pub write_retry: WriteRetryOptions,
|
||||
pub user_provider: Option<String>,
|
||||
pub tracing: TracingOptions,
|
||||
pub query: QueryOptions,
|
||||
@@ -112,6 +115,7 @@ impl Default for FrontendOptions {
|
||||
meta_client: None,
|
||||
logging: LoggingOptions::default(),
|
||||
datanode: DatanodeClientOptions::default(),
|
||||
write_retry: WriteRetryOptions::default(),
|
||||
user_provider: None,
|
||||
tracing: TracingOptions::default(),
|
||||
query: QueryOptions::default(),
|
||||
@@ -227,6 +231,72 @@ mod tests {
|
||||
type GrpcStream<T> =
|
||||
Pin<Box<dyn Stream<Item = std::result::Result<T, Status>> + Send + Sync + 'static>>;
|
||||
|
||||
#[test]
|
||||
fn test_write_retry_config() {
|
||||
let defaults: FrontendOptions = toml::from_str("").unwrap();
|
||||
assert_eq!(defaults.write_retry, WriteRetryOptions::default());
|
||||
let options: FrontendOptions = toml::from_str(
|
||||
r#"
|
||||
[write_retry]
|
||||
max_retries = 5
|
||||
initial_backoff = "250ms"
|
||||
max_backoff = "5s"
|
||||
backoff_multiplier = 3.0
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
options.write_retry,
|
||||
WriteRetryOptions {
|
||||
max_retries: 5,
|
||||
initial_backoff: Duration::from_millis(250),
|
||||
max_backoff: Duration::from_secs(5),
|
||||
backoff_multiplier: 3.0,
|
||||
}
|
||||
);
|
||||
options.write_retry.validate().unwrap();
|
||||
let serialized = toml::to_string(&options).unwrap();
|
||||
let parsed: FrontendOptions = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(options.write_retry, parsed.write_retry);
|
||||
let disabled: FrontendOptions = toml::from_str("[write_retry]\nmax_retries = 0").unwrap();
|
||||
assert_eq!(disabled.write_retry.max_retries, 0);
|
||||
assert_eq!(
|
||||
disabled.write_retry.initial_backoff,
|
||||
defaults.write_retry.initial_backoff
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_retry_config_from_env() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("FRONTEND_RETRY_TEST__WRITE_RETRY__MAX_RETRIES", Some("4")),
|
||||
(
|
||||
"FRONTEND_RETRY_TEST__WRITE_RETRY__INITIAL_BACKOFF",
|
||||
Some("500ms"),
|
||||
),
|
||||
("FRONTEND_RETRY_TEST__WRITE_RETRY__MAX_BACKOFF", Some("8s")),
|
||||
(
|
||||
"FRONTEND_RETRY_TEST__WRITE_RETRY__BACKOFF_MULTIPLIER",
|
||||
Some("1.5"),
|
||||
),
|
||||
],
|
||||
|| {
|
||||
let options =
|
||||
FrontendOptions::load_layered_options(None, "FRONTEND_RETRY_TEST").unwrap();
|
||||
assert_eq!(
|
||||
options.write_retry,
|
||||
WriteRetryOptions {
|
||||
max_retries: 4,
|
||||
initial_backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(8),
|
||||
backoff_multiplier: 1.5,
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logical_batcher_protocols_from_env() {
|
||||
temp_env::with_vars(
|
||||
|
||||
@@ -25,7 +25,7 @@ use common_base::Plugins;
|
||||
use common_datasource::object_store::LocalFileAccess;
|
||||
use common_event_recorder::{EventRecorderImpl, EventRecorderRef};
|
||||
use common_meta::cache::{LayeredCacheRegistryRef, TableFlownodeSetCacheRef, TableRouteCacheRef};
|
||||
use common_meta::cache_invalidator::{CacheInvalidatorRef, DummyCacheInvalidator};
|
||||
use common_meta::cache_invalidator::CacheInvalidatorRef;
|
||||
use common_meta::key::TableMetadataManager;
|
||||
use common_meta::key::flow::FlowMetadataManager;
|
||||
use common_meta::kv_backend::KvBackendRef;
|
||||
@@ -45,6 +45,7 @@ use operator::statement::{
|
||||
StatementExecutor, StatementExecutorConfiguratorRef, StatementExecutorRef,
|
||||
};
|
||||
use operator::table::TableMutationOperator;
|
||||
use operator::write_dispatcher::WriteDispatcher;
|
||||
use partition::cache::PartitionInfoCacheRef;
|
||||
use partition::manager::PartitionRuleManager;
|
||||
use pipeline::pipeline_operator::PipelineOperator;
|
||||
@@ -68,7 +69,7 @@ pub struct FrontendBuilder {
|
||||
options: FrontendOptions,
|
||||
kv_backend: KvBackendRef,
|
||||
layered_cache_registry: LayeredCacheRegistryRef,
|
||||
local_cache_invalidator: Option<CacheInvalidatorRef>,
|
||||
local_cache_invalidator: CacheInvalidatorRef,
|
||||
catalog_manager: CatalogManagerRef,
|
||||
node_manager: NodeManagerRef,
|
||||
plugins: Option<Plugins>,
|
||||
@@ -78,6 +79,9 @@ pub struct FrontendBuilder {
|
||||
}
|
||||
|
||||
impl FrontendBuilder {
|
||||
/// Creates a frontend builder from its configuration and shared services.
|
||||
///
|
||||
/// Uses the layered cache registry for local invalidation unless overridden.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
options: FrontendOptions,
|
||||
@@ -91,8 +95,8 @@ impl FrontendBuilder {
|
||||
Self {
|
||||
options,
|
||||
kv_backend,
|
||||
local_cache_invalidator: layered_cache_registry.clone(),
|
||||
layered_cache_registry,
|
||||
local_cache_invalidator: None,
|
||||
catalog_manager,
|
||||
node_manager,
|
||||
plugins: None,
|
||||
@@ -138,9 +142,10 @@ impl FrontendBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/// Overrides the default local cache invalidator.
|
||||
pub fn with_local_cache_invalidator(self, cache_invalidator: CacheInvalidatorRef) -> Self {
|
||||
Self {
|
||||
local_cache_invalidator: Some(cache_invalidator),
|
||||
local_cache_invalidator: cache_invalidator,
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -185,7 +190,11 @@ impl FrontendBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates configuration and assembles the frontend [`Instance`].
|
||||
pub async fn try_build(self) -> Result<Instance> {
|
||||
if let Err(reason) = self.options.write_retry.validate() {
|
||||
return error::InvalidWriteRetryConfigSnafu { reason }.fail();
|
||||
}
|
||||
let kv_backend = self.kv_backend;
|
||||
let node_manager = self.node_manager;
|
||||
let plugins = self.plugins.unwrap_or_default();
|
||||
@@ -208,9 +217,18 @@ impl FrontendBuilder {
|
||||
partition_info_cache.clone(),
|
||||
));
|
||||
|
||||
let local_cache_invalidator = self
|
||||
.local_cache_invalidator
|
||||
.unwrap_or_else(|| Arc::new(DummyCacheInvalidator));
|
||||
let local_cache_invalidator = self.local_cache_invalidator;
|
||||
|
||||
let mut write_dispatcher =
|
||||
WriteDispatcher::new(node_manager.clone(), partition_manager.clone());
|
||||
if self.options.write_retry.max_retries > 0 {
|
||||
write_dispatcher = write_dispatcher.with_retry(
|
||||
self.options.write_retry.clone(),
|
||||
self.catalog_manager.clone(),
|
||||
local_cache_invalidator.clone(),
|
||||
);
|
||||
}
|
||||
let write_dispatcher = Arc::new(write_dispatcher);
|
||||
|
||||
let region_query_handler =
|
||||
if let Some(factory) = plugins.get::<RegionQueryHandlerFactoryRef>() {
|
||||
@@ -234,6 +252,7 @@ impl FrontendBuilder {
|
||||
table_flownode_cache.clone(),
|
||||
self.options.auto_create_table,
|
||||
)
|
||||
.with_write_dispatcher(write_dispatcher.clone())
|
||||
};
|
||||
// The execution-only inserter owns no batchers, avoiding an Arc cycle.
|
||||
let bulk_inserter = Arc::new(create_inserter());
|
||||
@@ -262,11 +281,14 @@ impl FrontendBuilder {
|
||||
create_inserter()
|
||||
.with_pending_rows_batcher(build_batcher(self.options.table_batcher_options())),
|
||||
);
|
||||
let deleter = Arc::new(Deleter::new(
|
||||
self.catalog_manager.clone(),
|
||||
partition_manager.clone(),
|
||||
node_manager.clone(),
|
||||
));
|
||||
let deleter = Arc::new(
|
||||
Deleter::new(
|
||||
self.catalog_manager.clone(),
|
||||
partition_manager.clone(),
|
||||
node_manager.clone(),
|
||||
)
|
||||
.with_write_dispatcher(write_dispatcher),
|
||||
);
|
||||
let requester = Arc::new(Requester::new(
|
||||
self.catalog_manager.clone(),
|
||||
partition_manager.clone(),
|
||||
@@ -409,3 +431,76 @@ impl FrontendBuilder {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use api::v1::meta::Role;
|
||||
use common_meta::cache_invalidator::{Context, DummyCacheInvalidator};
|
||||
use common_meta::instruction::CacheIdent;
|
||||
use common_meta::key::table_route::{TableRouteKey, TableRouteValue};
|
||||
use common_meta::key::{MetadataKey, MetadataValue};
|
||||
use common_meta::rpc::router::{Region, RegionRoute};
|
||||
use common_meta::rpc::store::PutRequest;
|
||||
use meta_client::client::MetaClientBuilder;
|
||||
use store_api::storage::RegionId;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_cache_invalidator() {
|
||||
for custom_invalidator in [false, true] {
|
||||
let mut builder = FrontendBuilder::new_test(
|
||||
&FrontendOptions::default(),
|
||||
Arc::new(MetaClientBuilder::new(0, Role::Frontend).build()),
|
||||
);
|
||||
if custom_invalidator {
|
||||
builder = builder.with_local_cache_invalidator(Arc::new(DummyCacheInvalidator));
|
||||
}
|
||||
let backend = builder.kv_backend.clone();
|
||||
let routes: TableRouteCacheRef = builder.layered_cache_registry.get().unwrap();
|
||||
let partitions: PartitionInfoCacheRef = builder.layered_cache_registry.get().unwrap();
|
||||
let instance = builder.try_build().await.unwrap();
|
||||
let table_id = 1024;
|
||||
for region_number in [1, 2] {
|
||||
let route = TableRouteValue::physical(vec![RegionRoute {
|
||||
region: Region {
|
||||
id: RegionId::new(table_id, region_number),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}]);
|
||||
backend
|
||||
.put(
|
||||
PutRequest::new()
|
||||
.with_key(TableRouteKey::new(table_id).to_bytes())
|
||||
.with_value(route.try_as_raw_value().unwrap()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if region_number == 1 {
|
||||
routes.get(table_id).await.unwrap().unwrap();
|
||||
partitions.get(table_id).await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(routes.contains_key(&table_id));
|
||||
assert!(partitions.contains_key(&table_id));
|
||||
|
||||
instance
|
||||
.cache_invalidator()
|
||||
.invalidate(&Context::default(), &[CacheIdent::TableId(table_id)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(custom_invalidator, routes.contains_key(&table_id));
|
||||
assert_eq!(custom_invalidator, partitions.contains_key(&table_id));
|
||||
let route = routes.get(table_id).await.unwrap().unwrap();
|
||||
let region = route.as_physical_table_route_ref().unwrap().region_routes[0]
|
||||
.region
|
||||
.id;
|
||||
assert_eq!(
|
||||
if custom_invalidator { 1 } else { 2 },
|
||||
region.region_number()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Instance {
|
||||
LogicalTablePendingRowsBatcher::try_new(
|
||||
self.partition_manager().clone(),
|
||||
self.node_manager().clone(),
|
||||
self.inserter.write_dispatcher().clone(),
|
||||
self.catalog_manager().clone(),
|
||||
self.table_flownode_set_cache().clone(),
|
||||
true,
|
||||
|
||||
@@ -519,31 +519,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The PHP instrumentation case: an empty string must be distinguishable
|
||||
/// from any other unparsable value in the reported diagnostics.
|
||||
/// The PHP instrumentation case: `getHeaderLine('Content-Length')` sends ""
|
||||
/// when the header is absent. The span must be kept with a null attribute.
|
||||
#[test]
|
||||
fn test_prepare_trace_column_rewrites_reports_empty_string_value() {
|
||||
let rows = vec![Row {
|
||||
values: vec![Value {
|
||||
value_data: Some(ValueData::StringValue(String::new())),
|
||||
fn test_prepare_trace_column_rewrites_nulls_empty_string_value() {
|
||||
let mut rows = Rows {
|
||||
schema: vec![ColumnSchema {
|
||||
column_name: "span_attributes.http.response.body.size".to_string(),
|
||||
datatype: ColumnDataType::String as i32,
|
||||
..Default::default()
|
||||
}],
|
||||
}];
|
||||
rows: vec![Row {
|
||||
values: vec![Value {
|
||||
value_data: Some(ValueData::StringValue(String::new())),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let pending_rewrites = vec![PendingTraceColumnRewrite {
|
||||
col_idx: 0,
|
||||
target_type: ColumnDataType::Int64,
|
||||
column_name: "span_attributes.http.response.body.size".to_string(),
|
||||
}];
|
||||
|
||||
let err = prepare_trace_column_rewrites(&rows, pending_rewrites, "opentelemetry_traces")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.error.to_string().contains(
|
||||
"'span_attributes.http.response.body.size' in table 'opentelemetry_traces' \
|
||||
from String(\"\") to Int64"
|
||||
),
|
||||
"unexpected error message: {}",
|
||||
err.error
|
||||
);
|
||||
prepare_trace_column_rewrites(&rows.rows, pending_rewrites, "opentelemetry_traces")
|
||||
.unwrap()
|
||||
.apply(&mut rows);
|
||||
|
||||
assert_eq!(rows.schema[0].datatype, ColumnDataType::Int64 as i32);
|
||||
assert_eq!(rows.rows[0].values[0].value_data, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,7 @@ pub use influxdb::{InfluxdbMergeMode, InfluxdbOptions};
|
||||
pub use jaeger::JaegerOptions;
|
||||
pub use mysql::MysqlOptions;
|
||||
pub use opentsdb::OpentsdbOptions;
|
||||
pub use operator::write_dispatcher::WriteRetryOptions;
|
||||
pub use otlp::OtlpOptions;
|
||||
pub use pending_rows_batcher::{BatcherOptions, PendingRowsBatcherOptions};
|
||||
pub use postgres::PostgresOptions;
|
||||
|
||||
@@ -23,8 +23,6 @@ fst.workspace = true
|
||||
futures.workspace = true
|
||||
greptime-proto.workspace = true
|
||||
itertools.workspace = true
|
||||
jieba-rs = "0.10"
|
||||
lazy_static.workspace = true
|
||||
mockall.workspace = true
|
||||
pin-project.workspace = true
|
||||
prost.workspace = true
|
||||
|
||||
@@ -31,7 +31,7 @@ use index::bloom_filter::reader::BloomFilterReaderImpl;
|
||||
use index::external_provider::{ExternalTempFileProvider, Reader, Writer};
|
||||
use index::fulltext_index::Config;
|
||||
use index::fulltext_index::create::{BloomFilterFulltextIndexCreator, FulltextIndexCreator};
|
||||
use index::fulltext_index::tokenizer::{Analyzer, EnglishTokenizer};
|
||||
use index::fulltext_index::tokenizer::{Analyzer, ScriptTokenizer};
|
||||
use index::inverted_index::create::InvertedIndexCreator;
|
||||
use index::inverted_index::create::sort::external_sort::ExternalSorter;
|
||||
use index::inverted_index::create::sort_create::SortIndexCreator;
|
||||
@@ -365,7 +365,7 @@ fn bench_bloom_search(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("bloom_search");
|
||||
|
||||
let lines = service_logs(ROWS * 10);
|
||||
let analyzer = Analyzer::new(Box::new(EnglishTokenizer), false);
|
||||
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), false);
|
||||
let blob = rt.block_on(async {
|
||||
let mut creator = bloom_creator();
|
||||
for line in &lines {
|
||||
|
||||
@@ -21,7 +21,7 @@ use async_trait::async_trait;
|
||||
use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use futures::AsyncRead;
|
||||
use index::fulltext_index::create::{FulltextIndexCreator, TantivyFulltextIndexCreator};
|
||||
use index::fulltext_index::tokenizer::{ChineseTokenizer, EnglishTokenizer, Tokenizer};
|
||||
use index::fulltext_index::tokenizer::{ScriptTokenizer, Tokenizer};
|
||||
use index::fulltext_index::{Analyzer, Config};
|
||||
use puffin::puffin_manager::{PuffinWriter, PutOptions};
|
||||
|
||||
@@ -86,8 +86,8 @@ impl PuffinWriter for NoopPuffinWriter {
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_english_tokenizer(c: &mut Criterion) {
|
||||
let tokenizer = EnglishTokenizer;
|
||||
fn bench_tokenizer_english_text(c: &mut Criterion) {
|
||||
let tokenizer = ScriptTokenizer;
|
||||
|
||||
let texts = vec![
|
||||
("short", "Hello, world! This is a test."),
|
||||
@@ -125,7 +125,7 @@ fn bench_english_tokenizer(c: &mut Criterion) {
|
||||
),
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("english_tokenizer");
|
||||
let mut group = c.benchmark_group("tokenizer_english_text");
|
||||
|
||||
for (size, text) in texts {
|
||||
group.bench_with_input(BenchmarkId::new("tokenize", size), &text, |b, text| {
|
||||
@@ -136,7 +136,7 @@ fn bench_english_tokenizer(c: &mut Criterion) {
|
||||
group.finish();
|
||||
|
||||
// Benchmark with repeated tokenization to simulate real-world usage
|
||||
let mut repeat_group = c.benchmark_group("english_tokenizer_repeated");
|
||||
let mut repeat_group = c.benchmark_group("tokenizer_english_text_repeated");
|
||||
|
||||
let sample_text = "The quick brown fox jumps over the lazy dog. This sentence contains most letters of the alphabet.";
|
||||
|
||||
@@ -157,9 +157,9 @@ fn bench_english_tokenizer(c: &mut Criterion) {
|
||||
repeat_group.finish();
|
||||
}
|
||||
|
||||
fn bench_chinese_tokenizer(c: &mut Criterion) {
|
||||
let tokenizer = ChineseTokenizer;
|
||||
let mut group = c.benchmark_group("chinese_tokenizer");
|
||||
fn bench_tokenizer_chinese_text(c: &mut Criterion) {
|
||||
let tokenizer = ScriptTokenizer;
|
||||
let mut group = c.benchmark_group("tokenizer_chinese_text");
|
||||
|
||||
for (name, text) in CHINESE_TOKENIZER_TEXTS {
|
||||
group.throughput(Throughput::Bytes(text.len() as u64));
|
||||
@@ -170,7 +170,7 @@ fn bench_chinese_tokenizer(c: &mut Criterion) {
|
||||
|
||||
group.finish();
|
||||
|
||||
let mut repeat_group = c.benchmark_group("chinese_tokenizer_repeated");
|
||||
let mut repeat_group = c.benchmark_group("tokenizer_chinese_text_repeated");
|
||||
let sample_text = CHINESE_TOKENIZER_TEXTS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "mixed_log")
|
||||
@@ -253,8 +253,8 @@ fn bench_tantivy_chinese_fulltext_index(c: &mut Criterion) {
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_english_tokenizer,
|
||||
bench_chinese_tokenizer,
|
||||
bench_tokenizer_english_text,
|
||||
bench_tokenizer_chinese_text,
|
||||
bench_tantivy_chinese_fulltext_index
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -270,8 +270,10 @@ mod tests {
|
||||
/// Converts a slice of bytes to a vector of `u64`.
|
||||
pub fn u64_vec_from_bytes(bytes: &[u8]) -> Vec<u64> {
|
||||
bytes
|
||||
.chunks_exact(std::mem::size_of::<u64>())
|
||||
.map(|chunk| u64::from_le_bytes(chunk.try_into().unwrap()))
|
||||
.as_chunks::<{ std::mem::size_of::<u64>() }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| u64::from_le_bytes(*chunk))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::fulltext_index::error::{
|
||||
AbortedSnafu, BiErrorsSnafu, BloomFilterFinishSnafu, ExternalSnafu, PuffinAddBlobSnafu, Result,
|
||||
SerializeToJsonSnafu,
|
||||
};
|
||||
use crate::fulltext_index::tokenizer::{Analyzer, ChineseTokenizer, EnglishTokenizer};
|
||||
use crate::fulltext_index::tokenizer::{Analyzer, ScriptTokenizer};
|
||||
use crate::fulltext_index::{Config, KEY_FULLTEXT_CONFIG};
|
||||
|
||||
const PIPE_BUFFER_SIZE_FOR_SENDING_BLOB: usize = 8192;
|
||||
@@ -50,11 +50,9 @@ impl BloomFilterFulltextIndexCreator {
|
||||
global_memory_usage: Arc<AtomicUsize>,
|
||||
global_memory_usage_threshold: Option<usize>,
|
||||
) -> Self {
|
||||
let tokenizer = match config.analyzer {
|
||||
crate::fulltext_index::Analyzer::English => Box::new(EnglishTokenizer) as _,
|
||||
crate::fulltext_index::Analyzer::Chinese => Box::new(ChineseTokenizer) as _,
|
||||
};
|
||||
let analyzer = Analyzer::new(tokenizer, config.case_sensitive);
|
||||
// Both analyzers tokenize the same way: query probes must be derivable from the
|
||||
// term alone, which a context-dependent segmenter like jieba can't provide.
|
||||
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), config.case_sensitive);
|
||||
|
||||
let inner = BloomFilterCreator::new(
|
||||
rows_per_segment,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user