Compare commits

...

13 Commits

Author SHA1 Message Date
Dhruv Patel e9ba44870a
Merge d4b8fc0b80 into 624425a08a 2026-08-04 09:41:05 +08:00
Prabhat Sharma 624425a08a
feat(editor): rebuild query autocomplete, and fix the value path behind it (#13586)
Design at autocomplete/autocomplete.md

Rewrites the query editor's autocomplete across every surface that has
one, and
follows the field-value path far enough to fix the bugs it was hiding.
53
commits, in six phases; each phase was written test-first, reviewed
adversarially, and checked in a browser before the next one started.

## What was wrong

The completion providers were wired inconsistently. Six surfaces bound a
resolver that was never defined, so their value completion could not
resolve at
all — SLO, dashboards and anomaly detection among them. Traces never
bound
`:suggestions`. Alerts never called `updateStreamKeywords`. PromQL ran a
second
editor runtime purely for its grammar, and asked
`/prometheus/api/v1/series`
— the heaviest endpoint available — for data the stream schema already
has.

## Phases

**1 — icons, staleness, quoting.** Correct `CompletionItemKind` glyphs,
stream
keywords refreshed when the stream changes, `spath` quoting.

**2 — the function catalog.** Custom functions get one tab stop per
argument,
org VRL functions stop appearing twice, column types read from
`dataType` as
well as `type`.

**3 — signature help, hover, async completion.** Providers return real
`CompletionList`s with `incomplete: true`, so a list that arrives late
is
re-queried rather than pinned.

**4 — numeric ranking.** Numeric columns sort first inside a numeric
aggregate.

**5 — values for streams nobody has searched.** The value cache only
ever held
what a search had already returned, so a stream you had not queried
offered
nothing. Values are now fetched on demand, bounded (10s timeout, 60s
cooldown,
one in-flight request per key), and shared between the editor and the
builder.
PromQL joins the same path: labels from the stream schema, values from
the
shared cache. `/series` now has zero production callers.

**6 — one way to fetch a value.** Item 22 only, in this PR.

## The dashboard filter 400

Clicking the ✕ beside "Select Field" in a filter's dropdown sets the
column to
`{}`, and the watcher on `condition.column` then asks for that column's
values.
`loadFilterItem` built `fields: [row.field]` out of `undefined`,
JSON.stringify
turned that into `null`, and the server answered

```
400 {"code":400,"message":"SerdeJsonError# invalid type: null, expected a string"}
```

`_values_stream` answers 400, not an empty result, for each way a piece
of the
payload can go missing — and every one is reachable: an unresolved
stream alias
(`.find(…)?.stream`), a stream not yet chosen (`""`), a range not yet
set. The
range fails differently: `meta.dateTime` starts as `{start_time: ""}`
and
`""?.toISOString()` does not short-circuit, so it throws and both
callers
surface "Something went wrong!" for a lookup the user never asked to
fail.

Guarded at the two callers, where the payload is built and where the
throw
happens. Not at `fetchFieldValues` — a guard there is too late to stop
the
throw and would duplicate validation the callers need anyway. The
trigger is
left alone: re-picking a field must still load its values.

## Notable removals

CodeMirror is gone — it was still bundled for the PromQL grammar alone.
Bundle 26.3 MB → 25.43 MB; the PromQL chunks 442.7 KB → 20.6 KB. The
PromQL
term catalog went from 7 entries to 113, generated from Prometheus's own
tables
and vendored with its MIT notice.

## Verification

- 76 files, +10.5k/−1.4k, ~9,100 tests passing across the touched areas.
- `vue-tsc` clean, `npm run build` exit 0.
- Every phase checked in a running browser against a live instance,
including
the 400 above: the ✕ now produces no request and no toast, adding a
filter and
re-picking a field each still fetch once, and the value list still
populates.

## Known, deliberately out of scope

- A filter still fetches twice (once on add, once on open) — that is
item 23,
  the cache read, not this PR.
- `useMetricsExplorer.loadFilterItem` has the same unguarded
`toISOString()`
  pattern on a different surface.
- `services/metrics.ts` and `services/search.ts` still carry dead
  `get_promql_series` wrappers now that nothing calls `/series`.
2026-08-03 22:29:04 +00:00
Shrinath Rao d76c0a6bbb
ci(playwright): single-source ALL shard matrices via ci-matrix/ manifests (#13606)
## Why
The OSS and ENT Playwright shard matrices (`run_files` per `testfolder`)
were hand-maintained inline in each `playwright.yml` and had to be
synced on every spec add — error-prone and drift-prone (ENT was even
missing `dashboard-favorites`). This makes the shard list a **single
source of truth** for **every** Playwright workflow.

## What (OSS side)
All matrices are generated at run time from JSON manifests in
`tests/ui-testing/ci-matrix/`:

- **`ci-matrix/ci_matrix.json`** — PR-gate base (24 shards). The only
place shared shards live.
- **`ci-matrix/ci_matrix_regression.json`** — regression base (10
shards); `RegressionSet/<Feature>` mapping now carried in
`actual_folder`.
- **`.github/scripts/build-ci-matrix.js`** — merges base (+ optional ENT
overlay) → GitHub matrix; validates uniqueness / no dup specs / no spec
both active+disabled. Reused by ENT from its OSS checkout.
- **`ci-matrix/README.md`** — manifest index + how to add/move/disable a
spec.
- Workflows `playwright.yml` + `playwright_regression.yml`: new
`generate_matrix` job → `strategy.matrix: fromJSON(...)`; `case` blocks
→ `${{ matrix.actual_folder }}`.

Disabling a spec (JSON has no `//`): move it into that shard's
`disabled: [{file, reason}]` array — never emitted (won't run), record
survives. Every shard ships a `"disabled": []` placeholder.

## Safety (verified)
- **OSS `playwright.yml` and `playwright_regression.yml` matrices are
byte-identical to before** — zero behavior change.
- Companion ENT PR (same branch slug
`test/playwright-matrix-single-source`) derives its matrices from these
manifests + overlays.

## Validate
Add the `e2e` label; confirm `generate_matrix` prints the shard count
and the `e2e /` jobs fan out.
2026-08-03 17:36:29 +00:00
Shrinath Rao 49448334ed
test(alpha1): split shard logins across multiple Dex users (#13602)
## What
Lets alpha1 shards log in as **different Dex users** instead of all
hammering the cloud env as one user. Paired with
**openobserve/o2-enterprise#2333** (the workflow wiring).

## Why
- **Load distribution** — spreads API/session load across users if the
env rate-limits per user.
- **Blast radius** — one user's account/session issue stops ~1/3 of
shards, not all.
- Test **data isolation is unchanged** (still per-org); this is purely
about the login user.

## Changes (OSS)
- `global-setup-alpha1.js` — new `ALPHA1_USER_INDEX` (1|2|3) selects the
Dex user: email resolves from `ALPHA1_USER_EMAIL_<N>`, **falling back to
`ALPHA1_USER_EMAIL`** when the indexed secret isn't set (safe
incremental rollout). Password shared (`ALPHA1_USER_PASSWORD`).
- Auth **filenames stay canonical** (`user.json` / `cloud-config.json`)
— ~20 specs + shared utils read those exact paths. The per-user split
happens at the CI layer (each shard downloads only its user's artifact
into the canonical path), so no spec changes needed.
- New `auth-warm.spec.js` — a login-only spec the barrier uses to mint
auth artifacts for users 2..N **without** running the heavy org-wide
cleanup.

## Default behaviour
With no index set, everything resolves to user 1 — identical to today.

## Enterprise PR
openobserve/o2-enterprise#2333
2026-08-03 15:00:09 +00:00
Omkar Kesarkhane 021d2135b5
fix(folders): stop the move dialog offering the folder you are already in (#13599)
## The bug

Selecting checks in Synthetic Monitoring and pressing **Move** opened a
dialog reading:

```
Current Folder:  Foo
Select Folder:   Foo
```

The same folder name twice — which reads as "move this to where it
already is".

Nothing could actually go wrong: `primary-button-disabled` already
blocked a same-folder submit. The dialog simply gave no clue *why* Move
was greyed out, because the destination it had chosen for you was the
one destination it would not accept.

## Cause

`MoveAcrossFolders` seeded `selectedFolder` from `activeFolderId`, and
`SelectFolderDropDown` listed every folder including that one.

## Fix

- `SelectFolderDropDown` gains an optional `excludeFolderId` prop: it
filters that folder out of the options and refuses to seed the selection
with it.
- `MoveAcrossFolders` passes `:excludeFolderId="activeFolderId"`, opens
with no destination chosen, and additionally disables Move while the
destination is empty — *"not the active folder"* is not the same as
*"somewhere to move to"*.
- The folder-list watcher now keeps a still-offerable choice. Creating a
folder from the **+** button arrives here as a store list change, and
the re-seed would otherwise clear the folder just created and selected.

## Why the prop is opt-in

`SelectFolderDropDown` has six other consumers — ImportAlert,
AlertList's clone dialog, CreateReport, AddSlo, SloList, and the
synthetics *duplicate* dialog. Every one of those picks a folder to file
something **into**, where opening on the active folder is correct.

Unset, the filter matches every folder (`!== undefined`) and the seeding
is untouched, so those call sites are byte-for-byte unchanged.

## Scope

This dialog is shared, so the change reaches **alerts, pipelines,
reports, SLOs and synthetics**. Behaviour is strictly narrowed: the
option removed was already a guaranteed no-op behind the disabled-submit
guard.

`MoveDashboardToAnotherFolder` uses a separate `SelectFolderDropdown`
under `components/dashboards/` and has the same behaviour, but it is a
different component tree and is deliberately left alone.

## Tests

5 new cases on the dropdown (default offers everything, excluded id
dropped, blank start, normal seeding still works, choice survives a list
change) and 1 on the dialog.

Three existing tests asserted the old seeding and are updated. **One
deserves review:** `should compute primaryButtonDisabled=false when
selectedFolder value is null` asserted that a *null* destination
**enables** Move — executing that submits `dst_folder_id: null`. I read
it as wrong rather than intentional and inverted it. If that null path
is load-bearing somewhere I have not found, this is the thing to
challenge.

## Verification

Run on this branch, based on `main`:

- `src/components/common/sidebar/` — 291 passed (6 files)
- Consumers: SyntheticMonitoring, AlertList, ImportAlert,
MoveDashboardToAnotherFolder — 216 passed, 10 pre-existing skips
- `eslint` on all four changed files — clean
- `npm run lint:design:strict` — OK, no regressions
- `npm run type-check` — exit 0
2026-08-03 11:57:26 +00:00
Shrinath Rao 503eca6264
fix(e2e): deflake logs autocomplete value-suggestion capture (#13593)
## Problem

In [o2-enterprise run
30791574230](https://github.com/openobserve/o2-enterprise/actions/runs/30791574230)
the **Logs-Features** shard logged **2 failed, 6 flaky, 3 skipped, 63
passed** with **52 retries**. Seven of the eight troubled tests are in
`logs-autocomplete-suggestions.spec.js` (the eighth `code`-field test
shares the cause), and every one dies on the identical assertion:

```
Error: Expected to find a field with captured values
expect(received).not.toBeNull()
Received: null
```

— i.e. the shared `findFieldWithStringValues()` helper returned `null`.

## Root cause

A **click-time race**, not missing data (the `e2e_automate` stream holds
3848 rows ingested at shard start). The helper clicked a field's sidebar
expand button and then polled IndexedDB for 10s — but the 10s window
started at **click time** and raced the `/_values_stream` fetch. In the
failing run the runner was slow (a perf assertion showed queries ~2.6×
over baseline: 1055ms vs <403ms). The values response landed **after**
the IndexedDB poll had already given up, so the record read as `null`
and the assertion failed hard. Because `findFieldWithStringValues()` is
shared across ~8 tests, one slow window knocked out the whole cluster,
and each burned up to 4 attempts → 52 retries.

An intended fix (`clickFieldExpandAndWaitValues`, a deterministic
`/_values_stream` response wait) already existed in the file but was
**never wired in**.

## Fix

- **`expandFieldAndCaptureValues()`** — anchor the IndexedDB
confirmation **after** the actual `/_values_stream` response instead of
racing it from click time; re-click to re-expand if a prior collapse
meant no fetch fired. Replaces the dead `clickFieldExpandAndWaitValues`
helper and the bare click+poll in the `code`-field test.
- **`findFieldWithStringValues()`** — scan via the anchored capture,
with a bounded refresh-and-retry round so a transient empty `/_values`
response self-heals rather than failing the test.
- **Readiness gate** — add `waitForStreamData()` to every `beforeEach`
so freshly-ingested data is searchable before the UI drives value
capture (WAL → index lag). Best-effort so an already-warm shared stream
never blocks.

## Testing

- `node --check` passes; no linter is configured for the suite.
- The slow-runner condition can't be reproduced deterministically
locally; the change is a test-harness reliability fix (deterministic
response-anchoring + readiness gate) that removes the timing dependency
that caused the flake. Will confirm green via the ENT playwright
regression this PR dispatches.
2026-08-03 11:27:33 +00:00
Omkar Kesarkhane aedcc83a24
fix(synthetics): stop a browser replay without stranding a step in progress (#13592)
## Problem

Recording a browser test and replaying it, then pressing **Stop** in the
journey editor:

1. The step the replay was on stayed stuck in the in-progress (spinner)
state forever.
2. The extension did not stop promptly — it kept going for another step
or two.

## Root cause

One defect produced both symptoms, and it lives in the recorder
extension's player
(`CrxPlayer.stop()` in `openobserve/playwright-crx`): it did not abort
the in-flight
Playwright call. It only raised a flag that `_checkStopped()` reads at
the **start** of an
action — never while a `click` / `fill` / `expect` is pending, and those
carry a 60s timeout.

So after Stop: the current step ran to completion, then the loop
advanced and emitted
`stepStarted` for the *next* step before unwinding. That step could
never report a result.
`useSyntheticsRecorder` set `activeStepId` from it and `stepDotState`
rendered it as
`"active"` — a permanent spinner.

The player fix is a companion PR in `openobserve/playwright-crx`. **This
PR is the O2 half**,
which is also what makes the UI honest while the stop is in flight.

## Changes

- **`ReplayPhase` gains `stopping`** — the interval between the click
and the extension
confirming. Stop becomes a disabled, loading button in the same slot (no
layout shift) and
the banner reads "Stopping replay…", so the UI no longer claims the run
is over while it is
  still winding down.
- **`useSyntheticsRecorder.stopReplay()` owns the `running → stopping →
stopped` transition**,
the way `replay()` already owns `running → passed/failed`. It clears
`activeStepId`, since
the interrupted step never reports a result and nothing else would ever
clear it. Bounded by
the existing `COMMAND_TIMEOUT_MS`, so `stopping` cannot strand the
journey.
- **A replay generation counter** orphans the in-flight replay promise
on stop. Otherwise the
abandoned run answers later and knocks a newer `running` back to
`stopped`, or resurrects a
banner the user just dismissed. Latent before — making stop fast is what
would start firing it.
- **`stepDotState` renders `active` only while `running`**, so an
unresolved step falls through
to `pending` — an empty circle, which is the truth: it never completed.
- **Stream gating** — a late `stepReplayStarted` is ignored outside
`running`; a
`stepReplayResult` already in flight still counts toward "completed X of
N", because that step
  genuinely ran.
- `CreateBrowserTest.onStopReplay` collapses to pure delegation; its
route guard treats
  `stopping` as live.

Also on this branch: a small `cursor-help` → `cursor-pointer` change in
`MonitorTable.vue`
(separate commit, unrelated to the above).

## Deliberately unchanged

The incognito replay window is **not** closed on Stop, so window reuse
across
record → replay → re-run is untouched.
(`crxApp.close({closeWindows:true})` was not an option
anyway — it removes *every* incognito window, including the user's own.)

`stopReplayAndForget` (route guards, `beforeunload`), recording
start/stop, and the
passed/failed replay paths are untouched.

## Testing

Regression tests in `useSyntheticsRecorder.spec.ts`,
`BrowserJourney.spec.ts` and
`CreateBrowserTest.spec.ts` — **each verified to fail with its guard
removed**, including the
headline one: `replayPhase: "stopped"` with a non-null `activeStepId`
must render `pending`,
not `active`.

The end-to-end proof lives in the companion PR: a browser test drives
the real extension in
real Chrome, blocks a step for 40s, presses Stop, and asserts prompt
acknowledgement,
`stopped: true`, and that no later step was announced. It passes in ~5s
with the player fix;
without it the extension never acknowledges the stop within 15s at all.

## Verification

- `npm run lint` — 0 errors in changed files (4 repo-wide errors are
pre-existing in
  `eslint.config.js` and `TraceDAG.spec.ts`)
- `npm run type-check` — clean
- `npx vitest run src/components/synthetics src/views/synthetics
src/composables/useSyntheticsRecorder.spec.ts`
  — **817/817 passing across 44 spec files**

## Note for reviewers

This needs the companion `playwright-crx` player fix to actually stop
promptly; without it the
UI is honest but the extension still winds down slowly. The extension
`dist/` must be rebuilt
and reloaded in `chrome://extensions`.
2026-08-03 11:04:22 +00:00
Loakesh Indiran f16e8e53ff
perf(synthetics): config caching, run-budget validation, HA correctness, step-grain Steps tab (#13580)
Synthetics performance and correctness work. Four areas: caching the
config reads on the job path, a false-alarm bug in validation, four HA
correctness bugs found by auditing what happens with more than one alert
manager, and the Steps tab moving to a step-grain stream.

Pairs with o2-enterprise#2325 — **merge this first**, that one calls
functions introduced here.

---

## Caching
- [x] Probe-token auth cache — 10s TTL, negative results cached so a bad
token can't force a query per request
- [x] Locations whole-table cache — 30s, serves `get` / `find_by_pool` /
`list_visible` from one load
- [x] Check definition cache via `get_cached()` — was read twice per job
(resolve + ack)
- [x] Agent capabilities cache via `get_cached()` — was read on every
lease poll
- [x] Org ingest-token cache — `find_default_enabled()` replaces three
hand-rolled `list_by_org().find(enabled)` copies
- [x] `list_by_locations()` — one batched agents query, replacing one
per location

## Cross-node invalidation
- [x] `coordinator::synthetics` — event invalidation following the
alerts/pipelines pattern
- [x] Write paths call `invalidate_and_publish()`, the watcher calls
plain `invalidate_cache()` so events don't echo
- [x] Deletes emit a delete event, so coordinator keys don't accumulate
one per check ever created
- [x] Ingest-token invalidation reuses the existing
`/org_ingestion_tokens/` watch — no second event stream
- [x] SQLite write mutex released before emitting — `get_lock()` and the
coordinator's `put()` take the same lock, so emitting while holding it
deadlocked the process

## HA — correctness with more than one alert manager
Confirmed with @subhradeep that a region can run multiple alert
managers. The synthetics scheduler, dispatcher, reaper and staleness
watcher all run on **every** one of them.

- [x] **Duplicate runs** — `claim_due()` claims due checks with `SELECT
… FOR UPDATE SKIP LOCKED`, advancing the schedule inside the same
transaction
- [x] **Duplicate "location down" pages** — the one-shot flag moved from
an in-process `HashSet` to `synthetics_locations.down_notified_at`,
claimed with a CAS
- [x] **Duplicate run completion** — `increment_jobs_done` claims
completion via `completed_at IS NULL`; two acks of the same run could
both be told they completed it
- [x] **Inert dedup index** — `scheduled_ts` is now the schedule slot,
not each node's tick clock, so the existing `synthetics_jobs_dedup_uq` +
`ON CONFLICT DO NOTHING` actually fires
- [x] Migration `m20260803_000001` adds `down_notified_at`, defaulted so
no backfill is needed
- [x] `DB_SCHEMA_VERSION` 62 → 63 — without it the migration never runs
on an already-upgraded deployment

## The false-alarm fix
- [x] Validate against the run budget (840s), not the job lease (900s)
- [x] The three bounds are deployment-configurable:
`O2_SYNTHETICS_MAX_CHECK_BUDGET_SECS` / `_JOB_LEASE_SECS` /
`_MAX_NET_TIMEOUT_MS`
- [x] Rejected limits are logged, not fatal — nothing installed means
validation keeps the conservative defaults
- [x] Browser retries capped at 2; protocol types stay at 3; default
stays 0 for every type

## Steps tab — the step-grain stream (B10)
- [x] Three `GROUP BY`s over the step stream replace downloading 5000
execution rows and tallying their JSON in the browser
- [x] Falls back to the old tally when the stream is empty, so the tab
does not blank for checks whose agents have not been upgraded
- [x] Gated on the `step_id` column rather than stream existence — a
partially-created stream would otherwise cause four failed queries

## Overview KPIs
- [x] Count tiles summed from the cached histogram; `buildKpiSql`
deleted

## Rename
- [x] `synthetics_monitors` → `synthetics_checks`, `monitor_type` →
`check_type`
- [x] Three UI call sites that read the old field names, including a
delete guard that silently disabled itself

---

**Why validation moved off the lease.** Three bounds stack:

```
check worst case  <=  max_check_budget_secs  <  job_lease_secs
     840s                    840s                   900s
```

Validation used the lease, so the server accepted checks the execution
environment cannot finish. On the managed path the probe is a Lambda,
and a function that hits its timeout is killed mid-run — lease headroom
is irrelevant once the process is gone. The net probe's function timeout
had drifted to **60s** while validation accepted up to 900s, so a plain
`timeout_ms=30s, retries=2` (worst case 100s) was accepted and then
killed, reporting a failure the target never had.

**Why the limits are ENT-declared but OSS-consumed.** Synthetics is
enterprise-only, so the values live in `SyntheticsConfig`. The
validation they bound lives in `config`, which cannot depend on
`o2_enterprise`. `SyntheticsLimits` + `init_limits` is the seam, falling
back to built-in defaults in OSS builds and tests. A bad limit is logged
rather than fatal: refusing to start ingest, search and dashboards
because a probe ceiling is misconfigured would be the worse outage.

**The duplicate-run bug.** `fetch_due` was a plain `SELECT` and the
advance was an unconditional `UPDATE … WHERE id = ?`, so nothing stopped
two alert managers firing the same check. Each inserted its own
`synthetics_runs` row under a fresh KSUID, which made the resulting job
rows genuinely distinct — `lease_batch`'s `status = 0` guard cannot
collapse them, because they are not contending for one row. N alert
managers meant every check ran N times.

`SKIP LOCKED` rather than a compare-and-swap because it *distributes*: a
second scheduler skips the rows the first holds and picks up different
due checks, so N replicas split the backlog. A CAS gives the same
exactly-once guarantee but every node reads the same candidates, one
wins them all, and the losers each issue a doomed `UPDATE`. The lock and
the advance share a transaction because the locks only live until
`COMMIT`; the fan-out stays outside it so config rows are not locked
across N job inserts.

**Duplicate run completion needed no second alert manager.** The
increment was atomic, but the completion decision re-read the row in a
second statement and returned "complete" to whichever caller saw
`jobs_done >= job_count` — which both acks of a 2-job run do. Acks are
served on ingesters, of which every environment runs two, and the probes
of one run finish independently. Effect: `consecutive_failures` counted
one run twice on every check, plus a genuinely duplicated notification
on checks left at the default `cooldown_mins = 0`.

**The Steps tab was truncating silently.** It downloaded up to 5000
execution rows and tallied `last_attempt_steps` in the browser. On the
busiest check measured — 18,079 executions over 7 days — that cap
covered 4 of the 7 days and reported a **56.3% fail rate against a true
33.7%**. The win is correctness; the ~18MB payload reduction is
secondary.

## Verification
`cargo check --workspace`, clippy, fmt clean. Config 98, infra 33
synthetics tests pass. Frontend: **839 synthetics tests pass**. Three
frontend spec files fail to import on `useLocalTimezone` — verified
identical on clean `main`, unrelated to this branch.
2026-08-03 10:14:11 +00:00
ktx-kirtan 4fa71dd15c
feat(ui): calm-signal pass over IAM and Synthetics Monitors (#13568)
Second batch of the "Calm Signal" pass (after #13562: Streams,
Pipelines, Nodes).

## Users
- Summary strip that doubles as the role facet. Tiles are the roles
**actually present in the data**, so custom roles appear without being
enumerated in code. Counts are role *membership* — a user with Admin + a
custom role is counted under both, so tiles intentionally don't sum to
the total. Top 5 by size; the tail folds into one "+N more roles" tile
counting unique users.
- Pending users now show the invited chip on the enterprise path (it
only appeared on the open-source one).
- Custom roles get one stable neutral chip instead of an untyped tag
whose colour was derived from the role's spelling.
- Removed a `<style scoped>` block whose only selector is used nowhere.

## Roles
- Member count per role, muted when nobody holds it. Comes from the
batched user→roles map — **one request for the org, not one per role** —
and is gated to enterprise, where that endpoint exists. Rows render
immediately; counts patch in when they land.

## Service Accounts
- Created reads as relative age, with a dot on keys minted this week.

## Synthetics Monitors
- Status counts were hidden inside a dropdown's option labels ("Passed
(12)"). They're now a strip that acts as the facet; the dropdown and its
dead options plumbing are gone.
- Health row rail, plus the red / muted row wash for failing and paused
monitors that Alerts and Pipelines already use.
- Last check renders through `OTimeCell` instead of a local time-ago
copy.
- **Fixes styling that never applied:** the uptime sparkline and its
hover tooltip referenced `.spark` / `.stt-*` classes that are defined
nowhere in the repo, so the bars had no size and the tooltip no surface.
Rebuilt with tokens.
- `timezone` is a prop now — a leaf table shouldn't reach into the
store.

## No strip on Dashboards, Roles, Service Accounts or Groups
Their candidate tiles were structurally constant (System = 1),
near-always zero, derivable from a neighbour, or already in the table
footer. Dashboards was already compliant (favourites, owner cell,
relative dates) and is untouched; Groups' API returns names only, so
per-row counts would cost one request per group.

`calm-signal.md` records both rules this batch produced: the
three-question test for a tile, and rail-vs-wash (a full-row wash only
when the state means *act now*, is rare, and the row is the unit of
action).

## Testing
`lint`, `lint:design:strict`, `lint:styles`, `lint:tokens`,
`lint:token-purity`, `type-check` pass. 885 unit tests across IAM and
Synthetics pass.

Synthetics needs a monitor in each state to eyeball the rail/wash;
Roles' member column needs an enterprise build (403s and hides itself
otherwise).

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
2026-08-03 08:54:15 +00:00
Huaijin e1e3091c06
refactor: move streaming aggregates to OSS (#13589)
## Summary

- move streaming aggregation cache, aggregate execution, and optimizer
code into the OSS search crate
- move cache aggregation SQL analysis into the SQL visitor and enable
streaming aggregation in search_service without enterprise gating
- move MetadataCountExec into OSS and use it for metadata count
optimization in all builds
- remove migration-only dead code while keeping the cache module under
search

Companion cleanup: openobserve/o2-enterprise#2327.

## Testing

- cargo fmt --all -- --check
- cargo test -p search metadata_count --lib
- cargo test -p search aggregate_optimize_rewrite --lib
- cargo test -p search streaming_aggregate --lib
- cargo test -p search cache::streaming_agg --lib
- cargo test -p search datafusion::optimizer::stream_aggregate --lib
- cargo test -p search_service partition --lib
- cargo check -p search_service
2026-08-03 08:44:55 +00:00
ktx-vaidehi f8253d4b24
fix: dashboard table chart width (#13587) 2026-08-03 07:55:33 +00:00
ktx-akshay 7f87242ac9
fix: dashboard reports drawer duplicates entries and hides reports in custom folders (#13569)
## Summary

This PR fixes two bugs in the Dashboard Reports drawer
(`ScheduledDashboards.vue` / `ViewDashboard.vue`).

**Issue 1 — Reports duplicate on every reopen**
Opening the Reports drawer, creating a report, then reopening the drawer
for the same dashboard caused the same report to appear as a duplicate
row. Reopening again added yet another duplicate, so the count grew by
one on every open. This happened because the drawer component never
unmounts between opens (it's toggled via `v-model:open`, not `v-if`),
and the report list was being appended to on each reopen instead of
being rebuilt.

**Issue 2 — Reports in a custom folder don't show up**
Reports created from a dashboard using the Default folder appeared
correctly in the drawer, but reports created using a custom report
folder did not — even though creation succeeded. The drawer's list
request was filtering by the dashboard's own folder id, but that filter
is meant to match the *report's* folder, not the dashboard's. Any report
saved to a folder other than the dashboard's own folder was silently
excluded.

| # | Issue | Root Cause | Fix |
|---|-------|------------|-----|
| 1 | Reports duplicate in the drawer each time it's reopened |
`formatReports()` in `ScheduledDashboards.vue` pushed new rows onto the
local `scheduledReports` list without clearing it first; since the
drawer stays mounted across opens, old rows persisted and new ones piled
on top | Rebuild the list fresh from `props.reports` on every call
instead of accumulating |
| 2 | Reports saved to a custom folder don't appear in the dashboard's
Reports drawer | `openScheduledReports()` in `ViewDashboard.vue` passed
the dashboard's folder id as the `folder_id` filter on the reports list
API, which filters by the *report's own* folder, not the dashboard's |
Removed the folder filter so the drawer lists all reports for the
dashboard regardless of which report folder they're saved in |

Issue : https://github.com/openobserve/o2-enterprise/issues/2319

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
2026-08-03 07:28:17 +00:00
Shrinath Rao de95f7f2d8
fix: restore Table default log-detail view (#13368) + heal alert-name inline-edit read (#13585)
## What & why

Two **Playwright Regression** nightly failures, reproduced identically
on both OSS ([run
30785096054](https://github.com/openobserve/openobserve/actions/runs/30785096054))
and ENT ([run
30785071382](https://github.com/openobserve/o2-enterprise/actions/runs/30785071382))
— same two shared-`tests/` specs, all 3 retries failed each. Both are
OSS-owned specs (ENT overlays only its own `tests/*` and builds OSS
`web/`), so this single OSS PR fixes both nightlies.

### 1. Logs — real product regression (`logs-regression.spec.js:1158`)
`Log detail sidebar opens with Table tab by default (#13368)` → `Error:
Table tab should be selected by default`.

**Root cause:**
[#13368](https://github.com/openobserve/openobserve/pull/13368) (Jul 23)
deliberately made the log-detail sidebar open on the **Table** tab.
[#13451](https://github.com/openobserve/openobserve/pull/13451) (Jul 30,
*"table migration to same components"*) silently flipped
`detailTableInitialTab` back to `"json"` in `SearchResult.vue` (both the
`ref` init and the per-open reset). The unchanged test correctly caught
the reverted behavior.

**Fix:** restore `"table"` as the default log-detail tab. Product fix —
no test change.

### 2. Alerts — stale test vs redesigned component
(`alerts-stream-switching.spec.js:443`)
`Bug #11577: Alert preview chart y-axis displays numeric values` →
`TimeoutError: locator.inputValue: Timeout 45000ms exceeded`.

**Root cause:** the alert name became an **`OInlineEdit`** title
(`OFormInlineEdit`, from the
[#13547](https://github.com/openobserve/openobserve/pull/13547) Alerts
revamp). In display mode the value lives in the `…-value` span; the
`…-input` only exists **while editing**. The test read `.inputValue()`
on the non-existent input → 45s timeout.

**Fix:** read the committed display value via a new `getAlertName()`
page-object helper (with an edit-mode `inputValue()` fallback).

## Verification
- **Alerts #11577** —  passed locally against a local O2 build (`1
passed`); `getAlertName()` reads the name and the full
save/verify/delete flow works.
- **Logs #13368** — restores the exact `detailTableInitialTab = "table"`
value #13368 set and the unchanged test already validated as green.
Confirmed by this run's Logs-Regression job + the dispatched ENT e2e
gate.

## Scope
- `web/src/plugins/logs/SearchResult.vue` — 2 lines (product)
-
`tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-stream-switching.spec.js`
— read display value
- `tests/ui-testing/pages/alertsPages/alertsPage.js` — `alertNameValue`
locator + `getAlertName()` helper
2026-08-03 07:19:28 +00:00
193 changed files with 23121 additions and 3160 deletions

View File

@ -58,8 +58,30 @@ All token-backed and dark-mode-safe. Reuse these before inventing anything.
Status chip beside them. Pass `size` at the call site, matching its siblings.
- **Row state signal** — an extreme-left colour **rail** via `OTable`'s
per-row `getRowStyle` (inset box-shadow, rem width, token colour) + a **light
exception highlight** via `row-class` (tint only the rows that need action —
never the normal ones).
exception highlight** via `row-class`. The rail and the wash are two different
strengths of the same signal, and the rail is the default:
| | Rail (`getRowStyle`) | Wash (`row-class`) |
| --- | --- | --- |
| Cost | a few px at the row edge | ~the whole row |
| Use for | **every** state, always | only the two cases below |
A full-row wash is the loudest thing on a list, so it earns its place only when
**all three** hold: the state means **act now** (not merely "not green"), it is
**rare in a healthy system**, and **the row is the unit of action**. In practice
that leaves exactly two washes:
- `!bg-status-error-bg` — failing/errored/offline. *Alerts* failed, *Pipelines*
errored, *Nodes* offline, *Synthetics* failed.
- `!bg-surface-panel` — paused/disabled. This one is **de-emphasis, not alarm**:
the row is deliberately inert, so it recedes rather than shouts.
Everything else keeps a clean row and reads from the rail — including states
that are "bad but not urgent": **degraded/warning** (worth noticing, not worth
acting on this second), **stale**, **unknown**, **never-ran**. And whatever the
state means, if it is **common** the wash is wrong regardless: *Streams* drops it
entirely, because "never ingested" and "quiet for a day" describe a large share
of rows in a normal org and a table where most rows are tinted signals nothing.
Pages with no failure state at all — Users, Roles, Dashboards — never wash.
- **Recency**`OTimeCell` `mode="relative"` (`"3 min ago"`) with a hot/warm/
cold dot, instead of a raw timestamp column.
- **People**`OUserCell` for owner/author columns.
@ -156,6 +178,24 @@ people to ignore the colour. When in doubt, grey.
Colour only earns attention if most of the screen stays quiet:
- **Earn every tile — a strip is not a page decoration.** Before adding one, put
each tile to three questions: does the number **vary**, does someone **act** on
it, and is it **not already on screen**? Tiles that fail are noise dressed as
signal:
- *structurally constant* — "System accounts" is 1 in almost every org, so the
tile is a label with a number stuck to it;
- *almost always zero* — "New this week" on a list that gains an item a quarter;
- *derivable from its neighbours* — "In use" beside "Unused" and "Total";
- *already in the footer*`footerTitle` renders "N Dashboards" under every
table, so a Total tile alone is not a reason to have a strip.
A page whose only candidates fail these gets **no strip** — keep the per-row
signals (relative recency, a state rail, a count column) and stop. Dashboards,
Service Accounts and Roles all ended up here: pages where a strip added pixels
and no information. Roles is the clearest case — "Unused" is just the member
column sorted ascending, so two tiles restated what the rows already said. **A
count column plus sorting usually beats a strip**; reach for a strip only when
the page has a real distribution to summarise (Users across roles, monitors
across health) *and* the tiles double as the facet.
- **Highlight exceptions, not the norm.** Tint the failed/paused rows; leave the
healthy majority clean. A table where every row is coloured signals nothing.
And if a page has no true failure state (a catalog list), the calm answer is a

114
.github/scripts/build-ci-matrix.js vendored Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* build-ci-matrix.js single source of truth for the Playwright UI shard matrix.
*
* Prints a GitHub Actions matrix object: {"include":[ {testfolder,actual_folder,browser,run_files}, ... ]}
* consumed by `strategy.matrix: ${{ fromJSON(needs.generate_matrix.outputs.matrix) }}`.
*
* Usage:
* node build-ci-matrix.js <base.json> # OSS: base manifest verbatim
* node build-ci-matrix.js <base.json> <overlay.json> # ENT: base + enterprise overlay
*
* The base (OSS tests/ui-testing/ci-matrix/ci_matrix.json) is the ONLY place shared shards are
* listed. ENT never re-lists shared specs its overlay (ci_matrix.ent.json) carries
* only the delta, so a spec added to OSS flows into ENT automatically:
* { "append": { "<testfolder>": ["extra.spec.js", ...] }, // add ENT-only specs to a shared shard
* "shards": [ {testfolder,actual_folder,browser,run_files} ], // whole ENT-only shards (Workflows, SDR…)
* "disabled": { "<testfolder>": [{file,reason}, ...] } } // ENT-only specs turned off (doc only)
*
* DISABLING A SPEC (JSON has no // comments): don't delete it — move it into a
* shard's "disabled": [{ "file": "x.spec.js", "reason": "why" }] array. Disabled
* entries are documentation only: this script never emits them, so they don't run,
* but the record + reason survive. "_comment" (or any _-prefixed key) is also ignored.
*
* This lives in OSS so ENT can reuse it from its tree-merged OSS checkout.
*/
const fs = require("fs");
function die(msg) {
process.stderr.write(`build-ci-matrix: ${msg}\n`);
process.exit(1);
}
function readJson(path) {
try {
return JSON.parse(fs.readFileSync(path, "utf8"));
} catch (e) {
die(`cannot read/parse ${path}: ${e.message}`);
}
}
const [basePath, overlayPath] = process.argv.slice(2);
if (!basePath) die("usage: build-ci-matrix.js <base.json> [overlay.json]");
const base = readJson(basePath);
if (!Array.isArray(base)) die(`base ${basePath} must be a JSON array of shards`);
// Deep-copy base so we never mutate the parsed source objects.
const include = base.map((s) => ({ ...s, run_files: [...(s.run_files || [])] }));
const byFolder = new Map(include.map((s) => [s.testfolder, s]));
if (overlayPath) {
const overlay = readJson(overlayPath);
// append: add enterprise-only specs to an EXISTING shared shard.
for (const [folder, specs] of Object.entries(overlay.append || {})) {
const shard = byFolder.get(folder);
if (!shard) {
die(`overlay append targets unknown folder "${folder}" — it must exist in the base manifest`);
}
for (const spec of specs) {
if (shard.run_files.includes(spec)) {
die(`overlay append: "${spec}" already in base shard "${folder}" — remove it from the overlay`);
}
shard.run_files.push(spec);
}
}
// shards: whole enterprise-only shards (Workflows, SDR-Logs, SDR-Traces, …).
for (const shard of overlay.shards || []) {
if (byFolder.has(shard.testfolder)) {
die(`overlay shard "${shard.testfolder}" collides with a base shard — use "append" instead`);
}
const copy = { browser: "chrome", ...shard, run_files: [...(shard.run_files || [])] };
include.push(copy);
byFolder.set(copy.testfolder, copy);
}
// disabled: enterprise-only turned-off specs, recorded against a shard for docs only.
for (const [folder, entries] of Object.entries(overlay.disabled || {})) {
const shard = byFolder.get(folder);
if (!shard) die(`overlay disabled targets unknown folder "${folder}"`);
shard.disabled = [...(shard.disabled || []), ...entries];
}
}
// Sanity: unique testfolders, no empty shards, no dup specs, and no spec both
// active (run_files) and disabled in the same shard.
const seen = new Set();
for (const s of include) {
if (!s.testfolder) die(`shard missing testfolder: ${JSON.stringify(s)}`);
if (seen.has(s.testfolder)) die(`duplicate testfolder "${s.testfolder}"`);
seen.add(s.testfolder);
if (!s.actual_folder) s.actual_folder = s.testfolder;
if (!s.run_files || s.run_files.length === 0) die(`shard "${s.testfolder}" has no run_files`);
if (new Set(s.run_files).size !== s.run_files.length) {
die(`shard "${s.testfolder}" has duplicate run_files`);
}
for (const d of s.disabled || []) {
if (s.run_files.includes(d.file)) {
die(`shard "${s.testfolder}": "${d.file}" is in both run_files and disabled`);
}
}
}
// Emit ONLY the fields the CI matrix consumes — disabled/_comment/notes are stripped,
// so turned-off specs never reach `npx playwright test`.
const matrix = include.map((s) => ({
testfolder: s.testfolder,
actual_folder: s.actual_folder,
browser: s.browser || "chrome",
run_files: s.run_files,
}));
process.stdout.write(JSON.stringify({ include: matrix }));

View File

@ -176,10 +176,36 @@ jobs:
retention-days: 1
if-no-files-found: error
# Build the shard matrix from tests/ui-testing/ci-matrix/ci_matrix.json (single source of truth,
# shared with ENT via build-ci-matrix.js). Runs under the same gate as build_binary so
# ui_integration_tests always has a matrix whenever it is allowed to run.
generate_matrix:
timeout-minutes: 5
name: generate_matrix
needs: [check_changes]
if: >-
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'e2e')) ||
(github.event_name != 'pull_request' &&
needs.check_changes.outputs.has_changes == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
steps:
- uses: actions/checkout@v5
- id: gen
run: |
MATRIX=$(node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix.json)
if [ -z "$MATRIX" ]; then echo "::error::matrix generation failed (build-ci-matrix produced no output)"; exit 1; fi
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "$MATRIX" | node -e "const m=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('generated',m.include.length,'shards:', m.include.map(s=>s.testfolder).join(', '))"
ui_integration_tests:
timeout-minutes: 45
name: e2e / ${{ matrix.testfolder }}
needs: [build_binary]
needs: [build_binary, generate_matrix]
if: ${{ !cancelled() && needs.build_binary.result == 'success' }}
runs-on:
labels: eks-openobserve-standard-8
@ -191,328 +217,10 @@ jobs:
# options: --user root
strategy:
fail-fast: false
matrix:
include:
- testfolder: "GeneralTests"
browser: "chrome"
run_files:
[
"sanity.spec.js",
"changeOrg.spec.js",
"enrichment.spec.js",
"schema.spec.js",
"schemaload.spec.js",
"serviceAccount.spec.js",
"usersOrg.spec.js",
"org.spec.js",
"theme-management.spec.js",
"ingestion-config.spec.js",
"ingestionTokens.spec.js",
"landingPage.spec.js",
"languageTranslation.spec.js",
"model-pricing.spec.js",
"edition-features.spec.js",
"logsqueries.sql-comments.spec.js",
"iam-form-validation.spec.js",
"regex-patterns-form-validation.spec.js",
"cipher-keys-form-validation.spec.js",
"settings-form-validation.spec.js",
"ai-toolsets-form-validation.spec.js",
"shared-components-form-validation.spec.js",
"onboarding-form-validation.spec.js",
"rum-form-validation.spec.js",
"login-form-validation.spec.js",
]
- testfolder: "Logs-Builder-Basic"
browser: "chrome"
run_files:
[
"logsQueryBuilder-chart.spec.js",
"logsQueryBuilder-filters-basic.spec.js",
"pagination.spec.js",
"unflattened.spec.js",
]
- testfolder: "Logs-Builder-Advanced"
browser: "chrome"
run_files:
[
"logsQueryBuilder-editor.spec.js",
"logsQueryBuilder-filters-advanced.spec.js",
"logsquickmode.spec.js",
"logshistogram.spec.js",
"logsQuickPick.spec.js",
]
- testfolder: "Logs-Core"
browser: "chrome"
run_files:
[
"logspage.spec.js",
"logstable.spec.js",
"logsqueries.spec.js",
"join.spec.js",
"shareLink.spec.js",
"logsAnalyzeDimensions.spec.js",
"monaco-query-prefill.spec.js",
"logsqueries.matchall.spec.js",
"ftsDefaultColumn.spec.js",
"logsVisualizePersistence.spec.js",
]
- testfolder: "Logs-Features"
browser: "chrome"
run_files: [
"logs-autocomplete-suggestions.spec.js",
# "searchJobInspector.spec.js", # TODO: update these tests for new correlation feature
"logs-sql-autocomplete.spec.js",
"logsqueries.cte.spec.js",
"logsDownloads.spec.js",
"secondsPrecisionAdded.spec.js",
"searchpartition.spec.js",
"indexquery.spec.js",
"region.spec.js",
]
- testfolder: "Alerts"
browser: "chrome"
run_files: [
# "alerts-e2e-flow.spec.js",
"alerts-ui-operations.spec.js",
"alerts-import.spec.js",
# "alerts-advanced.spec.js",
# "alerts-scheduled-features.spec.js",
"alerts-metrics-notification.spec.js",
"alerts-destinations-prebuilt.spec.js",
"alerts-vrl-encoding.spec.js",
"alerts-history.spec.js",
"alerts-template-prebuilt-guard.spec.js",
"alerts-form-validation.spec.js",
"anomaly-detection-form-validation.spec.js",
# Alerts 4.0 (multi-alerts) e2e — PR #13547
"alerts-priority-tags.spec.js",
"alerts-multialert-api.spec.js",
"alerts-multialert-ui.spec.js",
"alerts-multialert-regression.spec.js",
"alerts-multialert-firing.spec.js",
"alerts-multialert-transitions.spec.js",
]
- testfolder: "Dashboards-Core"
browser: "chrome"
run_files:
[
"dashboard.spec.js",
"dashboard2.spec.js",
"dashboardtype.spec.js",
"dashboard-folder.spec.js",
"dashboard-import.spec.js",
"maxquery.spec.js",
"dashboard-chartJson.spec.js",
"dashboard-promql-suggestions.spec.js",
"dashboard-sql-autocomplete.spec.js",
"dashboard-share-link.spec.js",
"crossLinking.spec.js",
"dashboard-multi-sql.spec.js",
"crossLinkMultiStream.spec.js",
"dashboards-form-validation.spec.js",
]
- testfolder: "Dashboards-Isolated"
browser: "chrome"
run_files: ["dashboard-favorites.spec.js"]
- testfolder: "Dashboards-Settings"
browser: "chrome"
run_files:
[
"dashboard-filter.spec.js",
"dashboard-general-setting.spec.js",
"dashboard-tabs-setting.spec.js",
"dashboard-transpose.spec.js",
"dashboard-join.spec.js",
"dashboard-nested-functions.spec.js",
"dashboard-legends-copy.spec.js",
"dashboard-raw-query.spec.js",
]
# Dashboard panel tests are grouped by the feature they exercise so
# each shard stays well under the ephemeral-runner endurance window.
# A single combined 16-spec shard ran ~40 min on CI and got reclaimed
# mid-run ("runner lost communication"). Add new specs to the shard of
# the feature they cover; the Tables shard also owns the pagination and
# pivot-table specs (previously their own single-spec shards).
- testfolder: "Dashboards-Charts"
browser: "chrome"
run_files:
[
"custom-charts.spec.js",
"dashboard-geoMap.spec.js",
"dashboard-maps.spec.js",
"dashboard-multi-y-axis.spec.js",
"dashboard-html-chart.spec.js",
"dashboard-sankey.spec.js",
"dashboard-pie-donut.spec.js",
"dashboard-metric-camelcase.spec.js",
"dashboard-create-alert.spec.js",
]
- testfolder: "Dashboards-Visualize"
browser: "chrome"
run_files:
[
"visualize.spec.js",
"visualize-vrl.spec.js",
]
- testfolder: "Dashboards-Tables"
browser: "chrome"
run_files:
[
"dashboard-table-chart.spec.js",
"dashboard-table-copy-cell.spec.js",
"dashboard-table-csv-download.spec.js",
"dashboard-table-column-formatting.spec.js",
"dashboard-table-column-filtering.spec.js",
"dashboard-table-pagination.spec.js",
"dashboard-pivot-table.spec.js",
]
- testfolder: "Dashboards-Streaming"
browser: "chrome"
run_files: ["dashboard-streaming.spec.js"]
- testfolder: "Pipelines"
browser: "chrome"
run_files: [
"pipeline-conditions.spec.js",
"pipeline-conditions-validation.spec.js",
"pipelines.spec.js",
"pipeline-dynamic.spec.js",
"pipeline-core.spec.js",
"pipeline-traces.spec.js",
"pipeline-metrics.spec.js",
"pipeline-backfill.spec.js",
"pipeline-history.spec.js",
"scheduled-pipeline-query-builder.spec.js",
# "remotepipeline.spec.js"
"pipelines-form-validation.spec.js",
]
- testfolder: "Functions"
browser: "chrome"
run_files:
[
"js-transform-type.spec.js",
"row-expansion.spec.js",
"enrichment-table-url.spec.js",
"functions-form-validation.spec.js",
"action-scripts-form-validation.spec.js",
]
- testfolder: "Reports"
browser: "chrome"
run_files:
[
"reportsScheduleNow.spec.js",
"reportsScheduleLater.spec.js",
"reportFolders.spec.js",
"reports-bulk-operations.spec.js",
"reports-form-validation.spec.js",
"reportCsvMediaType.spec.js",
]
- testfolder: "Streams"
browser: "chrome"
run_files:
[
"multiselect-stream.spec.js",
"streamname.spec.js",
"streaming.spec.js",
"stream-settings.spec.js",
"streams-form-validation.spec.js",
]
- testfolder: "Traces"
browser: "chrome"
run_files:
[
"service-catalog.spec.js",
"tracesSearch.spec.js",
"traceQueryEditor.spec.js",
"traceErrorFilter.spec.js",
"traceDetails.spec.js",
"traceAdvancedFiltering.spec.js",
"tracesAnalyzeDimensions.spec.js",
"traces-autocomplete-suggestions.spec.js",
]
# The RUM ingest/token/sourcemaps routes are part of the standard
# (OSS) router (src/api/http/src/handler/http/router/mod.rs), so these specs run
# against the OSS binary built by this workflow. Ingestion-auth
# negative cases live in the API suite (tests/api-testing/tests/rum).
- testfolder: "RUM"
browser: "chrome"
run_files:
[
"rum-cdn-dataflow.spec.js",
"rum-npm-dataflow.spec.js",
"rum-page-dataflow.spec.js",
"rum-onboarding-snippets.spec.js",
"sourcemap-ui.spec.js",
"sourcemap-upload-pretty.spec.js",
]
# rum-token.spec.js RESETS the org RUM token; running it beside the
# dataflow specs (workers run files in parallel) would 401 their
# in-flight SDK beacons. Own shard = own isolated instance.
- testfolder: "RUM-Token"
browser: "chrome"
run_files: ["rum-token.spec.js"]
- testfolder: "Metrics"
browser: "chrome"
run_files:
[
"metrics.spec.js",
"metrics-queries.spec.js",
"metrics-aggregations.spec.js",
"metrics-advanced.spec.js",
"metrics-config-tabs.spec.js",
"metrics-visualizations.spec.js",
"metrics-config.spec.js",
"metrics-promql-query-persistence.spec.js",
"metrics-table-column-order.spec.js",
"metrics-promql-builder.spec.js",
"promqlAutocomplete.spec.js",
"metrics-share-deep-link.spec.js",
]
- testfolder: "Dashboards-Variables"
browser: "chrome"
run_files:
[
"dashboard-variables-setting.spec.js",
"dashboard-variables-global.spec.js",
"dashboard-variables-tab-level.spec.js",
"dashboard-variables-panel-level.spec.js",
"dashboard-variables-dependency.spec.js",
"dashboard-variables-refresh.spec.js",
"dashboard-variables-url-sync.spec.js",
"dashboard-variables-creation-scopes.spec.js",
"dashboard-variables-default-values-chain.spec.js",
"dashboard-variables-custom-parents.spec.js",
"dashboard-variables-stream-field.spec.js",
"dashboard-mustache-variables.spec.js",
]
- testfolder: "Dashboards-Panel-Level-DateTime-Config"
browser: "chrome"
run_files:
[
"dashboard-panel-time-config-behavior.spec.js",
"dashboard-panel-time-url-priority.spec.js",
"dashboard-panel-time-advanced-edge-cases.spec.js",
"dashboard-panel-time-apply-behavior.spec.js",
"dashboard-panel-time-variables-behavior.spec.js",
]
- testfolder: "Dashboard-Config-Settings"
browser: "chrome"
run_files:
[
"dashboard-series-color-multiwindow.spec.js",
"dashboard-config-advanced.spec.js",
"dashboard-config-axis.spec.js",
"dashboard-config-gauge-maps.spec.js",
"dashboard-config-general.spec.js",
"dashboard-config-legends.spec.js",
"dashboard-config-line-style.spec.js",
"dashboard-config-panel-time.spec.js",
"dashboard-config-table.spec.js",
"dashboard-config-trellis.spec.js",
"dashboard-config-drilldown.spec.js",
"dashboard-config-promql.spec.js",
"dashboard-config-markline.spec.js",
]
# Matrix is generated from tests/ui-testing/ci-matrix/ci_matrix.json by the
# generate_matrix job (the single source of truth shared with ENT). To add
# or move a spec, edit that JSON file — do NOT hand-edit a matrix here.
matrix: ${{ fromJSON(needs.generate_matrix.outputs.matrix) }}
steps:
- name: Kill background apt processes
run: |
@ -629,22 +337,9 @@ jobs:
echo "DEBUG: matrix.testfolder = ${{ matrix.testfolder }}"
if [ -n "$FILE_LIST" ]; then
# Map logical folder names to actual directories
case "${{ matrix.testfolder }}" in
"Logs-Builder-Basic"|"Logs-Builder-Advanced"|"Logs-Core"|"Logs-Features")
ACTUAL_FOLDER="Logs"
;;
"Dashboards-Core"|"Dashboards-Settings"|"Dashboards-Charts"|"Dashboards-Visualize"|"Dashboards-Tables"|"Dashboards-Streaming"|"Dashboards-Variables"|"Dashboards-Panel-Level-DateTime-Config"|"Dashboard-Config-Settings"|"Dashboards-Isolated")
ACTUAL_FOLDER="Dashboards"
;;
"RUM-Token")
ACTUAL_FOLDER="RUM"
;;
*)
ACTUAL_FOLDER="${{ matrix.testfolder }}"
;;
esac
# actual_folder (the real directory under playwright-tests/) comes straight
# from the matrix now — the logical->directory mapping lives in ci-matrix/ci_matrix.json.
ACTUAL_FOLDER="${{ matrix.actual_folder }}"
echo "DEBUG: ACTUAL_FOLDER = $ACTUAL_FOLDER"
# Build file paths

View File

@ -167,76 +167,38 @@ jobs:
retention-days: 1
if-no-files-found: error
generate_matrix:
timeout-minutes: 5
name: generate_matrix
needs: [check_main_changed]
if: ${{ needs.check_main_changed.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
steps:
- uses: actions/checkout@v5
- id: gen
run: |
MATRIX=$(node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix_regression.json)
if [ -z "$MATRIX" ]; then echo "::error::matrix generation failed (build-ci-matrix produced no output)"; exit 1; fi
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "$MATRIX" | node -e "const m=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('generated',m.include.length,'shards:', m.include.map(s=>s.testfolder).join(', '))"
ui_integration_tests:
timeout-minutes: 45
name: e2e / ${{ matrix.testfolder }}
needs: [build_binary]
needs: [build_binary, generate_matrix]
runs-on:
labels: eks-openobserve-standard-8
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- testfolder: "Logs-Regression"
run_files:
[
"logs-regression.spec.js",
"logs-bugs.spec.js",
"logs-9754.spec.js",
"logs-9044-7354.spec.js",
]
- testfolder: "Alerts-Regression"
run_files:
[
"alerts-regression.spec.js",
"alerts-stream-switching.spec.js",
"alerts-bugs.spec.js",
]
- testfolder: "Dashboard-Regression"
run_files:
[
"dashboard-regression.spec.js",
]
- testfolder: "Streams-Regression"
run_files:
[
"streams-regression.spec.js",
]
- testfolder: "Pipelines-Regression"
run_files:
[
"enrichment-regression.spec.js",
"pipeline-regression.spec.js",
]
- testfolder: "Traces-Regression"
run_files:
[
"traces-regression.spec.js",
"traces-bugs.spec.js",
]
- testfolder: "Metrics-Regression"
run_files:
[
"metrics-regression.spec.js",
]
- testfolder: "GeneralTests-Regression"
run_files:
[
"ui-regression.spec.js",
"landing-regression.spec.js",
]
- testfolder: "DataSources-Regression"
run_files:
[
"datasources-regression.spec.js",
]
- testfolder: "Reports-Regression"
run_files:
[
"reports-regression-bugs.spec.js",
]
# Matrix from tests/ui-testing/ci-matrix/ci_matrix_regression.json (single source of
# truth, shared with ENT via build-ci-matrix.js). Edit that JSON, not this file.
matrix: ${{ fromJSON(needs.generate_matrix.outputs.matrix) }}
steps:
- name: Kill background apt processes
@ -326,23 +288,10 @@ jobs:
echo "DEBUG: matrix.testfolder = ${{ matrix.testfolder }}"
if [ -n "$FILE_LIST" ]; then
# Derive feature subfolder from testfolder (maps testfolder name → directory under RegressionSet/)
case "${{ matrix.testfolder }}" in
"Dashboard-Regression") FEATURE="Dashboards" ;;
"Pipelines-Regression") FEATURE="Pipelines" ;;
"Alerts-Regression") FEATURE="Alerts" ;;
"Logs-Regression") FEATURE="Logs" ;;
"Traces-Regression") FEATURE="Traces" ;;
"Metrics-Regression") FEATURE="Metrics" ;;
"Streams-Regression") FEATURE="Streams" ;;
"Reports-Regression") FEATURE="Reports" ;;
"DataSources-Regression") FEATURE="DataSources" ;;
"GeneralTests-Regression") FEATURE="General" ;;
*) FEATURE="RegressionSet" ;;
esac
ACTUAL_FOLDER="RegressionSet/$FEATURE"
# actual_folder (e.g. RegressionSet/Logs) comes from the matrix now —
# the testfolder->directory mapping lives in ci_matrix_regression.json.
ACTUAL_FOLDER="${{ matrix.actual_folder }}"
echo "DEBUG: ACTUAL_FOLDER = $ACTUAL_FOLDER"
echo "DEBUG: FEATURE = $FEATURE"
FILE_PATHS=""
for file in $FILE_LIST; do

1
Cargo.lock generated
View File

@ -10533,6 +10533,7 @@ dependencies = [
"futures",
"futures-util",
"hashbrown 0.16.1",
"hashlink 0.11.0",
"infra",
"itertools 0.14.0",
"log",

View File

@ -806,6 +806,7 @@ pub fn service_routes() -> Router {
// Search
.route("/{org_id}/_search", post(search::search))
.route("/{org_id}/query_functions", get(search::query_functions::list))
.route("/{org_id}/_search_partition", post(search::search_partition))
.route("/{org_id}/{stream_name}/_around", get(search::around_v1).post(search::around_v2))
.route("/{org_id}/{stream_name}/_values", get(search::values))
@ -1514,6 +1515,117 @@ mod tests {
);
}
// NOTE ON WHAT IS TESTABLE HERE.
//
// service_routes() wraps the entire router in auth_middleware, which
// short-circuits BEFORE routing: a completely nonexistent path also answers
// 401. So an HTTP-level test against service_routes() cannot tell a
// registered route from an absent one — an assertion like "not 404", or
// even "== 401", passes whether or not the endpoint exists.
//
// Registration is therefore pinned two ways that DO discriminate: the route
// appears in the OpenAPI surface, and a minimal router carrying only this
// route dispatches GET to the handler and rejects other methods.
#[tokio::test]
async fn query_functions_route_dispatches_get_and_rejects_other_methods() {
let app = Router::new().route(
"/{org_id}/query_functions",
get(search::query_functions::list),
);
let ok = app
.clone()
.oneshot(
Request::builder()
.uri("/myorg/query_functions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
let wrong_method = app
.oneshot(
Request::builder()
.method("POST")
.uri("/myorg/query_functions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
wrong_method.status(),
StatusCode::METHOD_NOT_ALLOWED,
"the catalog is read-only; the frontend issues GET"
);
}
#[test]
fn query_functions_is_published_in_the_openapi_surface() {
// Discriminating: this fails if the handler is dropped from openapi.rs.
let spec = super::openapi::ApiDoc::openapi();
let json = serde_json::to_string(&spec).unwrap();
assert!(
json.contains("/{org_id}/query_functions"),
"query_functions is missing from the OpenAPI surface"
);
}
// ── tmp/code.md B4 — the query-function catalog route ─────────────────────
//
// catalog_functions() can be perfect while no HTTP route exposes it. This is
// the only assertion that fails if the endpoint is simply never registered.
#[tokio::test]
async fn query_functions_handler_returns_the_documented_payload_shape() {
// Calls the handler DIRECTLY, bypassing auth, so the body contract the
// frontend service depends on ({ list: [...] }) is actually asserted.
use axum::extract::Path;
let response =
openobserve_api_search::search::query_functions::list(Path("default".to_string()))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let payload: Value = serde_json::from_slice(&body).expect("body must be JSON");
let list = payload
.get("list")
.and_then(Value::as_array)
.expect("payload must be { list: [...] } — the frontend reads data.list");
assert!(!list.is_empty(), "catalog must not be empty");
let entry = list
.iter()
.find(|f| f.get("name").and_then(Value::as_str) == Some("match_all"))
.expect("match_all must be present");
for field in ["name", "signature", "doc", "kind", "deprecated"] {
assert!(
entry.get(field).is_some(),
"serialized entry is missing `{field}`"
);
}
// Assert non-empty doc on an entry the SERVER is the sole source for.
// The frontend catalog carries its own prose for the O2 UDFs and wins
// on merge, so requiring a server-side doc for match_all would pin data
// the UI never renders.
let alias = list
.iter()
.find(|f| f.get("name").and_then(Value::as_str) == Some("match_all_raw"))
.expect("rewriter alias must be present");
assert!(!alias["doc"].as_str().unwrap_or("").is_empty());
assert_eq!(alias["deprecated"], Value::Bool(true));
}
// ── is_origin_allowed unit tests ──────────────────────────────────────────
#[test]

View File

@ -92,6 +92,7 @@ use crate::{
rum::ingest::sessionreplay,
openobserve_api_search::search::search,
openobserve_api_search::search::search_partition,
openobserve_api_search::search::query_functions::list,
openobserve_api_search::search::around_v1,
openobserve_api_search::search::around_v2,
openobserve_api_search::search::values,

View File

@ -552,7 +552,15 @@ mod tests {
#[test]
fn test_select_final_rule_org_level() {
let default_rule = create_rule(".*", None, None, 100);
let org_rule = create_rule("test_org", None, None, 50);
// An org-level rule is the one carrying the GLOBAL sentinels in both
// user_role and user_id — see select_final_rule_resource. Passing None
// here described a shape the selector has never matched.
let org_rule = create_rule(
"test_org",
Some(DEFAULT_GLOBAL_USER_ROLE_IDENTIFIER),
Some(DEFAULT_GLOBAL_USER_ID_IDENTIFIER),
50,
);
let custom_rules = vec![org_rule.clone()];
let result = select_final_rule_resource(
@ -607,7 +615,12 @@ mod tests {
#[test]
fn test_select_final_rule_all_levels() {
let default_rule = create_rule(".*", None, None, 100);
let org_rule = create_rule("test_org", None, None, 80);
let org_rule = create_rule(
"test_org",
Some(DEFAULT_GLOBAL_USER_ROLE_IDENTIFIER),
Some(DEFAULT_GLOBAL_USER_ID_IDENTIFIER),
80,
);
let role_rule = create_rule("test_org", Some("admin"), Some(".*"), 60);
let user_rule = create_rule("test_org", Some("admin"), Some("test@example.com"), 40);
let custom_rules = vec![org_rule.clone(), role_rule.clone(), user_rule.clone()];

View File

@ -34,7 +34,7 @@ use crate::service::auth::{UserEmail, check_permissions};
pub struct ListSyntheticsQuery {
pub folder: Option<String>,
#[serde(rename = "type")]
pub monitor_type: Option<config::meta::synthetics::SyntheticType>,
pub check_type: Option<config::meta::synthetics::SyntheticType>,
pub enabled: Option<bool>,
pub location: Option<String>,
pub tag: Option<String>,
@ -46,7 +46,7 @@ impl From<ListSyntheticsQuery> for config::meta::synthetics::ListSyntheticsParam
fn from(q: ListSyntheticsQuery) -> Self {
Self {
folder_id: q.folder,
monitor_type: q.monitor_type,
check_type: q.check_type,
enabled: q.enabled,
location: q.location,
tag: q.tag,
@ -88,11 +88,11 @@ pub struct ListRunsQuery {
context_path = "/api",
tag = "Synthetics",
operation_id = "ListSyntheticsRuns",
summary = "List runs for a monitor",
summary = "List runs for a check",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("start_time" = Option<i64>, Query, description = "Filter runs with scheduled_ts >= start_time (microseconds)"),
("end_time" = Option<i64>, Query, description = "Filter runs with scheduled_ts <= end_time (microseconds)"),
("page" = Option<i64>, Query, description = "Page number (0-indexed, default 0)"),
@ -146,7 +146,7 @@ pub async fn list_runs(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("run_id" = String, Path, description = "Run ID (KSUID)"),
),
responses(
@ -332,7 +332,7 @@ pub async fn job_upload(
}
}
// ── Monitors ──────────────────────────────────────────────────────────────────
// ── Checks ──────────────────────────────────────────────────────────────────
#[utoipa::path(
get,
@ -345,7 +345,7 @@ pub async fn job_upload(
params(
("org_id" = String, Path, description = "Organization name"),
("folder" = Option<String>, Query, description = "Filter by folder ID (KSUID)"),
("type" = Option<String>, Query, description = "Filter by monitor type (http|browser|tcp|tls|ssh)"),
("type" = Option<String>, Query, description = "Filter by check type (http|browser|tcp|tls|ssh)"),
("enabled" = Option<bool>, Query, description = "Filter by enabled status"),
("location" = Option<String>, Query, description = "Filter by location"),
("tag" = Option<String>, Query, description = "Filter by tag"),
@ -428,7 +428,7 @@ pub async fn create_synthetic(
)
.await
{
Ok(monitor) => MetaHttpResponse::json(monitor),
Ok(check) => MetaHttpResponse::json(check),
Err(e) => {
let msg = e.to_string();
if msg.starts_with("validation: ") {
@ -457,7 +457,7 @@ pub async fn create_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = config::meta::synthetics::Synthetic),
@ -488,8 +488,8 @@ pub async fn get_synthetic(
return MetaHttpResponse::forbidden("Forbidden");
}
match o2_enterprise::enterprise::synthetics::service::get_synthetic(&org_id, &id).await {
Ok(Some(monitor)) => MetaHttpResponse::json(monitor),
Ok(None) => MetaHttpResponse::not_found("monitor not found"),
Ok(Some(check)) => MetaHttpResponse::json(check),
Ok(None) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] get_synthetic: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -514,7 +514,7 @@ pub async fn get_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("folder" = Option<String>, Query, description = "Current folder ID of the synthetic (for RBAC)"),
),
request_body(content = config::meta::synthetics::Synthetic, description = "Updated synthetic definition", content_type = "application/json"),
@ -550,7 +550,7 @@ pub async fn update_synthetic(
match o2_enterprise::enterprise::synthetics::service::update_synthetic(&org_id, &id, body)
.await
{
Ok(monitor) => MetaHttpResponse::json(monitor),
Ok(check) => MetaHttpResponse::json(check),
Err(e) => {
let msg = e.to_string();
if msg.starts_with("validation: ") {
@ -582,7 +582,7 @@ pub async fn update_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("folder" = Option<String>, Query, description = "Current folder ID of the synthetic (for RBAC)"),
),
responses(
@ -614,8 +614,8 @@ pub async fn delete_synthetic(
return MetaHttpResponse::forbidden("Forbidden");
}
match o2_enterprise::enterprise::synthetics::service::delete_synthetic(&org_id, &id).await {
Ok(true) => MetaHttpResponse::ok("monitor deleted"),
Ok(false) => MetaHttpResponse::not_found("monitor not found"),
Ok(true) => MetaHttpResponse::ok("check deleted"),
Ok(false) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] delete_synthetic: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -662,7 +662,7 @@ pub async fn delete_synthetics_bulk(
)
.await
{
Ok(_) => MetaHttpResponse::ok("monitors deleted"),
Ok(_) => MetaHttpResponse::ok("checks deleted"),
Err(e) => {
tracing::error!("[synthetics] delete_synthetics_bulk: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -732,7 +732,7 @@ pub async fn move_synthetics(
)
.await
{
Ok(_) => MetaHttpResponse::ok("monitors moved"),
Ok(_) => MetaHttpResponse::ok("checks moved"),
Err(e) => {
tracing::error!("[synthetics] move_synthetics: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -757,7 +757,7 @@ pub async fn move_synthetics(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
request_body(content = Object, description = r#"{"enabled": true}"#, content_type = "application/json"),
responses(
@ -799,11 +799,11 @@ pub async fn set_synthetic_enabled(
.await
{
Ok(true) => MetaHttpResponse::ok(if enabled {
"monitor enabled"
"check enabled"
} else {
"monitor paused"
"check paused"
}),
Ok(false) => MetaHttpResponse::not_found("monitor not found"),
Ok(false) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] set_synthetic_enabled: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -828,7 +828,7 @@ pub async fn set_synthetic_enabled(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
responses(
(status = 202, description = "Accepted — scheduler will fire within 5 seconds"),
@ -886,7 +886,7 @@ pub async fn run_synthetic_now(
context_path = "/api",
tag = "Synthetics",
operation_id = "SyntheticsJobResolve",
summary = "Resolve a job — probe fetches monitor config (authenticated via o2syn_ token)",
summary = "Resolve a job — probe fetches check config (authenticated via o2syn_ token)",
params(
("org_id" = String, Path, description = "Organization name"),
),
@ -1128,9 +1128,9 @@ async fn process_ack(
if should_notify && !resp.destinations.is_empty() {
let notification = openobserve_core::synthetics::CheckNotification {
org_id: resp.org_id.clone(),
monitor_name: resp.synthetics_name.clone(),
monitor_id: resp.synthetics_id.clone(),
monitor_type: resp.synthetic_type.clone(),
check_name: resp.synthetics_name.clone(),
check_id: resp.synthetics_id.clone(),
check_type: resp.synthetic_type.clone(),
target: resp.target.clone(),
destinations: resp.destinations.clone(),
run_id: resp.run_id.clone(),
@ -1144,6 +1144,7 @@ async fn process_ack(
degraded,
status_reason: resp.status_reason.clone(),
failing_locations: resp.failing_locations.clone(),
passing_locations: resp.passing_locations.clone(),
};
tokio::spawn(async move {
openobserve_core::synthetics::notify_check_result(notification).await;

View File

@ -75,6 +75,7 @@ pub(crate) mod around;
pub mod error_utils;
pub mod multi_streams;
pub mod patterns;
pub mod query_functions;
pub mod query_manager;
pub mod saved_view;
#[cfg(feature = "enterprise")]

View File

@ -0,0 +1,53 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The SQL function catalog served to the query editor.
//!
//! The frontend used to hand-maintain its list of callable functions, which had
//! no way of tracking the pinned DataFusion fork, build features, or an
//! organisation's own VRL transforms. This endpoint is derived from the live
//! registry instead.
use axum::{Json, extract::Path, response::Response};
use search::datafusion::exec::{CatalogFunction, catalog_functions};
use serde::Serialize;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct QueryFunctionsResponse {
pub list: Vec<CatalogFunction>,
}
/// GET /api/{org_id}/query_functions
///
/// Returns every function this organisation can call: the DataFusion registry
/// (including the JSON family), the O2 UDFs, the SQL-rewriter aliases, and the
/// org's own VRL transforms.
#[utoipa::path(
get,
path = "/{org_id}/query_functions",
context_path = "/api",
tag = "Search",
operation_id = "QueryFunctions",
security(("Authorization" = [])),
params(("org_id" = String, Path, description = "Organization name")),
responses(
(status = 200, description = "Success", content_type = "application/json",
body = QueryFunctionsResponse),
)
)]
pub async fn list(Path(org_id): Path<String>) -> Response {
let list = catalog_functions(&org_id);
axum::response::IntoResponse::into_response(Json(QueryFunctionsResponse { list }))
}

View File

@ -52,7 +52,7 @@ pub type RwAHashSet<K> = tokio::sync::RwLock<HashSet<K>>;
pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// for DDL commands and migrations
pub const DB_SCHEMA_VERSION: u64 = 62;
pub const DB_SCHEMA_VERSION: u64 = 63;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables

View File

@ -124,10 +124,10 @@ pub struct Synthetic {
#[serde(default)]
pub tags: Vec<String>,
#[serde(rename = "type")]
pub monitor_type: SyntheticType,
pub check_type: SyntheticType,
/// Target URL (HTTP/Browser) or host:port (TCP/TLS/SSH).
pub target: String,
/// Type-specific config, stored as JSONB. Shape depends on monitor_type.
/// Type-specific config, stored as JSONB. Shape depends on check_type.
pub config: serde_json::Value,
/// Schedule — same modular format as reports frequency.
pub frequency: SyntheticFrequency,
@ -148,10 +148,10 @@ pub struct Synthetic {
/// Silence period (minutes) between repeated alert notifications.
#[serde(default, alias = "cooldown_secs")]
pub cooldown_mins: i32,
/// Collect RUM data for browser monitors (session replay / performance).
/// Collect RUM data for browser checks (session replay / performance).
#[serde(default)]
pub collect_rum_data: bool,
/// Enable session replay capture (browser monitors only).
/// Enable session replay capture (browser checks only).
#[serde(default)]
pub session_replay: bool,
/// Optional authentication config (basic auth, bearer token, etc.).
@ -217,7 +217,30 @@ pub enum SyntheticType {
Dns,
}
/// Retry ceiling for browser checks. Lower than the protocol types because a
/// browser run costs `devices x attempts x journey_budget`: at 3 retries a ~100s
/// journey already reaches the browser Lambda's 303s function timeout, so the
/// config would validate and then be killed mid-journey — reporting a failure
/// the target never had.
pub const MAX_BROWSER_RETRIES: i32 = 2;
/// Retry ceiling for the protocol types. One attempt is a single request, so the
/// worst case is bounded by `timeout_ms` and stays well inside the budget.
pub const MAX_NET_RETRIES: i32 = 3;
impl SyntheticType {
/// Largest `retries` value this type accepts.
///
/// Note the product default is **0** for every type, and that is deliberate:
/// retries mask real failures, so opting in is the user's choice. This is
/// only the ceiling on that choice.
pub fn max_retries(&self) -> i32 {
match self {
Self::Browser => MAX_BROWSER_RETRIES,
_ => MAX_NET_RETRIES,
}
}
/// JSON paths inside this type's `config` blob whose string values are
/// credentials and must be AES-encrypted at rest (and decrypted on read).
///
@ -440,7 +463,7 @@ pub struct SyntheticVariable {
// ── Settings (packed into the `settings` JSON column) ────────────────────────
/// Non-type-specific monitor settings stored as a single `settings` JSON blob.
/// Non-type-specific check settings stored as a single `settings` JSON blob.
/// auth and variables are stored in their own dedicated encrypted TEXT columns, not here.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyntheticSettings {
@ -489,7 +512,7 @@ pub struct SyntheticListItem {
pub description: String,
pub tags: Vec<String>,
#[serde(rename = "type")]
pub monitor_type: SyntheticType,
pub check_type: SyntheticType,
pub target: String,
pub frequency: SyntheticFrequency,
pub locations: Vec<String>,
@ -509,7 +532,7 @@ pub struct SyntheticListItem {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ListSyntheticsParams {
pub folder_id: Option<String>,
pub monitor_type: Option<SyntheticType>,
pub check_type: Option<SyntheticType>,
pub enabled: Option<bool>,
pub location: Option<String>,
pub tag: Option<String>,
@ -519,7 +542,7 @@ pub struct ListSyntheticsParams {
#[derive(Debug, Serialize, ToSchema)]
pub struct SyntheticListResponse {
pub monitors: Vec<SyntheticListItem>,
pub checks: Vec<SyntheticListItem>,
pub total: i64,
}
@ -623,7 +646,7 @@ pub struct SshAuth {
//
// The retired version-1 step was untyped JSON with a single `selector` and a
// recorder-stamped `timeout_ms`. This typed, server-validated structure replaced
// it, and is now the only shape a monitor can hold.
// it, and is now the only shape a check can hold.
//
// The envelope is defined ONCE, complete, even though later phases populate
// parts of it: `settle.navigation` (Phase 3), `settle.responses` (Phase 4),
@ -773,7 +796,7 @@ pub struct BrowserStepV2 {
pub timeout_ms: Option<u32>,
}
/// A (browser, device) pair for browser monitor fan-out.
/// A (browser, device) pair for browser check fan-out.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BrowserDevice {
/// "chromium" | "firefox" | "edge"
@ -803,7 +826,7 @@ pub struct BrowserConfig {
/// retry sequence cannot outlive it — see `validate_browser_config`.
pub journey_budget_ms: Option<u32>,
pub capture: Option<BrowserCapture>,
/// The DOM attribute the recorder selects on for this monitor.
/// The DOM attribute the recorder selects on for this check.
///
/// Absent means [`DEFAULT_TEST_ID_ATTR`]. It exists because the attribute is
/// a property of the application under test, not of OpenObserve: Playwright
@ -815,7 +838,7 @@ pub struct BrowserConfig {
/// application outside that list produces NO `test_attribute` candidates at
/// all and every step degrades to role/text/css without any error.
///
/// Recorded per monitor rather than per org so a journey that was recorded
/// Recorded per check rather than per org so a journey that was recorded
/// against one application keeps working when another is added.
pub test_id_attr: Option<String>,
}
@ -935,20 +958,115 @@ const MAX_JOURNEY_BUDGET_MS: u32 = 900_000;
/// case inside this number, which is in turn what lets the server lease for it
/// unconditionally.
///
/// NOTE: the AWS Lambda function timeout must be >= this value, or runs are
/// killed mid-journey. That setting lives outside this repository and cannot be
/// asserted here. 900s is also AWS's maximum, so raising `retries` or
/// `journey_budget_ms` further requires re-deriving all three together.
pub const JOB_LEASE_SECS: i64 = 900;
/// NOTE: the AWS Lambda function timeout must be >= `max_check_budget_secs`, or
/// runs are killed mid-journey. That setting lives outside this repository — it
/// is applied by the probe deploy scripts — so it cannot be asserted here.
/// 900s is also AWS's maximum.
pub const DEFAULT_JOB_LEASE_SECS: i64 = 900;
/// Ceiling on a check's worst-case run, in seconds. Deliberately **below**
/// `job_lease_secs`: the gap is what dispatch and the ack need, because a run
/// finishing exactly at the function timeout still has to report before the
/// reaper assumes the probe is gone.
pub const DEFAULT_MAX_CHECK_BUDGET_SECS: i64 = 840;
/// Ceiling for ONE attempt of a non-browser check, in milliseconds.
///
/// Net `timeout_ms` was previously unbounded: every protocol config defaults it
/// to 10s, but nothing rejected `timeout_ms: 3_600_000`, so the worst case of a
/// retry sequence had no upper limit and could not be checked against the lease.
const MAX_NET_TIMEOUT_MS: u32 = 300_000;
/// retry sequence had no upper limit and could not be checked against the budget.
pub const DEFAULT_MAX_NET_TIMEOUT_MS: u32 = 300_000;
const MIN_NET_TIMEOUT_MS: u32 = 1_000;
/// The three stacked bounds, tunable by deployment.
///
/// ```text
/// check worst case <= max_check_budget_secs < job_lease_secs
/// 840s 900s
/// (also the Lambda
/// function timeout)
/// ```
///
/// Synthetics is enterprise-only, so the values are declared in
/// `o2_enterprise`'s `SyntheticsConfig` (`O2_SYNTHETICS_*` env vars) and pushed
/// in here at startup by [`init_limits`]. This crate cannot read them directly —
/// `config` has no dependency on `o2_enterprise` — so the holder below is the
/// seam, and it falls back to the `DEFAULT_*` values in OSS builds and in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyntheticsLimits {
pub job_lease_secs: i64,
pub max_check_budget_secs: i64,
pub max_net_timeout_ms: u32,
}
impl Default for SyntheticsLimits {
fn default() -> Self {
Self {
job_lease_secs: DEFAULT_JOB_LEASE_SECS,
max_check_budget_secs: DEFAULT_MAX_CHECK_BUDGET_SECS,
max_net_timeout_ms: DEFAULT_MAX_NET_TIMEOUT_MS,
}
}
}
impl SyntheticsLimits {
/// Rejects a set of limits that cannot hold together.
///
/// The ordering is the whole point: a budget at or above the lease means a
/// check can be accepted, run to its limit, and still have its ack rejected
/// as stale — which surfaces to the user as a failure their target never
/// had. Operators own these values; this only refuses combinations that
/// cannot work.
pub fn validate(&self) -> Result<(), String> {
if self.max_check_budget_secs >= self.job_lease_secs {
return Err(format!(
"O2_SYNTHETICS_MAX_CHECK_BUDGET_SECS ({}) must be strictly less than \
O2_SYNTHETICS_JOB_LEASE_SECS ({}) the gap is what dispatch and the ack need. \
A run that finishes at the budget still has to report before the reaper assumes \
the probe is gone.",
self.max_check_budget_secs, self.job_lease_secs
));
}
if self.max_check_budget_secs <= 0 || self.job_lease_secs <= 0 {
return Err("synthetics limits must be positive".to_string());
}
if self.max_net_timeout_ms as i64 > self.max_check_budget_secs * 1_000 {
return Err(format!(
"O2_SYNTHETICS_MAX_NET_TIMEOUT_MS ({}) exceeds the check budget ({}s) — a single \
attempt could never fit",
self.max_net_timeout_ms, self.max_check_budget_secs
));
}
Ok(())
}
}
static LIMITS: std::sync::OnceLock<SyntheticsLimits> = std::sync::OnceLock::new();
/// Installs deployment-configured limits. Called once from `init_enterprise`.
///
/// **Not fatal.** A bad synthetics ceiling must not stop the whole application
/// from starting — synthetics is one feature, and o2 serving ingest, search and
/// dashboards matters more than it. On rejection nothing is installed, so
/// [`limits`] keeps returning the `DEFAULT_*` values, which are known to hold
/// together. The caller logs the error; an operator fixes the env var and
/// restarts.
///
/// Falling back rather than accepting is the safe direction: the defaults are
/// conservative, whereas an invalid pair (say budget == lease) is what silently
/// converts healthy targets into alerts.
pub fn init_limits(limits: SyntheticsLimits) -> Result<(), String> {
limits.validate()?;
let _ = LIMITS.set(limits);
Ok(())
}
/// The active limits — deployment-configured when enterprise has initialised,
/// otherwise the `DEFAULT_*` values.
pub fn limits() -> SyntheticsLimits {
LIMITS.get().copied().unwrap_or_default()
}
/// Worst-case wall clock for one leased job, in milliseconds.
///
/// Retries happen INSIDE the leased job, so the lease has to cover the whole
@ -1009,16 +1127,18 @@ fn validate_net_retry_budget(
.and_then(|v| u32::try_from(v).ok())
.unwrap_or_else(default_timeout_ms);
if !(MIN_NET_TIMEOUT_MS..=MAX_NET_TIMEOUT_MS).contains(&timeout_ms) {
let max_net_timeout_ms = limits().max_net_timeout_ms;
if !(MIN_NET_TIMEOUT_MS..=max_net_timeout_ms).contains(&timeout_ms) {
return Err(format!(
"config.timeout_ms: must be {MIN_NET_TIMEOUT_MS}..={MAX_NET_TIMEOUT_MS}, got {timeout_ms}"
"config.timeout_ms: must be {MIN_NET_TIMEOUT_MS}..={max_net_timeout_ms}, got {timeout_ms}"
));
}
let budget_secs = limits().max_check_budget_secs;
let worst_case_ms = worst_case_run_ms(timeout_ms, 1, retries, wait_before_retry_secs);
if worst_case_ms > JOB_LEASE_SECS * 1_000 {
// Same shape as the browser message above: remedy first, arithmetic
// behind it, durations rather than raw milliseconds.
// Bound is the CHECK BUDGET, not the lease (ours). Wording is main's:
// remedy first, durations rather than raw milliseconds.
if worst_case_ms > budget_secs * 1_000 {
let attempts = retries + 1;
let retries_fix = if retries > 0 {
format!("lower retries below {retries}, ")
@ -1026,12 +1146,12 @@ fn validate_net_retry_budget(
String::new()
};
return Err(format!(
"config: this check needs up to {} per run, which is over the {} job lease. To fix it, \
{retries_fix}or lower config.timeout_ms (currently {}). Detail: {} attempt(s) x {} \
each, plus {}s between retries. A check that outlives its lease has its job \
terminated mid-run and its real result rejected as a stale ack.",
"config: this check needs up to {} per run, which is over the {} check budget. To fix \
it, {retries_fix}or lower config.timeout_ms (currently {}). Detail: {} attempt(s) x \
{} each, plus {}s between retries. A check that outlives the budget is killed mid-run \
by the probe's function timeout and reports a failure the target never had.",
human_ms(worst_case_ms),
human_ms(JOB_LEASE_SECS * 1_000),
human_ms(budget_secs * 1_000),
human_ms(i64::from(timeout_ms)),
attempts,
human_ms(i64::from(timeout_ms)),
@ -1042,7 +1162,7 @@ fn validate_net_retry_budget(
}
/// The complete v2 action vocabulary — exactly Playwright's recorder action
/// model, minus what a monitor cannot use.
/// model, minus what a check cannot use.
///
/// Deliberately excludes `hover`, `scroll`, `wait`/`waitFor` and `screenshot`:
/// upstream `ActionName` has no counterpart for any of them, so the recorder
@ -1107,7 +1227,7 @@ const LOCATOR_ORIGINS: &[&str] = &["recorded", "authored", "composite"];
/// How one part of a combined locator attaches to the part before it.
///
/// Named after Playwright's own operations rather than CSS's, because the
/// stored value IS a Playwright selector string and anyone debugging a monitor
/// stored value IS a Playwright selector string and anyone debugging a check
/// reads Playwright's documentation: `and` is `.and(b)`, `has` and `has_not`
/// are `.filter({ has })` / `.filter({ hasNot })`, `descendant` is `.locator(b)`.
///
@ -1117,11 +1237,11 @@ const LOCATOR_ORIGINS: &[&str] = &["recorded", "authored", "composite"];
/// order and so destroys the preference the ordered bundle exists to express.
const COMPOSITE_RELATIONS: &[&str] = &["and", "has", "has_not", "descendant"];
/// The recorder's test-id attribute when a monitor does not set one.
/// The recorder's test-id attribute when a check does not set one.
///
/// `data-test` rather than Playwright's `data-testid`: OpenObserve's own
/// frontend marks interactive elements with it, and self-monitoring is this
/// feature's acceptance test (X-1's o2.introspect monitors).
/// feature's acceptance test (X-1's o2.introspect checks).
pub const DEFAULT_TEST_ID_ATTR: &str = "data-test";
/// Longest attribute name accepted. A DOM attribute name this long is not a
@ -1131,12 +1251,12 @@ const MAX_SETTLE_RESPONSES: usize = 5;
const MAX_TAGS: usize = 20;
const MAX_VARIABLES: usize = 50;
const MAX_BROWSER_DEVICE_COMBOS: usize = 12;
/// Minimum schedule interval (seconds) for protocol monitors (http/tcp/ping/…).
/// Minimum schedule interval (seconds) for protocol checks (http/tcp/ping/…).
/// Ping-style checks legitimately run at 1s granularity.
/// NOTE: the scheduler ticks every 5s, so sub-5s intervals fire at tick
/// resolution — allowed here, but effective cadence is bounded by the tick.
const MIN_INTERVAL_SECS: i64 = 1;
/// Minimum schedule interval (seconds) for browser monitors — each fire costs
/// Minimum schedule interval (seconds) for browser checks — each fire costs
/// one Lambda invocation per location per browser×device combo.
const MIN_BROWSER_INTERVAL_SECS: i64 = 60;
@ -1181,11 +1301,11 @@ fn location_allowed(loc: &str, allowed: &[String]) -> bool {
&& allowed.iter().any(|a| a == &format!("aws-{loc}"))
}
/// A save-time warning: the monitor is accepted, but something about it is worth
/// A save-time warning: the check is accepted, but something about it is worth
/// telling the author.
///
/// Separate from the `Err(String)` channel on purpose. A zero-assertion journey
/// is legitimate — a monitor that only navigates still proves the site answers —
/// is legitimate — a check that only navigates still proves the site answers —
/// so refusing it would be wrong; but it can also click its way through a broken
/// application and pass, which is worth saying out loud (P5.2.4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -1203,14 +1323,14 @@ pub struct SyntheticWarning {
}
impl Synthetic {
/// Non-blocking problems worth surfacing when a monitor is saved.
/// Non-blocking problems worth surfacing when a check is saved.
///
/// Deliberately not part of `validate`: everything here is accepted. A caller
/// that ignores this returns exactly the behaviour it had before.
pub fn warnings(&self) -> Vec<SyntheticWarning> {
let mut warnings = Vec::new();
if self.monitor_type == SyntheticType::Browser {
if self.check_type == SyntheticType::Browser {
let has_assertion = self
.config
.get("steps")
@ -1244,8 +1364,8 @@ impl Synthetic {
/// Returns the first problem found as `Err(message)`; messages are safe to
/// return verbatim in a 400 response.
/// `is_create`: the `start` freshness check only applies on create — edits
/// round-trip the monitor's original start date, which is legitimately in
/// the past for any monitor older than the grace window.
/// round-trip the check's original start date, which is legitimately in
/// the past for any check older than the grace window.
pub fn validate(
&self,
allowed_locations: &[String],
@ -1279,7 +1399,7 @@ impl Synthetic {
}
// ── target ─────────────────────────────────────────────────────────
match self.monitor_type {
match self.check_type {
SyntheticType::Http | SyntheticType::Api | SyntheticType::Browser => {
validate_http_url("target", &self.target)?
}
@ -1313,16 +1433,16 @@ impl Synthetic {
self.frequency.interval
));
}
let min_secs = if self.monitor_type == SyntheticType::Browser {
let min_secs = if self.check_type == SyntheticType::Browser {
MIN_BROWSER_INTERVAL_SECS
} else {
MIN_INTERVAL_SECS
};
if self.frequency.interval_secs() < min_secs {
return Err(format!(
"frequency: interval too short ({}s < {min_secs}s minimum for {:?} monitors)",
"frequency: interval too short ({}s < {min_secs}s minimum for {:?} checks)",
self.frequency.interval_secs(),
self.monitor_type
self.check_type
));
}
}
@ -1355,8 +1475,19 @@ impl Synthetic {
}
// ── retry / alert settings ─────────────────────────────────────────
if !(0..=3).contains(&self.retries) {
return Err(format!("retries: must be 0..=3, got {}", self.retries));
// Browser is capped lower than the protocol types, and the cap is
// load-bearing rather than a preference. A browser run is
// devices x attempts x journey_budget, so at 3 retries a ~100s journey
// already reaches the browser function's 303s timeout — the config
// would validate and then be killed mid-journey, reporting a failure
// the target never had. Raise this only together with the deployed
// function timeout and MAX_CHECK_BUDGET_SECS.
let max_retries = self.check_type.max_retries();
if !(0..=max_retries).contains(&self.retries) {
return Err(format!(
"retries: must be 0..={max_retries} for {:?} checks, got {}",
self.check_type, self.retries
));
}
if !(0..=300).contains(&self.wait_before_retry_secs) {
return Err(format!(
@ -1450,13 +1581,13 @@ impl Synthetic {
self.validate_config(allowed_browsers, allowed_devices)
}
/// Parses `config` into the struct matching `monitor_type` and validates it.
/// Parses `config` into the struct matching `check_type` and validates it.
fn validate_config(
&self,
allowed_browsers: &[String],
allowed_devices: &[String],
) -> Result<(), String> {
let type_check = match self.monitor_type {
let type_check = match self.check_type {
SyntheticType::Browser => {
let cfg: BrowserConfig = serde_json::from_value(self.config.clone())
.map_err(|e| format!("config: not a valid browser config: {e}"))?;
@ -1542,7 +1673,7 @@ impl Synthetic {
// browser path applies inside `validate_browser_config`. Done here, once,
// rather than in each arm: the arms are per-type and this rule is not, and
// adding a check type should not be able to opt out of it silently.
if self.monitor_type != SyntheticType::Browser {
if self.check_type != SyntheticType::Browser {
validate_net_retry_budget(&self.config, self.retries, self.wait_before_retry_secs)?;
}
Ok(())
@ -1846,13 +1977,12 @@ fn validate_browser_config(
// duplicate. Which is verbatim what the LEASE_SECS comment in
// `dispatcher/mod.rs` was written to prevent.
let devices = i64::try_from(cfg.browser_devices.len().max(1)).unwrap_or(1);
let budget_secs = limits().max_check_budget_secs;
let worst_case_ms = worst_case_run_ms(budget_ms, devices, retries, wait_before_retry_secs);
if worst_case_ms > JOB_LEASE_SECS * 1_000 {
// Remedy FIRST, in terms the form actually offers. The previous wording
// led with "Lower journey_budget_ms" — a field the UI neither renders
// nor sends — so the one lever named first was the one the reader could
// not reach, and the arithmetic came before the instruction. The numbers
// are unchanged, just moved behind the fix and rendered as durations.
// Bound is the CHECK BUDGET, not the lease (ours). Wording is main's:
// remedy first, and journey_budget_ms named last because the UI does not
// render it.
if worst_case_ms > budget_secs * 1_000 {
let attempts = retries + 1;
let combos_fix = if devices > 1 {
format!("drop a combo from config.browser_devices (currently {devices}), ")
@ -1865,13 +1995,13 @@ fn validate_browser_config(
String::new()
};
return Err(format!(
"config: this check needs up to {} per run, which is over the {} job lease. To fix it, \
{combos_fix}{retries_fix}or shorten the run with config.journey_budget_ms (currently \
{}). Detail: {devices} browser/device combo(s) x {attempts} attempt(s) x {} each, \
plus {}s between retries. A run that outlives its lease is requeued and executed a \
second time.",
"config: this check needs up to {} per run, which is over the {} check budget. To fix \
it, {combos_fix}{retries_fix}or shorten the run with config.journey_budget_ms \
(currently {}). Detail: {devices} browser/device combo(s) x {attempts} attempt(s) x \
{} each, plus {}s between retries. A run that outlives the budget is killed \
mid-journey by the probe's function timeout.",
human_ms(worst_case_ms),
human_ms(JOB_LEASE_SECS * 1_000),
human_ms(budget_secs * 1_000),
human_ms(i64::from(budget_ms)),
human_ms(i64::from(budget_ms)),
wait_before_retry_secs,
@ -2007,6 +2137,82 @@ fn validate_browser_devices_and_schedule(
Ok(())
}
#[cfg(test)]
mod limits_tests {
use super::*;
#[test]
fn defaults_hold_together() {
SyntheticsLimits::default()
.validate()
.expect("shipped defaults must be a valid combination");
}
#[test]
fn budget_equal_to_lease_is_rejected() {
// The gap is what dispatch and the ack need. Equal means a run that
// uses its full budget cannot report before the reaper requeues it.
let l = SyntheticsLimits {
job_lease_secs: 900,
max_check_budget_secs: 900,
..Default::default()
};
assert!(l.validate().is_err());
}
#[test]
fn budget_above_lease_is_rejected() {
let l = SyntheticsLimits {
job_lease_secs: 900,
max_check_budget_secs: 901,
..Default::default()
};
assert!(l.validate().is_err());
}
#[test]
fn net_timeout_larger_than_the_budget_is_rejected() {
// One attempt could never fit, so every config of that type would fail
// validation for a reason the user cannot act on.
let l = SyntheticsLimits {
max_check_budget_secs: 10,
max_net_timeout_ms: 300_000,
..Default::default()
};
assert!(l.validate().is_err());
}
#[test]
fn a_raised_but_still_ordered_pair_is_accepted() {
// Operators own these values; validation only refuses combinations that
// cannot work, not ones it merely dislikes.
let l = SyntheticsLimits {
job_lease_secs: 600,
max_check_budget_secs: 540,
max_net_timeout_ms: 120_000,
};
assert!(l.validate().is_ok());
}
#[test]
fn limits_fall_back_to_defaults_when_uninitialised() {
// Tests and OSS builds never call init_limits, so this is the path the
// whole validation suite actually runs on.
assert_eq!(limits(), SyntheticsLimits::default());
}
#[test]
fn browser_retries_are_capped_lower_than_net() {
assert_eq!(SyntheticType::Browser.max_retries(), MAX_BROWSER_RETRIES);
assert_eq!(SyntheticType::Http.max_retries(), MAX_NET_RETRIES);
assert!(
SyntheticType::Browser.max_retries() < SyntheticType::Http.max_retries(),
"browser is bounded by devices x attempts x journey budget, so its cap \
must stay below the protocol types'"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -2248,7 +2454,7 @@ mod tests {
fn valid_browser_synthetic() -> Synthetic {
Synthetic {
name: "login flow".to_string(),
monitor_type: SyntheticType::Browser,
check_type: SyntheticType::Browser,
target: "https://example.com".to_string(),
frequency: SyntheticFrequency {
frequency_type: SyntheticFrequencyType::Minutes,
@ -2292,7 +2498,7 @@ mod tests {
fn valid_tcp_synthetic() -> Synthetic {
Synthetic {
name: "db port".to_string(),
monitor_type: SyntheticType::Tcp,
check_type: SyntheticType::Tcp,
target: "db.example.com".to_string(),
frequency: SyntheticFrequency {
frequency_type: SyntheticFrequencyType::Minutes,
@ -2310,16 +2516,20 @@ mod tests {
}
#[test]
fn net_retry_sequence_must_fit_the_job_lease() {
fn net_retry_sequence_must_fit_the_check_budget() {
let (locs, brs, devs) = allowed();
// The lease covers the whole retry sequence because retries run inside
// the leased job. 4 x 300s alone is 1200s, past the 900s lease.
// The budget covers the whole retry sequence because retries run inside
// the leased job. 4 x 300s alone is 1200s, past the 840s budget.
//
// Validated against the BUDGET, not the lease: on the managed path the
// probe is a Lambda, and a function that hits its timeout is killed
// mid-run. Lease headroom is irrelevant once the process is gone.
let mut s = valid_tcp_synthetic();
s.config = serde_json::json!({ "port": 5432, "timeout_ms": 300_000 });
s.retries = 3;
s.wait_before_retry_secs = 0;
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
assert!(err.contains("job lease"), "{err}");
assert!(err.contains("check budget"), "{err}");
// The gaps count too: 3 x 250s = 750s of attempts is fine on its own,
// but not with 300s of waiting between them.
@ -2328,7 +2538,7 @@ mod tests {
s.retries = 2;
s.wait_before_retry_secs = 300;
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
assert!(err.contains("job lease"), "{err}");
assert!(err.contains("check budget"), "{err}");
}
#[test]
@ -2876,9 +3086,9 @@ mod tests {
#[test]
fn test_a_zero_assertion_journey_is_accepted_with_a_machine_readable_warning() {
// P5.2.4 — accepted, not refused: a monitor that only navigates still
// P5.2.4 — accepted, not refused: a check that only navigates still
// proves the site answers. The warning is what stops it being mistaken
// for a monitor that checks something.
// for a check that checks something.
let (locs, brs, devs) = allowed();
let s = v2_synthetic(serde_json::json!([v2_nav_step(), v2_click_step()]));
assert!(s.validate(&locs, &brs, &devs, true).is_ok());
@ -3233,19 +3443,36 @@ mod tests {
}
#[test]
fn test_browser_journey_budget_exceeding_lease_rejected() {
fn test_browser_journey_budget_exceeding_check_budget_rejected() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.retries = 3;
// 2 is the browser cap; 3 would be rejected by the retries bound before
// ever reaching the budget arithmetic this test is about.
s.retries = MAX_BROWSER_RETRIES;
s.wait_before_retry_secs = 5;
s.config["journey_budget_ms"] = serde_json::json!(300_000);
// 4 * 300s + 15s = 1215s — well past the 900s lease.
// 3 * 300s + 10s = 910s — past the 840s check budget.
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
// The error must name all three inputs, or an operator cannot tell which
// one to change.
assert!(err.contains("journey_budget_ms"), "{err}");
assert!(err.contains("retries"), "{err}");
assert!(err.contains("lease"), "{err}");
assert!(err.contains("check budget"), "{err}");
}
#[test]
fn browser_retries_above_the_cap_are_rejected() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.retries = MAX_BROWSER_RETRIES + 1;
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
assert!(err.contains("retries"), "{err}");
// The same value is fine on a protocol check, which is the point of the
// cap being per-type rather than global.
let mut n = valid_tcp_synthetic();
n.retries = MAX_BROWSER_RETRIES + 1;
assert!(n.validate(&locs, &brs, &devs, true).is_ok());
}
// The invariant above was per-DEVICE while the work is per-JOB: the probe runs
@ -3278,7 +3505,9 @@ mod tests {
assert!(err.contains("browser_devices"), "{err}");
assert!(err.contains("journey_budget_ms"), "{err}");
assert!(err.contains("retries"), "{err}");
assert!(err.contains("lease"), "{err}");
// "budget", not "lease": validation measures against the check budget so
// a config the probe's function timeout would kill is rejected on save.
assert!(err.contains("budget"), "{err}");
}
#[test]
@ -3299,15 +3528,20 @@ mod tests {
fn over_lease_message_leads_with_an_actionable_remedy() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.retries = 3;
// 2, not 3: browser retries are capped at MAX_BROWSER_RETRIES, so 3 is
// rejected by that rule first and never reaches the budget message.
s.retries = 2;
s.wait_before_retry_secs = 5;
s.config["journey_budget_ms"] = serde_json::json!(300_000);
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
// 4 attempts x 300s + 3 gaps x 5s = 1215s.
// 3 attempts x 300s + 2 gaps x 5s = 910s.
// Durations, not millisecond counts, for the two headline numbers.
assert!(err.contains("20m15s"), "worst case as a duration: {err}");
assert!(err.contains("15m job lease"), "limit as a duration: {err}");
assert!(err.contains("15m10s"), "worst case as a duration: {err}");
assert!(
err.contains("14m check budget"),
"limit as a duration: {err}"
);
// The remedy precedes the arithmetic.
let fix_at = err.find("To fix it").expect("names a fix");
let detail_at = err.find("Detail:").expect("keeps the arithmetic");
@ -3322,14 +3556,18 @@ mod tests {
/// Two browser/device combos with the web form's own default `retries: 1`
/// and the server default budget land at 2 x (2 x 300s + 5s) = 1210s, past
/// the 900s lease — so "Chromium desktop + Chromium mobile" cannot be saved
/// without the author first discovering they must set retries to 0.
/// the 840s check budget — so "Chromium desktop + Chromium mobile" cannot be
/// saved without the author first discovering they must set retries to 0.
///
/// Asserted here so that changing any of the three defaults has to confront
/// which configurations are savable, rather than shifting it silently.
///
/// Measures against the BUDGET, not the lease: validation moved to the
/// budget so a check the probe's function timeout will kill is rejected at
/// save time rather than failing in production.
#[test]
fn two_combos_at_default_retries_exceed_the_lease() {
let lease_ms = JOB_LEASE_SECS * 1_000;
fn two_combos_at_default_retries_exceed_the_budget() {
let lease_ms = DEFAULT_MAX_CHECK_BUDGET_SECS * 1_000;
assert_eq!(
worst_case_run_ms(DEFAULT_JOURNEY_BUDGET_MS, 2, 1, 5),
1_210_000
@ -3363,12 +3601,12 @@ mod tests {
let mut s = valid_browser_synthetic();
s.retries = 1;
s.wait_before_retry_secs = 0;
// Exactly 2 * 450s = 900s — equal to the lease, which is permitted.
s.config["journey_budget_ms"] = serde_json::json!(450_000);
// Exactly 2 * 420s = 840s — equal to the check budget, which is permitted.
s.config["journey_budget_ms"] = serde_json::json!(420_000);
assert!(s.validate(&locs, &brs, &devs, true).is_ok());
// One millisecond more per attempt tips it over.
s.config["journey_budget_ms"] = serde_json::json!(450_001);
s.config["journey_budget_ms"] = serde_json::json!(420_001);
assert!(s.validate(&locs, &brs, &devs, true).is_err());
}
@ -3408,7 +3646,7 @@ mod tests {
fn test_validate_http_assertion_field_and_operator() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.monitor_type = SyntheticType::Http;
s.check_type = SyntheticType::Http;
s.target = "https://example.com/".to_string();
s.config = serde_json::json!({
"method": "GET",
@ -3435,7 +3673,7 @@ mod tests {
fn test_validate_ssh_config_fields() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.monitor_type = SyntheticType::Ssh;
s.check_type = SyntheticType::Ssh;
s.target = "test.rebex.net:22".to_string();
s.config = serde_json::json!({
@ -3618,9 +3856,9 @@ mod tests {
fn test_validate_config_shape_mismatch() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.monitor_type = SyntheticType::Tcp;
s.check_type = SyntheticType::Tcp;
s.target = "example.com:443".to_string();
// browser-shaped config on a tcp monitor → port missing → shape error
// browser-shaped config on a tcp check → port missing → shape error
let err = s.validate(&locs, &brs, &devs, true).unwrap_err();
assert!(err.contains("not a valid tcp config"), "{err}");
}
@ -3641,7 +3879,7 @@ mod tests {
fn test_validate_http_ok() {
let (locs, brs, devs) = allowed();
let mut s = valid_browser_synthetic();
s.monitor_type = SyntheticType::Http;
s.check_type = SyntheticType::Http;
s.config = serde_json::json!({ "method": "GET" });
assert!(s.validate(&locs, &brs, &devs, true).is_ok());
}

View File

@ -23,9 +23,9 @@
#[cfg(feature = "enterprise")]
pub struct CheckNotification {
pub org_id: String,
pub monitor_name: String,
pub monitor_id: String,
pub monitor_type: String,
pub check_name: String,
pub check_id: String,
pub check_type: String,
pub target: String,
pub destinations: Vec<String>,
pub run_id: String,
@ -62,6 +62,14 @@ pub struct CheckNotification {
/// region could only say "the check is failing" and the reader had to open
/// the UI to find out where.
pub failing_locations: Vec<String>,
/// Locations that passed, alphabetical.
///
/// Carried because `failing_locations` is empty on a recovery **by
/// definition** — nothing is failing — which left the recovery message
/// unable to name anything and degrading to a bare count. With both sides
/// a partial recovery is expressible too: "2 of 3 recovered, the third is
/// still down".
pub passing_locations: Vec<String>,
}
/// Fires once per run (when all jobs have completed) for non-passing runs.
@ -100,10 +108,7 @@ pub async fn notify_check_result(n: CheckNotification) {
};
let subject = if n.recovery {
format!(
"[OpenObserve Synthetics] ✅ {} has RECOVERED",
n.monitor_name
)
format!("[OpenObserve Synthetics] ✅ {} has RECOVERED", n.check_name)
} else if n.degraded {
// Not "is WARNING": the point of the message is that this needs
// action before it becomes an outage. Named where we know the
@ -113,21 +118,21 @@ pub async fn notify_check_result(n: CheckNotification) {
match n.status_reason.as_deref() {
Some("cert_expiring") => format!(
"[OpenObserve Synthetics] 🟡 {} — CERTIFICATE EXPIRING SOON",
n.monitor_name
n.check_name
),
Some("sftp_degraded") => format!(
"[OpenObserve Synthetics] 🟡 {} — SFTP DEGRADED",
n.monitor_name
n.check_name
),
_ => format!("[OpenObserve Synthetics] 🟡 {} is DEGRADED", n.monitor_name),
_ => format!("[OpenObserve Synthetics] 🟡 {} is DEGRADED", n.check_name),
}
} else if n.flaky {
format!("[OpenObserve Synthetics] 🔁 {} is FLAKY", n.monitor_name)
format!("[OpenObserve Synthetics] 🔁 {} is FLAKY", n.check_name)
} else {
format!(
"[OpenObserve Synthetics] {} {} is {}",
status_emoji(&n.status),
n.monitor_name,
status_emoji(&n),
n.check_name,
n.status.to_uppercase()
)
};
@ -136,8 +141,8 @@ pub async fn notify_check_result(n: CheckNotification) {
.await
{
log::error!(
"[synthetics] notify dest={dest_name} monitor={}: {e}",
n.monitor_id
"[synthetics] notify dest={dest_name} check={}: {e}",
n.check_id
);
}
}
@ -149,10 +154,33 @@ pub async fn notify_check_result(n: CheckNotification) {
}
#[cfg(feature = "enterprise")]
fn status_emoji(status: &str) -> &'static str {
match status {
"recovered" => "",
"failed" | "down" => "🔴",
/// Emoji for a notification, branching on the **same flags** as
/// [`status_headline`] and in the same order.
///
/// This used to take `&str` and branch on `status` alone, which put a 🔴 on
/// every recovery: `status_headline` reads the `recovery` bool, but on a
/// recovery run `status` is `"passed"` — the run genuinely did pass — so the
/// emoji fell through to the catch-all. The headline said "has recovered" next
/// to an outage marker, and in a busy channel that reads as a second outage.
///
/// The old `"recovered" => "✅"` arm was unreachable: nothing sets `status` to
/// that literal. `AlertDecision::Recovered` becomes the `recovery` bool at the
/// ack and never round-trips into the status string.
///
/// Taking the whole notification is what keeps the two in step — a future
/// branch added to the headline is a compile-visible omission here, rather than
/// a silently wrong glyph.
fn status_emoji(n: &CheckNotification) -> &'static str {
if n.recovery {
return "";
}
if n.degraded {
return "🟡";
}
if n.flaky {
return "🔁";
}
match n.status.as_str() {
"warning" => "🟡",
"error" => "⚠️",
_ => "🔴",
@ -163,7 +191,7 @@ fn status_emoji(status: &str) -> &'static str {
#[cfg(feature = "enterprise")]
fn status_headline(n: &CheckNotification) -> String {
if n.recovery {
return format!("{} has recovered", n.monitor_name);
return format!("{} has recovered", n.check_name);
}
// `warning` covers two unrelated things, and they need opposite responses:
// a flaky run already fixed itself, a degrading target will not.
@ -175,31 +203,31 @@ fn status_headline(n: &CheckNotification) -> String {
return match n.status_reason.as_deref() {
Some("cert_expiring") => format!(
"{} — the TLS certificate is expiring soon, renew it before it lapses",
n.monitor_name
n.check_name
),
Some("sftp_degraded") => format!(
"{} connects and authenticates, but its SFTP subsystem is failing",
n.monitor_name
n.check_name
),
_ => format!(
"{} is reachable but degrading — this needs attention before it fails",
n.monitor_name
n.check_name
),
};
}
if n.flaky {
return format!(
"{} passed only after retries (flaky) — it recovered on its own",
n.monitor_name
n.check_name
);
}
match n.status.as_str() {
"warning" => format!("{} passed only after retries (flaky)", n.monitor_name),
"warning" => format!("{} passed only after retries (flaky)", n.check_name),
"error" => format!(
"{} could not be checked — probe infrastructure error",
n.monitor_name
n.check_name
),
_ => format!("{} is failing", n.monitor_name),
_ => format!("{} is failing", n.check_name),
}
}
@ -219,7 +247,7 @@ fn checked_at_utc(checked_at_micros: i64) -> String {
.unwrap_or_else(|| checked_at_micros.to_string())
}
/// Deep link to the monitor's results page in the UI.
/// Deep link to the check's results page in the UI.
#[cfg(feature = "enterprise")]
fn run_url(n: &CheckNotification) -> String {
let cfg = config::get_config();
@ -227,7 +255,7 @@ fn run_url(n: &CheckNotification) -> String {
let base_uri = &cfg.common.base_uri;
format!(
"{web_url}{base_uri}/web/synthetic/{}/results?org_identifier={}",
n.monitor_id, n.org_id
n.check_id, n.org_id
)
}
@ -239,6 +267,36 @@ fn run_url(n: &CheckNotification) -> String {
/// six-of-six outage read identically.
fn locations_line(n: &CheckNotification) -> String {
let total = if n.job_count > 0 { n.job_count } else { 1 };
// On a recovery the interesting set is what came back, not what is broken —
// and `failing_locations` is empty by definition, which is what used to make
// this degrade to a bare count and tell the reader nothing.
if n.recovery {
return match (
n.passing_locations.is_empty(),
n.failing_locations.is_empty(),
) {
// Nothing to name at all — an older ack, or the query failed.
(true, _) => total.to_string(),
// Full recovery.
(false, true) => format!(
"{} of {} recovered: {}",
n.passing_locations.len(),
total,
n.passing_locations.join(", ")
),
// Partial: some came back, some did not. Naming both is the whole
// point — "2 of 3 recovered" alone would read as an all-clear.
(false, false) => format!(
"{} of {} recovered: {} — still failing: {}",
n.passing_locations.len(),
total,
n.passing_locations.join(", "),
n.failing_locations.join(", ")
),
};
}
if n.failing_locations.is_empty() {
return total.to_string();
}
@ -256,9 +314,9 @@ fn locations_line(n: &CheckNotification) -> String {
fn build_slack_json(n: &CheckNotification) -> String {
let checked_secs = n.checked_at / 1_000_000;
let mut lines = vec![
format!("{} *{}*", status_emoji(&n.status), status_headline(n)),
format!("{} *{}*", status_emoji(n), status_headline(n)),
String::new(),
format!("*Monitor:* {} ({})", n.monitor_name, n.monitor_type),
format!("*Check:* {} ({})", n.check_name, n.check_type),
format!("*Target:* {}", n.target),
format!("*Locations:* {}", locations_line(n)),
];
@ -278,7 +336,7 @@ fn build_slack_json(n: &CheckNotification) -> String {
fn build_plain_text(n: &CheckNotification) -> String {
let mut lines = vec![
status_headline(n),
format!("Monitor: {} ({})", n.monitor_name, n.monitor_type),
format!("Check: {} ({})", n.check_name, n.check_type),
format!("Target: {}", n.target),
format!("Status: {}", n.status),
format!("Locations: {}", locations_line(n)),
@ -316,7 +374,7 @@ fn build_email_html(n: &CheckNotification) -> String {
r#"<div style="font-family:sans-serif;max-width:560px;">
<h2 style="color:{color};margin-bottom:4px;">{emoji} {headline}</h2>
<table style="border-collapse:collapse;background:#f7f7f7;border-radius:6px;width:100%;">
<tr><td style="padding:6px 12px;color:#666;width:140px;">Monitor</td>
<tr><td style="padding:6px 12px;color:#666;width:140px;">Check</td>
<td style="padding:6px 12px;">{name} ({mtype})</td></tr>
<tr><td style="padding:6px 12px;color:#666;">Target</td>
<td style="padding:6px 12px;">{target}</td></tr>
@ -332,10 +390,10 @@ fn build_email_html(n: &CheckNotification) -> String {
<a href="{url}" style="background:{color};color:#fff;padding:8px 16px;border-radius:4px;text-decoration:none;">View run details</a>
</p>
</div>"#,
emoji = status_emoji(&n.status),
emoji = status_emoji(n),
headline = html_escape(&status_headline(n)),
name = html_escape(&n.monitor_name),
mtype = html_escape(&n.monitor_type),
name = html_escape(&n.check_name),
mtype = html_escape(&n.check_type),
target = html_escape(&n.target),
status = n.status.to_uppercase(),
jobs = html_escape(&locations_line(n)),
@ -362,9 +420,6 @@ fn html_escape(s: &str) -> String {
/// Never-registered locations count as pending, not down.
#[cfg(feature = "enterprise")]
pub async fn location_staleness_watcher() {
use std::collections::HashSet;
let mut notified_down: HashSet<String> = HashSet::new();
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
@ -390,33 +445,46 @@ pub async fn location_staleness_watcher() {
.await
.unwrap_or_default();
if !loc.enabled || agents.is_empty() {
notified_down.remove(&loc.id);
clear_down(&loc.id).await;
continue;
}
let any_live = agents.iter().any(|a| now - a.last_seen_at <= window_us);
if any_live {
notified_down.remove(&loc.id);
continue;
}
if notified_down.contains(&loc.id) {
clear_down(&loc.id).await;
continue;
}
let conn = infra::db::ORM_CLIENT
.get_or_init(infra::db::connect_to_orm)
.await;
let checks = infra::table::synthetics_monitors::list_referencing_location(
conn, &org_id, &loc.id,
)
.await
.unwrap_or_default();
let checks =
infra::table::synthetics_checks::list_referencing_location(conn, &org_id, &loc.id)
.await
.unwrap_or_default();
if checks.is_empty() {
// Nothing runs here — stay quiet, re-evaluate next tick.
continue;
}
// Mark before dispatch so a location without destinations is still
// one-shot (no per-tick log spam / retry storm).
notified_down.insert(loc.id.clone());
// Claim before dispatch so a location without destinations is still
// one-shot (no per-tick log spam / retry storm), AND so that only one
// alert_manager speaks. This watcher runs on every alert_manager, so
// the suppression flag cannot live in this process's memory — N nodes
// would each believe they had not notified yet and send N pages for
// one outage. The CAS in `try_claim_down_notification` makes exactly
// one node the winner.
match infra::table::synthetics_locations::try_claim_down_notification(&loc.id, now)
.await
{
Ok(true) => {}
Ok(false) => continue, // another node is sending it
Err(e) => {
log::error!(
"[synthetics] staleness watcher: claim down notification for {}: {e}",
loc.id
);
continue;
}
}
let mut destinations: Vec<String> =
checks.iter().flat_map(|c| c.destinations.clone()).collect();
@ -471,6 +539,164 @@ mod tests {
fn checked_at_utc_falls_back_to_the_raw_value_when_out_of_range() {
assert_eq!(checked_at_utc(i64::MAX), i64::MAX.to_string());
}
/// A firing notification for a 3-location check, two of them broken.
fn firing() -> CheckNotification {
CheckNotification {
org_id: "default".into(),
check_name: "EU1 Cloud Health Check".into(),
check_id: "abc123".into(),
check_type: "http".into(),
target: "https://example.com".into(),
destinations: vec![],
run_id: "run1".into(),
status: "failed".into(),
job_count: 3,
error: None,
checked_at: 1_785_000_000_000_000,
recovery: false,
consecutive_failures: 3,
flaky: false,
status_reason: None,
degraded: false,
failing_locations: vec!["aws-us-east-1".into(), "aws-us-west-1".into()],
passing_locations: vec!["aws-eu-central-1".into()],
}
}
/// The same check recovering: status is "passed", nothing is failing.
fn recovered() -> CheckNotification {
CheckNotification {
status: "passed".into(),
recovery: true,
consecutive_failures: 0,
failing_locations: vec![],
passing_locations: vec![
"aws-eu-central-1".into(),
"aws-us-east-1".into(),
"aws-us-west-1".into(),
],
..firing()
}
}
// ── 2324-a · the emoji must agree with the headline ────────────────────
#[test]
fn recovery_is_not_marked_as_an_outage() {
// The bug: status is "passed" on a recovery, so branching on the status
// string fell through to the 🔴 catch-all while the headline said
// "has recovered". In a busy channel that reads as a second outage.
let n = recovered();
assert_eq!(status_emoji(&n), "");
assert!(status_headline(&n).contains("has recovered"));
}
#[test]
fn emoji_and_headline_agree_on_every_branch() {
// The two are only correct together; this is the invariant the old
// signature could not express.
let cases: Vec<(CheckNotification, &str, &str)> = vec![
(recovered(), "", "has recovered"),
(
CheckNotification {
degraded: true,
status: "warning".into(),
status_reason: Some("cert_expiring".into()),
..firing()
},
"🟡",
"certificate is expiring",
),
(
CheckNotification {
flaky: true,
status: "warning".into(),
..firing()
},
"🔁",
"flaky",
),
(firing(), "🔴", "is failing"),
(
CheckNotification {
status: "error".into(),
..firing()
},
"⚠️",
"could not be checked",
),
];
for (n, emoji, headline_fragment) in cases {
assert_eq!(status_emoji(&n), emoji, "status={}", n.status);
assert!(
status_headline(&n).contains(headline_fragment),
"status={} headline={}",
n.status,
status_headline(&n)
);
}
}
#[test]
fn degraded_outranks_flaky_exactly_as_the_headline_does() {
// Both arrive as `warning`. The order matters: a degrading target needs
// action, a flaky one already fixed itself.
let n = CheckNotification {
degraded: true,
flaky: true,
status: "warning".into(),
..firing()
};
assert_eq!(status_emoji(&n), "🟡");
}
// ── 2324-b · a recovery must be able to name its locations ─────────────
#[test]
fn full_recovery_names_the_locations_that_came_back() {
// The bug: failing_locations is empty by definition on a recovery, so
// this used to render the bare count "3".
let line = locations_line(&recovered());
assert!(line.contains("3 of 3 recovered"), "{line}");
assert!(line.contains("aws-us-east-1"), "{line}");
}
#[test]
fn partial_recovery_names_both_sides() {
// "2 of 3 recovered" alone would read as an all-clear.
let n = CheckNotification {
recovery: true,
status: "passed".into(),
passing_locations: vec!["aws-us-east-1".into(), "aws-us-west-1".into()],
failing_locations: vec!["aws-eu-central-1".into()],
..firing()
};
let line = locations_line(&n);
assert!(line.contains("2 of 3 recovered"), "{line}");
assert!(line.contains("still failing: aws-eu-central-1"), "{line}");
}
#[test]
fn recovery_with_no_location_data_falls_back_to_the_count() {
// An older ack, or the query failed. Better a bare count than a lie.
let n = CheckNotification {
recovery: true,
passing_locations: vec![],
failing_locations: vec![],
..recovered()
};
assert_eq!(locations_line(&n), "3");
}
#[test]
fn firing_still_names_only_what_is_broken() {
// The passing set exists now, but a firing message must not list it —
// the reader wants the outage, not the healthy regions.
let line = locations_line(&firing());
assert!(line.starts_with("2 of 3: "), "{line}");
assert!(!line.contains("aws-eu-central-1"), "{line}");
}
}
/// Sends the "location down" notification to each destination, matching the
@ -535,3 +761,14 @@ async fn notify_location_down(
}
}
}
/// Clears a location's down flag so a future outage notifies again.
///
/// Every alert_manager calls this on recovery; the underlying update is
/// idempotent, so they cannot disagree.
#[cfg(feature = "enterprise")]
async fn clear_down(location_id: &str) {
if let Err(e) = infra::table::synthetics_locations::clear_down_notification(location_id).await {
log::error!("[synthetics] staleness watcher: clear down flag for {location_id}: {e}");
}
}

View File

@ -291,7 +291,7 @@ pub async fn delete_folder(
folder_id: Some(folder_pk),
..Default::default()
};
if table::synthetics_monitors::count(client, org_id, &params).await? > 0 {
if table::synthetics_checks::count(client, org_id, &params).await? > 0 {
return Err(FolderError::DeleteWithSynthetics);
}
}

View File

@ -158,6 +158,11 @@ pub async fn watch() -> Result<(), anyhow::Error> {
let item_key = ev.key.strip_prefix(key).unwrap();
let parts: Vec<&str> = item_key.splitn(2, '/').collect();
if parts.len() == 2 {
// Also drop the org -> default-token pick, which synthetics
// reads once per job. Reusing this existing watch means a
// rotate or disable on another node lands here without a
// second event stream.
org_ingestion_tokens::invalidate_default_cache(parts[0]);
// find_enabled_token only returns enabled tokens.
// If found → token is enabled → cache it.
// If not found → token is disabled/missing → remove from cache.
@ -172,6 +177,9 @@ pub async fn watch() -> Result<(), anyhow::Error> {
}
db::Event::Delete(ev) => {
let item_key = ev.key.strip_prefix(key).unwrap();
if let Some((org_id, _)) = item_key.split_once('/') {
org_ingestion_tokens::invalidate_default_cache(org_id);
}
ORG_INGESTION_TOKENS.remove(item_key);
}
db::Event::Empty => {}

View File

@ -22,6 +22,7 @@ pub mod model_pricing;
pub mod org_status;
pub mod pipelines;
pub mod service_streams;
pub mod synthetics;
pub mod system_settings;
pub async fn get_coordinator() -> &'static Box<dyn crate::db::Db> {

View File

@ -0,0 +1,205 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Cluster-coordinator events for the synthetics in-memory caches.
//!
//! Each node keeps its own copy of the synthetics configuration caches
//! (`table::synthetics_checks`, `synthetics_locations`, `synthetics_probe_tokens`,
//! `synthetics_agents`). A write only clears the cache on the node that
//! performed it, so without a cross-node signal every other node keeps serving
//! the old value until its TTL expires.
//!
//! This module is that signal, modelled on [`super::alerts`] and
//! [`super::pipelines`]: writers call an `emit_*` function after committing to
//! the database, and every node runs [`watch`], which invalidates the matching
//! cache entry the moment the event arrives.
//!
//! # Key layout
//!
//! One watch prefix, with a `kind` segment so a single watcher serves all four
//! caches:
//!
//! ```text
//! /synthetics/check/{org_id}/{synthetics_id} one check definition changed
//! /synthetics/location/all the location registry changed
//! /synthetics/token/{org_id} an org's probe tokens changed
//! /synthetics/agent/{agent_id} one agent re-registered
//! ```
//!
//! `location` and `token` events are coarse on purpose. Both back whole-set
//! caches that are cheap to rebuild (tens of rows), so naming the exact row
//! would add parsing for no benefit.
//!
//! # Why the handler lives here rather than being passed in
//!
//! [`super::alerts::watch_events`] takes callbacks because its handler lives in
//! the `db` crate, which `infra` cannot reference. The synthetics caches are in
//! `infra::table`, the same crate as this module, so [`watch`] calls the
//! invalidation functions directly — same pattern, one less layer of
//! indirection.
use crate::{db::Event, errors::Error};
pub const SYNTHETICS_WATCHER_PREFIX: &str = "/synthetics/";
const KIND_CHECK: &str = "check";
const KIND_LOCATION: &str = "location";
const KIND_TOKEN: &str = "token";
const KIND_AGENT: &str = "agent";
/// A check definition was created or updated.
pub async fn emit_check_put(org_id: &str, synthetics_id: &str) -> Result<(), Error> {
emit_put(&check_key(org_id, synthetics_id)).await
}
/// A check definition was deleted.
pub async fn emit_check_delete(org_id: &str, synthetics_id: &str) -> Result<(), Error> {
emit_delete(&check_key(org_id, synthetics_id)).await
}
/// The location registry changed (add / update / remove).
pub async fn emit_locations_changed() -> Result<(), Error> {
emit_put(&format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_LOCATION}/all")).await
}
/// An org's probe tokens changed — created, enabled/disabled, or default moved.
///
/// Disabling a token is revocation, so this is the event whose delivery latency
/// is the fleet-wide revocation window.
pub async fn emit_tokens_changed(org_id: &str) -> Result<(), Error> {
emit_put(&format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_TOKEN}/{org_id}")).await
}
/// An agent re-registered, so its capabilities may have changed.
pub async fn emit_agent_changed(agent_id: &str) -> Result<(), Error> {
emit_put(&format!(
"{SYNTHETICS_WATCHER_PREFIX}{KIND_AGENT}/{agent_id}"
))
.await
}
async fn emit_put(key: &str) -> Result<(), Error> {
let cluster_coordinator = super::get_coordinator().await;
cluster_coordinator
.put(key, bytes::Bytes::from(""), true, None)
.await?;
Ok(())
}
async fn emit_delete(key: &str) -> Result<(), Error> {
let cluster_coordinator = super::get_coordinator().await;
cluster_coordinator.delete(key, false, true, None).await
}
fn check_key(org_id: &str, synthetics_id: &str) -> String {
format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_CHECK}/{org_id}/{synthetics_id}")
}
/// Watches synthetics events and invalidates the matching local cache.
///
/// Spawned once per node at startup. Both `Put` and `Delete` invalidate — the
/// caches hold no value from the event itself, so the two cases are the same
/// action, and a deleted check must stop being served just as surely as an
/// edited one.
pub async fn watch() -> Result<(), anyhow::Error> {
let cluster_coordinator = super::get_coordinator().await;
let mut events = cluster_coordinator.watch(SYNTHETICS_WATCHER_PREFIX).await?;
let events = std::sync::Arc::get_mut(&mut events).unwrap();
log::info!("Start watching synthetics cache events");
loop {
let ev = match events.recv().await {
Some(ev) => ev,
None => {
log::error!("watch_synthetics: event channel closed");
break;
}
};
let key = match &ev {
Event::Put(e) => e.key.clone(),
Event::Delete(e) => e.key.clone(),
Event::Empty => continue,
};
apply(&key).await;
}
Ok(())
}
/// Applies one event key to the local caches. Unknown keys are logged and
/// ignored rather than panicking — a newer node may emit a kind this build does
/// not understand yet.
async fn apply(key: &str) {
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
// parts[0] == "synthetics"
match parts.get(1).copied() {
Some(KIND_CHECK) => match (parts.get(2), parts.get(3)) {
(Some(org), Some(id)) => crate::table::synthetics_checks::invalidate_cache(org, id),
_ => log::error!("watch_synthetics: malformed check key {key}"),
},
Some(KIND_LOCATION) => crate::table::synthetics_locations::invalidate_cache().await,
Some(KIND_TOKEN) => crate::table::synthetics_probe_tokens::invalidate_cache(),
Some(KIND_AGENT) => match parts.get(2) {
Some(agent_id) => crate::table::synthetics_agents::invalidate_cache(agent_id),
None => log::error!("watch_synthetics: malformed agent key {key}"),
},
other => log::debug!("watch_synthetics: ignoring unknown event kind {other:?} ({key})"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_key_format() {
assert_eq!(
check_key("myorg", "abc123"),
"/synthetics/check/myorg/abc123"
);
}
#[test]
fn test_watch_prefix_covers_every_kind() {
// Every emitted key must sort under the single watch prefix, or the
// watcher silently never sees it.
for k in [
check_key("o", "i"),
format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_LOCATION}/all"),
format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_TOKEN}/o"),
format!("{SYNTHETICS_WATCHER_PREFIX}{KIND_AGENT}/a"),
] {
assert!(k.starts_with(SYNTHETICS_WATCHER_PREFIX), "{k}");
}
}
#[test]
fn test_key_parses_into_kind_and_ids() {
let key = check_key("myorg", "abc123");
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
assert_eq!(parts[0], "synthetics");
assert_eq!(parts[1], KIND_CHECK);
assert_eq!(parts[2], "myorg");
assert_eq!(parts[3], "abc123");
}
#[test]
fn test_org_and_id_with_unusual_chars_still_parse() {
// Org ids and ksuids are alphanumeric today; this guards the parse
// against a future id format that is merely long or mixed-case.
let key = check_key("Org-With_Dash", "3HGpH7OjyQHnknWNzXNIDXeF6zi");
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
assert_eq!(parts[2], "Org-With_Dash");
assert_eq!(parts[3], "3HGpH7OjyQHnknWNzXNIDXeF6zi");
}
}

View File

@ -55,9 +55,9 @@ pub mod slo_status;
pub mod slos;
pub mod source_maps;
pub mod synthetics_agents;
pub mod synthetics_checks;
pub mod synthetics_jobs;
pub mod synthetics_locations;
pub mod synthetics_monitors;
pub mod synthetics_probe_tokens;
pub mod synthetics_runs;
pub mod system_prompts;

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity for synthetics_monitors table.
//! `SeaORM` Entity for synthetics_checks table.
use sea_orm::entity::prelude::*;
@ -16,19 +16,19 @@ pub struct Model {
pub description: String,
pub tags: Json,
pub config: Json,
/// Serialized `MonitorFrequency` — replaces interval_secs / frequency_type / cron_expr.
/// Serialized `SyntheticFrequency` — replaces interval_secs / frequency_type / cron_expr.
pub frequency: Json,
pub locations: Json,
pub enabled: bool,
pub destinations: Json,
/// Extra monitor settings (retries, cooldown, rum toggles). No secrets here.
/// Extra check settings (retries, cooldown, rum toggles). No secrets here.
pub settings: Json,
/// JSON blob: { "auth": {...}, "cookies": [...], "variables": [...] }
/// All secret values encrypted per-field with AESenc:<base64> using the org DEK.
pub secrets: String,
/// Pre-computed next fire time (microseconds). 0 = fire on next scheduler tick.
pub next_run_at: i64,
/// When the scheduler last fanned out this monitor (microseconds).
/// When the scheduler last fanned out this check (microseconds).
pub last_triggered_at: i64,
/// Denormalised status from the most recent completed check.
/// 0=Unknown, 1=Up, 2=Warning, 3=Down
@ -72,7 +72,7 @@ mod tests {
name: "Login Flow".to_string(),
synthetics_type: "browser".to_string(),
target: "https://app.example.com".to_string(),
description: "Monitors the login flow".to_string(),
description: "Checks the login flow".to_string(),
tags: serde_json::json!(["prod", "checkout"]),
config: serde_json::json!({"browser_devices": [{"browser": "chromium", "device": "desktop"}], "steps": []}),
frequency: serde_json::json!({"type": "minutes", "interval": 5, "cron": ""}),

View File

@ -25,9 +25,9 @@ pub struct Model {
pub dispatch_attempts: i32,
/// KSUID of the parent run (all jobs for one scheduled slot share this).
pub run_id: String,
/// JSON array of BrowserDevice {execution_id, engine, device} — browser monitors only.
/// JSON array of BrowserDevice {execution_id, engine, device} — browser checks only.
pub browser_devices: Option<String>,
/// JSON blob of monitor metadata copied at enqueue time e.g. {"tags": ["prod"]}.
/// JSON blob of check metadata copied at enqueue time e.g. {"tags": ["prod"]}.
pub metadata: String,
/// JSON execution summaries written at ack time (no full step data — that's in the stream).
pub result: Option<String>,

View File

@ -31,6 +31,14 @@ pub struct Model {
/// Queue routing key (unique), e.g. `net-aws-us-east-1`, `private-acme-dc1`.
pub pool: String,
pub enabled: bool,
/// When this location's "down" notification was sent, in microseconds.
/// 0 = not currently notified as down.
///
/// Cluster-wide one-shot state. The staleness watcher runs on every
/// alert_manager, so the suppression flag cannot live in process memory —
/// N nodes would each send their own notification for one outage.
#[sea_orm(default_value = 0)]
pub down_notified_at: i64,
pub created_at: i64,
pub updated_at: i64,
}

View File

@ -0,0 +1,85 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Make the "location down" one-shot cluster-wide instead of per-process.
//!
//! The staleness watcher runs on every alert_manager node and suppressed repeat
//! notifications with an in-memory `HashSet<String>`. That set is process-local,
//! so N alert managers sent N notifications for the same outage — and did it
//! again on every down → recover → down cycle.
//!
//! One column, defaulted, so existing rows need no backfill:
//! - `down_notified_at` — when this location's "down" notification was sent, in microseconds. 0
//! means "not currently notified as down", which is also the correct value for every existing
//! row: a location that is genuinely down will be re-detected on the next 60 s tick and
//! notified once.
//!
//! The column is claimed with a compare-and-swap (`WHERE down_notified_at = 0`)
//! rather than read-then-write, so exactly one node sends the notification —
//! the same primitive `synthetics_jobs::lease_batch` and
//! `synthetics_checks::try_claim_slot` use.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
const TABLE: &str = "synthetics_locations";
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// On a fresh database the table is created from the entity definition,
// so the column already exists by the time this runs. SQLite ignores the
// `IF NOT EXISTS` guard on `ADD COLUMN`, so guard explicitly with
// `has_column` to stay idempotent across backends.
if !manager.has_column(TABLE, "down_notified_at").await? {
manager
.alter_table(
Table::alter()
.table(SyntheticsLocations::Table)
.add_column_if_not_exists(
ColumnDef::new(SyntheticsLocations::DownNotifiedAt)
.big_integer()
.not_null()
.default(0i64),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
if manager.has_column(TABLE, "down_notified_at").await? {
manager
.alter_table(
Table::alter()
.table(SyntheticsLocations::Table)
.drop_column(SyntheticsLocations::DownNotifiedAt)
.to_owned(),
)
.await?;
}
Ok(())
}
}
#[derive(DeriveIden)]
enum SyntheticsLocations {
Table,
DownNotifiedAt,
}

View File

@ -154,6 +154,7 @@ mod m20260730_000001_add_alert_state_to_synthetics_monitors;
mod m20260730_000002_create_incident_integrations;
mod m20260730_000003_create_external_alerts;
mod m20260730_000004_add_alert_kind_to_incident_alerts;
mod m20260803_000001_add_down_notified_at_to_synthetics_locations;
/// Apply **only** the SLO tables, for targeted integration tests.
///
@ -315,6 +316,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260730_000002_create_incident_integrations::Migration),
Box::new(m20260730_000003_create_external_alerts::Migration),
Box::new(m20260730_000004_add_alert_kind_to_incident_alerts::Migration),
Box::new(m20260803_000001_add_down_notified_at_to_synthetics_locations::Migration),
]
}
}

View File

@ -70,9 +70,9 @@ pub mod slo_budget;
pub mod slos;
pub mod source_maps;
pub mod synthetics_agents;
pub mod synthetics_checks;
pub mod synthetics_jobs;
pub mod synthetics_locations;
pub mod synthetics_monitors;
pub mod synthetics_probe_tokens;
pub mod synthetics_runs;
pub mod system_prompts;

View File

@ -13,6 +13,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use config::RwHashMap;
use sea_orm::{
ColumnTrait, EntityTrait, FromQueryResult, Order, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set, entity::prelude::*, sea_query::OnConflict,
@ -30,6 +36,67 @@ use crate::{
pub const ORG_INGESTION_TOKEN_PREFIX: &str = "o2oi_";
/// `org_id` → its default enabled ingest token, with the time it was loaded.
///
/// Backs [`find_default_enabled`]. Synthetics needs this on every dispatch, on
/// every job resolve and in the reaper's dead-letter path, i.e. once per job —
/// all for a value that changes only when an operator adds, rotates or disables
/// a token.
///
/// Note this is a *different lookup direction* from the existing
/// `ORG_INGESTION_TOKENS` cache in the `db` crate, which is keyed
/// `{org}/{token} → name` and answers "is this token valid". That one cannot
/// serve "what is this org's token", which is why this exists rather than
/// reusing it.
///
/// `None` is cached too, so an org with no enabled token does not re-query on
/// every job.
static DEFAULT_TOKEN_CACHE: LazyLock<
RwHashMap<String, (Option<OrgIngestionTokenRecord>, Instant)>,
> = LazyLock::new(Default::default);
/// Backstop only. Cross-node invalidation arrives through the existing
/// `/org_ingestion_tokens/` coordinator watch (see `db::org_ingestion_tokens::watch`),
/// so this TTL just bounds a dropped event.
const DEFAULT_TOKEN_CACHE_TTL: Duration = Duration::from_secs(60);
/// Drops one org's cached default token.
///
/// Called by every write path in this module, and by the coordinator watcher so
/// a rotate or disable performed on another node lands here too.
pub fn invalidate_default_cache(org_id: &str) {
DEFAULT_TOKEN_CACHE.remove(org_id);
}
/// The org's default enabled ingest token, served from cache when fresh.
///
/// Equivalent to the `list_by_org(org).find(|t| t.enabled)` that callers used to
/// write by hand — `is_default` first, then newest — but as one row instead of
/// fetching every token for the org and discarding most of them.
pub async fn find_default_enabled(
org_id: &str,
) -> Result<Option<OrgIngestionTokenRecord>, errors::Error> {
if let Some(entry) = DEFAULT_TOKEN_CACHE.get(org_id)
&& entry.1.elapsed() < DEFAULT_TOKEN_CACHE_TTL
{
return Ok(entry.0.clone());
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let record = Entity::find()
.filter(Column::OrgId.eq(org_id))
.filter(Column::Enabled.eq(true))
.order_by(Column::IsDefault, Order::Desc)
.order_by(Column::CreatedAt, Order::Desc)
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?
.map(OrgIngestionTokenRecord::from);
DEFAULT_TOKEN_CACHE.insert(org_id.to_string(), (record.clone(), Instant::now()));
Ok(record)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgIngestionTokenRecord {
pub id: String,
@ -112,7 +179,11 @@ pub async fn add(record: &OrgIngestionTokenRecord) -> Result<(), errors::Error>
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match Entity::insert(model).exec(client).await {
Ok(_) => Ok(()),
Ok(_) => {
// A new token can be the org default, so the cached pick is stale.
invalidate_default_cache(&record.org_id);
Ok(())
}
Err(e) => match e.sql_err() {
Some(SqlErr::UniqueConstraintViolation(_)) => {
Err(Error::DbError(DbError::SeaORMError(format!(
@ -163,6 +234,7 @@ pub async fn upsert(record: &OrgIngestionTokenRecord) -> Result<(), errors::Erro
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
invalidate_default_cache(&record.org_id);
Ok(())
}
@ -237,6 +309,7 @@ pub async fn rotate_token(org_id: &str, name: &str) -> Result<String, errors::Er
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
invalidate_default_cache(org_id);
Ok(new_token)
}
@ -251,6 +324,7 @@ pub async fn delete_by_org(org_id: &str) -> Result<(), errors::Error> {
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
invalidate_default_cache(org_id);
Ok(())
}
@ -269,6 +343,7 @@ pub async fn remove_by_token(org_id: &str, token: &str) -> Result<(), errors::Er
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
invalidate_default_cache(org_id);
Ok(())
}
@ -299,6 +374,7 @@ pub async fn set_enabled(org_id: &str, name: &str, enabled: bool) -> Result<(),
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
invalidate_default_cache(org_id);
Ok(())
}

View File

@ -19,6 +19,12 @@
//! `/synthetics/agent/register`; `last_seen_at` refreshed by register and by
//! every job lease. A location whose agents are all stale is reported "down".
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use config::RwHashMap;
use sea_orm::{
ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, sea_query::Expr,
};
@ -63,6 +69,37 @@ impl From<Model> for SyntheticsAgentRecord {
}
}
/// `agent_id` → the agent row, with load time.
///
/// Backs [`get_cached`] only. The lease path reads an agent once per poll purely
/// for its `capabilities`, and agents poll every 2 s — ~330 reads/min for 11
/// agents, all for a value that changes only when an agent re-registers.
///
/// # Staleness contract
///
/// `last_seen_at` is rewritten by [`touch`] on **every lease**, and `touch` does
/// not invalidate — invalidating there would defeat the cache entirely, since it
/// fires at exactly the rate of the reads being served. Anything that judges
/// agent liveness (the staleness watcher, location health) must use [`get`].
static AGENT_CACHE: LazyLock<RwHashMap<String, (SyntheticsAgentRecord, Instant)>> =
LazyLock::new(Default::default);
const AGENT_CACHE_TTL: Duration = Duration::from_secs(15);
/// Drops one agent from the cache. Called on register, where capabilities change.
pub fn invalidate_cache(agent_id: &str) {
AGENT_CACHE.remove(agent_id);
}
/// Invalidates locally **and** tells every other node. Write paths call this;
/// the coordinator watcher calls [`invalidate_cache`] so events do not echo.
async fn invalidate_and_publish(agent_id: &str) {
invalidate_cache(agent_id);
if let Err(e) = crate::coordinator::synthetics::emit_agent_changed(agent_id).await {
log::error!("[synthetics] emit agent cache event failed for {agent_id}: {e}");
}
}
/// Insert an agent row, or refresh version/capabilities/last_seen_at when the
/// id already exists (idempotent re-register after restart).
pub async fn register(record: &SyntheticsAgentRecord) -> Result<(), errors::Error> {
@ -106,10 +143,21 @@ pub async fn register(record: &SyntheticsAgentRecord) -> Result<(), errors::Erro
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
}
}
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
// Register is where capabilities change, so the cached copy is now stale.
invalidate_and_publish(&record.id).await;
Ok(())
}
/// Refresh `last_seen_at` for an agent (called on heartbeat and every lease).
///
/// Deliberately does **not** invalidate [`AGENT_CACHE`] — it fires once per
/// lease, which is exactly the rate of the reads the cache serves. Callers that
/// need a current `last_seen_at` use [`get`], not [`get_cached`].
pub async fn touch(agent_id: &str, now_us: i64) -> Result<(), errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
Entity::update_many()
@ -135,6 +183,39 @@ pub async fn list_by_location(
Ok(rows.into_iter().map(Into::into).collect())
}
/// All agents for a set of locations, grouped by `location_id`.
///
/// One query for the whole set instead of [`list_by_location`] per location —
/// the locations list endpoint iterates every visible location to compute
/// per-type availability, so the per-location form issued ~20 queries to build
/// one response. Locations with no agents are absent from the map; callers
/// should treat a missing key as an empty slice.
pub async fn list_by_locations(
location_ids: &[String],
) -> Result<std::collections::HashMap<String, Vec<SyntheticsAgentRecord>>, errors::Error> {
if location_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let rows = Entity::find()
.filter(Column::LocationId.is_in(location_ids.to_vec()))
.order_by_desc(Column::LastSeenAt)
.all(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
let mut grouped: std::collections::HashMap<String, Vec<SyntheticsAgentRecord>> =
std::collections::HashMap::new();
for row in rows {
let rec: SyntheticsAgentRecord = row.into();
grouped
.entry(rec.location_id.clone())
.or_default()
.push(rec);
}
Ok(grouped)
}
/// Find an agent by its deploy identity. Agents hold no persistent state, so
/// a restarted container re-registers with the same (org, location, name) but
/// without its previous server-issued id — this lookup lets register reuse the
@ -180,6 +261,22 @@ pub async fn count_by_token(
}
/// Find one agent by id.
/// Reads an agent from cache when fresh — see [`AGENT_CACHE`] for the staleness
/// contract. Use [`get`] when `last_seen_at` matters.
pub async fn get_cached(agent_id: &str) -> Result<Option<SyntheticsAgentRecord>, errors::Error> {
if let Some(entry) = AGENT_CACHE.get(agent_id)
&& entry.1.elapsed() < AGENT_CACHE_TTL
{
return Ok(Some(entry.0.clone()));
}
let found = get(agent_id).await?;
if let Some(rec) = &found {
AGENT_CACHE.insert(agent_id.to_string(), (rec.clone(), Instant::now()));
}
Ok(found)
}
/// Reads an agent straight from the database, `last_seen_at` included.
pub async fn get(agent_id: &str) -> Result<Option<SyntheticsAgentRecord>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let row = Entity::find_by_id(agent_id)

View File

@ -13,9 +13,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::synthetics::{
BrowserConfig, ListSyntheticsParams, Synthetic, SyntheticAuth, SyntheticCookie,
SyntheticFrequency, SyntheticSettings, SyntheticStatus, SyntheticType, SyntheticVariable,
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use config::{
RwHashMap,
meta::synthetics::{
BrowserConfig, ListSyntheticsParams, Synthetic, SyntheticAuth, SyntheticCookie,
SyntheticFrequency, SyntheticSettings, SyntheticStatus, SyntheticType, SyntheticVariable,
},
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
@ -23,16 +31,78 @@ use sea_orm::{
};
use serde::{Deserialize, Serialize};
use super::entity::synthetics_monitors::{self, ActiveModel, Column, Entity};
use super::entity::synthetics_checks::{self, ActiveModel, Column, Entity};
use crate::errors;
/// `org_id/synthetics_id` → the synthetic check **definition**, with load time.
///
/// The probe job path reads a check twice per job — once at `resolve` to build
/// the `CheckJob`, once at `ack` for its type and destinations — which is the
/// single biggest per-job read in the feature (~400/min at 100 checks). The
/// definition changes only when a user edits the check.
///
/// **This cache backs [`get_cached`] only, never [`get`].** The distinction is
/// load-bearing: `Synthetic` carries `next_run_at` and `last_check_status`
/// alongside the definition, and those are rewritten on *every run* by
/// `advance_schedule` / `update_last_check_status`. Invalidating on those would
/// make the cache useless (they fire at the same rate as the reads it serves),
/// so instead they deliberately do **not** invalidate — and callers that need
/// scheduling or status state must use [`get`], `fetch_due` or `get_alert_state`.
static SYNTHETIC_CACHE: LazyLock<RwHashMap<String, (Synthetic, Instant)>> =
LazyLock::new(Default::default);
const SYNTHETIC_CACHE_TTL: Duration = Duration::from_secs(15);
fn synthetic_cache_key(org_id: &str, id: &str) -> String {
format!("{org_id}/{id}")
}
/// Drops one check from the definition cache. Called by every path that edits
/// a definition, so an edit is visible on this node immediately.
pub fn invalidate_cache(org_id: &str, id: &str) {
SYNTHETIC_CACHE.remove(&synthetic_cache_key(org_id, id));
}
/// Drops the whole definition cache. Used where a write may touch many rows.
pub fn invalidate_all_cache() {
SYNTHETIC_CACHE.clear();
}
/// Invalidates locally **and** tells every other node to do the same.
///
/// Write paths call this; the coordinator watcher calls the plain
/// [`invalidate_cache`], which is what stops an event from echoing forever.
///
/// A failed emit is logged, not propagated: the database write has already
/// committed, and the cache TTL is the backstop for a dropped event. Failing
/// the user's save because a cache hint did not send would be the worse trade.
async fn invalidate_and_publish(org_id: &str, id: &str) {
invalidate_cache(org_id, id);
if let Err(e) = crate::coordinator::synthetics::emit_check_put(org_id, id).await {
log::error!("[synthetics] emit check cache event failed for {org_id}/{id}: {e}");
}
}
/// Same as [`invalidate_and_publish`], but emits a *delete* event.
///
/// Both events invalidate identically on the receiving side, so this is not
/// about the handler — it is about the coordinator's key store. `emit_*_put`
/// writes a key; only a delete event removes it. Publishing a put on the delete
/// path would leave one dead key per check ever created, growing without bound.
async fn invalidate_and_publish_delete(org_id: &str, id: &str) {
invalidate_cache(org_id, id);
if let Err(e) = crate::coordinator::synthetics::emit_check_delete(org_id, id).await {
log::error!("[synthetics] emit check delete event failed for {org_id}/{id}: {e}");
}
}
// ── TryFrom: ORM model → meta type ───────────────────────────────────────────
impl TryFrom<synthetics_monitors::Model> for Synthetic {
impl TryFrom<synthetics_checks::Model> for Synthetic {
type Error = errors::Error;
fn try_from(m: synthetics_monitors::Model) -> Result<Self, Self::Error> {
let monitor_type: SyntheticType = serde_json::from_value(serde_json::Value::String(
fn try_from(m: synthetics_checks::Model) -> Result<Self, Self::Error> {
let check_type: SyntheticType = serde_json::from_value(serde_json::Value::String(
m.synthetics_type.clone(),
))
.map_err(|e| {
@ -70,7 +140,7 @@ impl TryFrom<synthetics_monitors::Model> for Synthetic {
name: m.name,
description: m.description,
tags,
monitor_type,
check_type,
target: m.target,
config: m.config,
frequency,
@ -100,6 +170,11 @@ impl TryFrom<synthetics_monitors::Model> for Synthetic {
// ── Public CRUD API ───────────────────────────────────────────────────────────
/// Reads a check straight from the database. Every field is current, including
/// `next_run_at` and `last_check_status`.
///
/// Use this anywhere scheduling or status state matters. For the probe job path,
/// which only needs the definition, prefer [`get_cached`].
pub async fn get<C: ConnectionTrait>(
conn: &C,
org_id: &str,
@ -110,6 +185,41 @@ pub async fn get<C: ConnectionTrait>(
maybe.map(Synthetic::try_from).transpose()
}
/// Reads a check **definition**, served from [`SYNTHETIC_CACHE`] when fresh.
///
/// Intended for the probe job path (`resolve`, `ack`), which needs `config`,
/// `check_type`, `target`, `destinations` and the retry settings — all of
/// which change only on a user edit.
///
/// # Staleness contract
///
/// - **Definition fields are correct**, within `SYNTHETIC_CACHE_TTL` of an edit made on another
/// node, and immediately for an edit made on this one.
/// - **`next_run_at` and `last_check_status` may be stale by design.** They are rewritten every run
/// and this cache is not invalidated when they change. Read them via [`get`], [`fetch_due`] or
/// [`get_alert_state`] instead.
///
/// A missing check is not cached — a resolve for a deleted check should keep
/// reaching the DB and failing loudly rather than being served from memory.
pub async fn get_cached<C: ConnectionTrait>(
conn: &C,
org_id: &str,
id: &str,
) -> Result<Option<Synthetic>, errors::Error> {
let key = synthetic_cache_key(org_id, id);
if let Some(entry) = SYNTHETIC_CACHE.get(&key)
&& entry.1.elapsed() < SYNTHETIC_CACHE_TTL
{
return Ok(Some(entry.0.clone()));
}
let found = get(conn, org_id, id).await?;
if let Some(synthetic) = &found {
SYNTHETIC_CACHE.insert(key, (synthetic.clone(), Instant::now()));
}
Ok(found)
}
pub async fn list<C: ConnectionTrait>(
conn: &C,
org_id: &str,
@ -185,26 +295,32 @@ pub async fn list_referencing_location<C: ConnectionTrait>(
pub async fn create<C: TransactionTrait>(
conn: &C,
org_id: &str,
monitor: Synthetic,
check: Synthetic,
) -> Result<Synthetic, errors::Error> {
let _lock = super::get_lock().await;
let txn = conn.begin().await?;
let now = config::utils::time::now_micros();
let id = config::ider::uuid();
let mut am = build_active_model(&monitor)?;
let mut am = build_active_model(&check)?;
am.id = Set(id);
am.org_id = Set(org_id.to_owned());
am.folder_id = Set(monitor.folder_id.clone());
am.synthetics_type = Set(monitor_type_to_str(&monitor.monitor_type).to_owned());
am.folder_id = Set(check.folder_id.clone());
am.synthetics_type = Set(check_type_to_str(&check.check_type).to_owned());
am.created_at = Set(now);
am.updated_at = Set(now);
am.next_run_at = Set(monitor.start.unwrap_or(0));
am.owner = Set(monitor.owner.clone());
am.next_run_at = Set(check.start.unwrap_or(0));
am.owner = Set(check.owner.clone());
let model = am.insert(&txn).await?.try_into_model()?;
let result = Synthetic::try_from(model)?;
txn.commit().await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(&result.org_id, &result.id).await;
Ok(result)
}
@ -212,48 +328,54 @@ pub async fn update<C: TransactionTrait>(
conn: &C,
org_id: &str,
id: &str,
monitor: Synthetic,
check: Synthetic,
) -> Result<Synthetic, errors::Error> {
let _lock = super::get_lock().await;
let txn = conn.begin().await?;
let Some(m) = get_model(&txn, org_id, id).await? else {
return Err(errors::Error::Message(format!("monitor not found: {id}")));
return Err(errors::Error::Message(format!("check not found: {id}")));
};
let mut am: ActiveModel = m.into();
update_mutable_fields(&mut am, &monitor)?;
update_mutable_fields(&mut am, &check)?;
am.updated_at = Set(config::utils::time::now_micros());
let model = am.update(&txn).await?.try_into_model()?;
let result = Synthetic::try_from(model)?;
txn.commit().await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(&result.org_id, &result.id).await;
Ok(result)
}
pub async fn put<C: TransactionTrait>(
conn: &C,
org_id: &str,
monitor: Synthetic,
check: Synthetic,
) -> Result<Synthetic, errors::Error> {
let _lock = super::get_lock().await;
let txn = conn.begin().await?;
let now = config::utils::time::now_micros();
let result = match get_model(&txn, org_id, &monitor.id).await? {
let result = match get_model(&txn, org_id, &check.id).await? {
Some(m) => {
let mut am: ActiveModel = m.into();
update_mutable_fields(&mut am, &monitor)?;
update_mutable_fields(&mut am, &check)?;
am.updated_at = Set(now);
let model = am.update(&txn).await?.try_into_model()?;
Synthetic::try_from(model)?
}
None => {
let mut am = build_active_model(&monitor)?;
am.id = Set(monitor.id.clone());
let mut am = build_active_model(&check)?;
am.id = Set(check.id.clone());
am.org_id = Set(org_id.to_owned());
am.folder_id = Set(monitor.folder_id.clone());
am.synthetics_type = Set(monitor_type_to_str(&monitor.monitor_type).to_owned());
am.folder_id = Set(check.folder_id.clone());
am.synthetics_type = Set(check_type_to_str(&check.check_type).to_owned());
am.created_at = Set(now);
am.updated_at = Set(now);
let model = am.insert(&txn).await?.try_into_model()?;
@ -262,6 +384,12 @@ pub async fn put<C: TransactionTrait>(
};
txn.commit().await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(&result.org_id, &result.id).await;
Ok(result)
}
@ -276,10 +404,16 @@ pub async fn delete<C: ConnectionTrait>(
.filter(Column::Id.eq(id))
.exec(conn)
.await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish_delete(org_id, id).await;
Ok(res.rows_affected > 0)
}
/// Moves a batch of monitors to a different folder.
/// Moves a batch of checks to a different folder.
pub async fn move_to_folder<C: ConnectionTrait>(
conn: &C,
org_id: &str,
@ -300,6 +434,14 @@ pub async fn move_to_folder<C: ConnectionTrait>(
.filter(Column::Id.is_in(ids.to_vec()))
.exec(conn)
.await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
for id in ids {
invalidate_and_publish(org_id, id).await;
}
Ok(res.rows_affected)
}
@ -321,40 +463,89 @@ pub async fn set_enabled<C: ConnectionTrait>(
.filter(Column::Id.eq(id))
.exec(conn)
.await?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(org_id, id).await;
Ok(res.rows_affected > 0)
}
// ── Scheduler helpers ─────────────────────────────────────────────────────────
/// Scheduler's fan-out data — the subset of Synthetic fields the scheduler needs.
pub struct DueMonitor {
pub struct DueCheck {
pub id: String,
pub name: String,
pub org_id: String,
pub monitor_type: SyntheticType,
pub check_type: SyntheticType,
pub locations: Vec<String>,
pub frequency: SyntheticFrequency,
/// Minutes from UTC — used for cron scheduling. 0 = UTC.
pub tz_offset: i32,
/// The scheduled due time that made this monitor eligible. The scheduler
/// The scheduled due time that made this check eligible. The scheduler
/// anchors the NEXT run to this (fixed-rate) instead of `now`, so the tick
/// lag doesn't accumulate into schedule drift.
pub next_run_at: i64,
/// Populated only for browser monitors (parsed from config.browser_devices).
/// Populated only for browser checks (parsed from config.browser_devices).
pub browser_devices: Vec<config::meta::synthetics::BrowserDevice>,
pub tags: Vec<String>,
}
/// Returns up to `limit` enabled monitors whose `next_run_at` is at or before `now_us`.
/// Returns up to `limit` enabled checks whose `next_run_at` is at or before `now_us`.
/// Ordered by next_run_at ASC so the most overdue fire first.
///
/// NOTE: Does not use FOR UPDATE SKIP LOCKED — the scheduler is single-node on alert_manager.
/// If multi-node scheduling is needed, convert to a raw SQL query with SKIP LOCKED.
impl TryFrom<synthetics_checks::Model> for DueCheck {
type Error = errors::Error;
fn try_from(m: synthetics_checks::Model) -> Result<Self, Self::Error> {
let check_type: SyntheticType = serde_json::from_value(serde_json::Value::String(
m.synthetics_type.clone(),
))
.map_err(|e| {
errors::Error::Message(format!(
"invalid synthetics_type '{}' for {}: {e}",
m.synthetics_type, m.id
))
})?;
let locations: Vec<String> = serde_json::from_value(m.locations)
.map_err(|e| errors::Error::Message(format!("invalid locations for {}: {e}", m.id)))?;
let frequency: SyntheticFrequency = serde_json::from_value(m.frequency).unwrap_or_default();
let browser_devices = if check_type == SyntheticType::Browser {
let cfg: BrowserConfig = serde_json::from_value(m.config).unwrap_or_default();
cfg.browser_devices
} else {
vec![]
};
let tags: Vec<String> = serde_json::from_value(m.tags).unwrap_or_default();
Ok(DueCheck {
id: m.id,
name: m.name,
org_id: m.org_id,
check_type,
locations,
frequency,
tz_offset: m.tz_offset,
next_run_at: m.next_run_at,
browser_devices,
tags,
})
}
}
pub async fn fetch_due<C: ConnectionTrait>(
conn: &C,
now_us: i64,
limit: u64,
) -> Result<Vec<DueMonitor>, errors::Error> {
) -> Result<Vec<DueCheck>, errors::Error> {
let _lock = super::get_lock().await;
let models = Entity::find()
.filter(Column::Enabled.eq(true))
@ -364,51 +555,85 @@ pub async fn fetch_due<C: ConnectionTrait>(
.all(conn)
.await?;
models
.into_iter()
.map(|m| {
let monitor_type: SyntheticType =
serde_json::from_value(serde_json::Value::String(m.synthetics_type.clone()))
.map_err(|e| {
errors::Error::Message(format!(
"invalid synthetics_type '{}' for {}: {e}",
m.synthetics_type, m.id
))
})?;
let locations: Vec<String> = serde_json::from_value(m.locations).map_err(|e| {
errors::Error::Message(format!("invalid locations for {}: {e}", m.id))
})?;
let frequency: SyntheticFrequency =
serde_json::from_value(m.frequency).unwrap_or_default();
let browser_devices = if monitor_type == SyntheticType::Browser {
let cfg: BrowserConfig = serde_json::from_value(m.config).unwrap_or_default();
cfg.browser_devices
} else {
vec![]
};
let tags: Vec<String> = serde_json::from_value(m.tags).unwrap_or_default();
Ok(DueMonitor {
id: m.id,
name: m.name,
org_id: m.org_id,
monitor_type,
locations,
frequency,
tz_offset: m.tz_offset,
next_run_at: m.next_run_at,
browser_devices,
tags,
})
})
.collect()
models.into_iter().map(DueCheck::try_from).collect()
}
/// Updates `last_triggered_at` and `next_run_at` after the scheduler fans out a monitor.
/// Updates `last_triggered_at` and `next_run_at` after the scheduler fans out a check.
/// Claims every due check in one pass, returning only the ones THIS node won.
///
/// This is the design's scheduler claim (`designs/synthetics/01-server-architecture.md`
/// §4.2): "Run 2+ replicas; they self-shard via `SELECT … FOR UPDATE SKIP
/// LOCKED` — no leader election."
///
/// Why locking beats the per-row compare-and-swap it replaces, at scale:
///
/// * **The nodes self-shard.** A locker skips rows another node already holds and takes *different*
/// due checks instead, so N schedulers split the backlog. Under a CAS every node reads the same
/// candidate list, one wins them all, and the losers issue one doomed UPDATE per check —
/// `FETCH_LIMIT` wasted writes per node per tick.
/// * **Losers do no writes at all.** The skip happens in the SELECT, so a node that loses a row
/// never issues its UPDATE.
///
/// The advance happens **inside** the same transaction, because the row locks
/// only exist until COMMIT — claiming and advancing separately would reopen the
/// race this closes. `next_run_for` computes each check's next slot; it is a
/// callback because that calculation (interval vs cron, tz, skip-missed) lives
/// with the scheduler, while the lock has to be held here.
///
/// Fan-out is deliberately left to the caller, *after* the commit: holding row
/// locks on user-facing config rows across N job inserts would stall any
/// concurrent edit of those checks for the duration.
///
/// SQLite has no `FOR UPDATE`, and needs none — it is single-node by
/// construction (`config.rs:3182` restricts cluster mode to Postgres), so no
/// second scheduler can exist to race with.
pub async fn claim_due<C>(
conn: &C,
now_us: i64,
limit: u64,
next_run_for: impl Fn(&DueCheck) -> i64,
) -> Result<Vec<DueCheck>, errors::Error>
where
C: ConnectionTrait + TransactionTrait,
{
let _lock = super::get_lock().await;
let txn = conn.begin().await?;
let mut query = Entity::find()
.filter(Column::Enabled.eq(true))
.filter(Column::NextRunAt.lte(now_us))
.order_by_asc(Column::NextRunAt)
.limit(limit);
if txn.get_database_backend() == sea_orm::DatabaseBackend::Postgres {
query = query.lock_with_behavior(
sea_orm::sea_query::LockType::Update,
sea_orm::sea_query::LockBehavior::SkipLocked,
);
}
let models = query.all(&txn).await?;
let due: Vec<DueCheck> = models
.into_iter()
.map(DueCheck::try_from)
.collect::<Result<Vec<_>, _>>()?;
for check in &due {
Entity::update_many()
.col_expr(Column::LastTriggeredAt, Expr::value(now_us))
.col_expr(Column::NextRunAt, Expr::value(next_run_for(check)))
.filter(Column::Id.eq(check.id.as_str()))
.exec(&txn)
.await?;
}
txn.commit().await?;
Ok(due)
}
/// Unconditionally sets a check's schedule. For user-initiated changes (edit,
/// disable, run-now), where the user's action must always win.
///
/// **Schedulers must not use this** — see [`try_claim_slot`].
pub async fn advance_schedule<C: ConnectionTrait>(
conn: &C,
id: &str,
@ -454,7 +679,7 @@ pub struct AlertState {
pub degraded_notified_at: i64,
}
/// Reads the alert state without pulling the whole monitor (config, secrets and
/// Reads the alert state without pulling the whole check (config, secrets and
/// step definitions are several KB, and this runs on every ack).
pub async fn get_alert_state<C: ConnectionTrait>(
conn: &C,
@ -532,7 +757,7 @@ async fn get_model<C: ConnectionTrait>(
conn: &C,
org_id: &str,
id: &str,
) -> Result<Option<synthetics_monitors::Model>, sea_orm::DbErr> {
) -> Result<Option<synthetics_checks::Model>, sea_orm::DbErr> {
Entity::find_by_id(id)
.filter(Column::OrgId.eq(org_id))
.one(conn)
@ -543,7 +768,7 @@ async fn list_models<C: ConnectionTrait>(
conn: &C,
org_id: &str,
params: &ListSyntheticsParams,
) -> Result<Vec<synthetics_monitors::Model>, sea_orm::DbErr> {
) -> Result<Vec<synthetics_checks::Model>, sea_orm::DbErr> {
let q = Entity::find()
.filter(Column::OrgId.eq(org_id))
.apply_filters(params)
@ -557,15 +782,15 @@ async fn list_models<C: ConnectionTrait>(
q.all(conn).await
}
fn pack_settings(monitor: &Synthetic) -> Result<serde_json::Value, errors::Error> {
fn pack_settings(check: &Synthetic) -> Result<serde_json::Value, errors::Error> {
Ok(serde_json::to_value(SyntheticSettings {
retries: monitor.retries,
cooldown_mins: monitor.cooldown_mins,
wait_before_retry_secs: monitor.wait_before_retry_secs,
alert_if_fails: monitor.alert_if_fails,
collect_rum_data: monitor.collect_rum_data,
session_replay: monitor.session_replay,
start: monitor.start,
retries: check.retries,
cooldown_mins: check.cooldown_mins,
wait_before_retry_secs: check.wait_before_retry_secs,
alert_if_fails: check.alert_if_fails,
collect_rum_data: check.collect_rum_data,
session_replay: check.session_replay,
start: check.start,
})?)
}
@ -586,61 +811,61 @@ struct StoredSecrets {
config: std::collections::BTreeMap<String, String>,
}
fn pack_secrets(monitor: &Synthetic) -> Result<String, errors::Error> {
fn pack_secrets(check: &Synthetic) -> Result<String, errors::Error> {
serde_json::to_string(&StoredSecrets {
auth: monitor.auth.clone(),
cookies: monitor.cookies.clone(),
variables: monitor.variables.clone(),
config: monitor.config_secrets.clone(),
auth: check.auth.clone(),
cookies: check.cookies.clone(),
variables: check.variables.clone(),
config: check.config_secrets.clone(),
})
.map_err(|e| errors::Error::Message(format!("secrets serialize failed: {e}")))
}
fn update_mutable_fields(am: &mut ActiveModel, monitor: &Synthetic) -> Result<(), errors::Error> {
let locations = serde_json::to_value(&monitor.locations)?;
let destinations = serde_json::to_value(&monitor.destinations)?;
let tags = serde_json::to_value(&monitor.tags)?;
let frequency = serde_json::to_value(&monitor.frequency)?;
let settings = pack_settings(monitor)?;
am.folder_id = Set(monitor.folder_id.clone());
am.tz_offset = Set(monitor.tz_offset);
am.name = Set(monitor.name.clone());
am.description = Set(monitor.description.clone());
fn update_mutable_fields(am: &mut ActiveModel, check: &Synthetic) -> Result<(), errors::Error> {
let locations = serde_json::to_value(&check.locations)?;
let destinations = serde_json::to_value(&check.destinations)?;
let tags = serde_json::to_value(&check.tags)?;
let frequency = serde_json::to_value(&check.frequency)?;
let settings = pack_settings(check)?;
am.folder_id = Set(check.folder_id.clone());
am.tz_offset = Set(check.tz_offset);
am.name = Set(check.name.clone());
am.description = Set(check.description.clone());
am.tags = Set(tags);
am.target = Set(monitor.target.clone());
am.config = Set(monitor.config.clone());
am.target = Set(check.target.clone());
am.config = Set(check.config.clone());
am.frequency = Set(frequency);
am.locations = Set(locations);
am.enabled = Set(monitor.enabled);
am.enabled = Set(check.enabled);
am.destinations = Set(destinations);
am.settings = Set(settings);
am.secrets = Set(pack_secrets(monitor)?);
am.secrets = Set(pack_secrets(check)?);
Ok(())
}
fn build_active_model(monitor: &Synthetic) -> Result<ActiveModel, errors::Error> {
let locations = serde_json::to_value(&monitor.locations)?;
let destinations = serde_json::to_value(&monitor.destinations)?;
let tags = serde_json::to_value(&monitor.tags)?;
let frequency = serde_json::to_value(&monitor.frequency)?;
let settings = pack_settings(monitor)?;
fn build_active_model(check: &Synthetic) -> Result<ActiveModel, errors::Error> {
let locations = serde_json::to_value(&check.locations)?;
let destinations = serde_json::to_value(&check.destinations)?;
let tags = serde_json::to_value(&check.tags)?;
let frequency = serde_json::to_value(&check.frequency)?;
let settings = pack_settings(check)?;
Ok(ActiveModel {
name: Set(monitor.name.clone()),
description: Set(monitor.description.clone()),
name: Set(check.name.clone()),
description: Set(check.description.clone()),
tags: Set(tags),
target: Set(monitor.target.clone()),
config: Set(monitor.config.clone()),
target: Set(check.target.clone()),
config: Set(check.config.clone()),
frequency: Set(frequency),
locations: Set(locations),
enabled: Set(monitor.enabled),
enabled: Set(check.enabled),
destinations: Set(destinations),
settings: Set(settings),
secrets: Set(pack_secrets(monitor)?),
secrets: Set(pack_secrets(check)?),
..Default::default()
})
}
fn monitor_type_to_str(t: &SyntheticType) -> &'static str {
fn check_type_to_str(t: &SyntheticType) -> &'static str {
match t {
SyntheticType::Http => "http",
SyntheticType::Api => "api",
@ -655,18 +880,18 @@ fn monitor_type_to_str(t: &SyntheticType) -> &'static str {
// ── Filter extension ──────────────────────────────────────────────────────────
trait ApplyMonitorFilters {
trait ApplyCheckFilters {
fn apply_filters(self, params: &ListSyntheticsParams) -> Self;
}
impl ApplyMonitorFilters for sea_orm::Select<Entity> {
impl ApplyCheckFilters for sea_orm::Select<Entity> {
fn apply_filters(self, params: &ListSyntheticsParams) -> Self {
let mut q = self;
if let Some(folder_id) = &params.folder_id {
q = q.filter(Column::FolderId.eq(folder_id.clone()));
}
if let Some(monitor_type) = &params.monitor_type {
q = q.filter(Column::SyntheticsType.eq(monitor_type_to_str(monitor_type)));
if let Some(check_type) = &params.check_type {
q = q.filter(Column::SyntheticsType.eq(check_type_to_str(check_type)));
}
if let Some(enabled) = params.enabled {
q = q.filter(Column::Enabled.eq(enabled));
@ -678,7 +903,7 @@ impl ApplyMonitorFilters for sea_orm::Select<Entity> {
#[cfg(test)]
mod tests {
use super::*;
use crate::table::entity::synthetics_monitors::Model;
use crate::table::entity::synthetics_checks::Model;
fn make_model() -> Model {
Model {
@ -689,7 +914,7 @@ mod tests {
name: "Login Flow".to_string(),
synthetics_type: "browser".to_string(),
target: "https://app.example.com".to_string(),
description: "Monitors the login flow".to_string(),
description: "Checks the login flow".to_string(),
tags: serde_json::json!(["prod"]),
config: serde_json::json!({
"browser_devices": [{"browser": "chromium", "device": "desktop"}],
@ -716,14 +941,14 @@ mod tests {
#[test]
fn test_try_from_model() {
let monitor = Synthetic::try_from(make_model()).unwrap();
assert_eq!(monitor.id, "mon-1");
assert_eq!(monitor.monitor_type, SyntheticType::Browser);
assert_eq!(monitor.locations, vec!["aws-us-east-1"]);
assert!(monitor.enabled);
assert_eq!(monitor.frequency.interval, 5);
let check = Synthetic::try_from(make_model()).unwrap();
assert_eq!(check.id, "mon-1");
assert_eq!(check.check_type, SyntheticType::Browser);
assert_eq!(check.locations, vec!["aws-us-east-1"]);
assert!(check.enabled);
assert_eq!(check.frequency.interval, 5);
assert_eq!(
monitor.frequency.frequency_type,
check.frequency.frequency_type,
config::meta::synthetics::SyntheticFrequencyType::Minutes
);
}
@ -737,14 +962,14 @@ mod tests {
#[test]
fn test_monitor_type_to_str() {
assert_eq!(monitor_type_to_str(&SyntheticType::Http), "http");
assert_eq!(monitor_type_to_str(&SyntheticType::Browser), "browser");
assert_eq!(monitor_type_to_str(&SyntheticType::Api), "api");
assert_eq!(monitor_type_to_str(&SyntheticType::Tcp), "tcp");
assert_eq!(monitor_type_to_str(&SyntheticType::Tls), "tls");
assert_eq!(monitor_type_to_str(&SyntheticType::Ssh), "ssh");
assert_eq!(monitor_type_to_str(&SyntheticType::Ping), "ping");
assert_eq!(monitor_type_to_str(&SyntheticType::Dns), "dns");
assert_eq!(check_type_to_str(&SyntheticType::Http), "http");
assert_eq!(check_type_to_str(&SyntheticType::Browser), "browser");
assert_eq!(check_type_to_str(&SyntheticType::Api), "api");
assert_eq!(check_type_to_str(&SyntheticType::Tcp), "tcp");
assert_eq!(check_type_to_str(&SyntheticType::Tls), "tls");
assert_eq!(check_type_to_str(&SyntheticType::Ssh), "ssh");
assert_eq!(check_type_to_str(&SyntheticType::Ping), "ping");
assert_eq!(check_type_to_str(&SyntheticType::Dns), "dns");
}
#[test]
@ -753,9 +978,66 @@ mod tests {
m.next_run_at = 1750000001000000;
m.last_triggered_at = 1750000000500000;
m.last_check_status = 1;
let monitor = Synthetic::try_from(m).unwrap();
assert_eq!(monitor.next_run_at, 1750000001000000);
assert_eq!(monitor.last_triggered_at, 1750000000500000);
assert_eq!(monitor.last_check_status, SyntheticStatus::Passed);
let check = Synthetic::try_from(m).unwrap();
assert_eq!(check.next_run_at, 1750000001000000);
assert_eq!(check.last_triggered_at, 1750000000500000);
assert_eq!(check.last_check_status, SyntheticStatus::Passed);
}
/// The lock clause is the whole HA fix: without FOR UPDATE SKIP LOCKED the
/// SELECT is plain and every alert_manager node claims every due check.
#[tokio::test]
async fn test_claim_due_locks_with_skip_locked_on_postgres() {
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![vec![make_model()]])
.append_exec_results(vec![MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}])
.into_connection();
let claimed = claim_due(&db, 500, 10, |_| 900).await.unwrap();
assert_eq!(
claimed.len(),
1,
"the due check should be returned as claimed"
);
let sql = format!("{:?}", db.into_transaction_log());
assert!(
sql.contains("FOR UPDATE SKIP LOCKED"),
"the claiming SELECT must lock and skip, else replicas do not self-shard: {sql}"
);
assert!(
sql.contains("UPDATE"),
"the advance must happen in the same transaction as the lock: {sql}"
);
}
/// SQLite has no FOR UPDATE and needs none — it is single-node, so no second
/// scheduler can exist to race with. Emitting the clause there is a syntax
/// error, so the backend branch is load-bearing.
#[tokio::test]
async fn test_claim_due_omits_lock_clause_on_sqlite() {
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![make_model()]])
.append_exec_results(vec![MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}])
.into_connection();
let claimed = claim_due(&db, 500, 10, |_| 900).await.unwrap();
assert_eq!(claimed.len(), 1);
let sql = format!("{:?}", db.into_transaction_log());
assert!(
!sql.contains("FOR UPDATE"),
"SQLite cannot parse FOR UPDATE: {sql}"
);
}
}

View File

@ -42,14 +42,14 @@ pub struct EnqueueParams<'a> {
pub valid_until: i64,
/// KSUID of the parent `synthetics_runs` row.
pub run_id: &'a str,
/// JSON array of `{execution_id, engine, device}` — browser monitors only. `None` for
/// protocol monitors.
/// JSON array of `{execution_id, engine, device}` — browser checks only. `None` for
/// protocol checks.
pub browser_devices: Option<&'a str>,
/// Serialized `JobMetadata` — monitor-level context copied at enqueue time.
/// Serialized `JobMetadata` — check-level context copied at enqueue time.
pub metadata: &'a str,
}
/// Monitor-level metadata copied into the job row at enqueue time.
/// Check-level metadata copied into the job row at enqueue time.
/// Stored as a JSON blob so new fields can be added without schema migrations.
#[derive(serde::Serialize, serde::Deserialize, Default)]
pub struct JobMetadata {
@ -57,7 +57,7 @@ pub struct JobMetadata {
pub tags: Vec<String>,
/// The synthetic's check type ("http", "tcp", "browser", …) — copied at
/// enqueue time so stream records (including dispatcher error records)
/// can carry `type` without a monitor lookup.
/// can carry `type` without a check lookup.
#[serde(default)]
pub synthetic_type: String,
}
@ -216,8 +216,8 @@ pub async fn get_by_id<C: ConnectionTrait>(
.map_err(errors::Error::from)
}
/// Deletes all pending checks for a synthetic (called on monitor delete).
pub async fn drain_monitor<C: ConnectionTrait>(
/// Deletes all pending checks for a synthetic (called on check delete).
pub async fn drain_check<C: ConnectionTrait>(
conn: &C,
synthetics_id: &str,
) -> Result<u64, errors::Error> {
@ -249,7 +249,7 @@ const MAX_LEASE_BATCH: i64 = 100;
///
/// `lease_secs` is a floor request, not the decision. Both probes hardcode 300s
/// client-side while a check's retry sequence — which runs *inside* the leased
/// job — is bounded only by `JOB_LEASE_SECS`, so an under-lease means the lease
/// job — is bounded only by the configured job lease, so an under-lease means the lease
/// expires while the probe is still working: the reaper terminates the job,
/// completes the run as an error, and the probe's real result is then rejected as
/// a stale ack. A client cannot be trusted to know how long its job may take, so
@ -264,7 +264,7 @@ pub async fn lease_batch<C: ConnectionTrait>(
lease_secs: i64,
browser: Option<bool>,
) -> Result<Vec<LeasedRow>, errors::Error> {
let lease_secs = lease_secs.max(config::meta::synthetics::JOB_LEASE_SECS);
let lease_secs = lease_secs.max(config::meta::synthetics::limits().job_lease_secs);
let lease_expires_at = now_us + lease_secs * 1_000_000;
// `limit` arrives from a client and is cast to u64 below, where a negative
@ -596,7 +596,7 @@ pub enum DispatchFailureOutcome {
/// Reset to Pending; a future lease will retry it.
Requeued,
/// Budget exhausted. Pure decision — no DB write here. The caller already
/// owns terminating the job (status + run counter + monitor status, e.g.
/// owns terminating the job (status + run counter + check status, e.g.
/// via the dispatcher's existing `mark_failure`), so writing an
/// intermediate status here would just be overwritten and wastes a query.
DeadLettered,
@ -691,26 +691,67 @@ pub async fn failing_locations<C: ConnectionTrait>(
conn: &C,
run_id: &str,
) -> Result<Vec<String>, errors::Error> {
let rows = Entity::find()
Ok(run_location_outcomes(conn, run_id).await?.failing)
}
/// Every location of a run, split by whether it passed.
///
/// `failing_locations` alone could not describe a recovery: on a recovered run
/// nothing is failing **by definition**, so the list came back empty and the
/// message degraded to a bare count ("Locations: 2"). The reader was told a
/// check recovered without being told where.
///
/// It also could not describe a *partial* recovery — "2 of 3 recovered,
/// aws-eu-central-1 still down" — because only one side of the split was ever
/// carried.
///
/// One query for both sides rather than two: the rows are already being read,
/// and the passing/failing split is a partition of the same result set.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct RunLocationOutcomes {
/// Locations that did not pass, worst first (Error > Warning > Failed).
pub failing: Vec<String>,
/// Locations that passed, alphabetical.
pub passing: Vec<String>,
}
pub async fn run_location_outcomes<C: ConnectionTrait>(
conn: &C,
run_id: &str,
) -> Result<RunLocationOutcomes, errors::Error> {
const STATUS_PASSED: i32 = 3;
let mut rows = Entity::find()
.select_only()
.column(Column::Location)
.column(Column::Status)
.filter(Column::RunId.eq(run_id))
.filter(Column::Status.ne(3))
.into_tuple::<(String, i32)>()
.all(conn)
.await?;
let mut rows = rows;
// Worst first: Error(6) > Warning(5) > Failed(4). A reader scanning the first
// line of a message should see the most severe location, not the
// alphabetically first.
// alphabetically first. Passing rows sort last and are split out below.
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut out: Vec<String> = Vec::with_capacity(rows.len());
for (loc, _) in rows {
if !out.contains(&loc) {
out.push(loc);
let mut out = RunLocationOutcomes::default();
for (loc, status) in rows {
// A location runs once per run, but dedupe anyway: a requeued job can
// leave two rows for one location, and naming it twice in a message
// reads as two separate outages.
let bucket = if status == STATUS_PASSED {
&mut out.passing
} else {
&mut out.failing
};
if !bucket.contains(&loc) {
bucket.push(loc);
}
}
// Failing is severity-ordered; passing has no severity, so alphabetical is
// the only stable order a reader can predict.
out.passing.sort();
Ok(out)
}

View File

@ -20,7 +20,13 @@
//! CRUD. `pool` is the queue routing key the scheduler writes jobs into and
//! probes/agents lease from.
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder, Set, sea_query::Expr};
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, sea_query::Expr};
use tokio::sync::RwLock;
use super::{
entity::synthetics_locations::{ActiveModel, Column, Entity, Model},
@ -34,6 +40,89 @@ use crate::{
pub const KIND_PUBLIC: &str = "public";
pub const KIND_PRIVATE: &str = "private";
/// Whole-table cache.
///
/// The locations registry is routing configuration: a couple of dozen rows that
/// change only when an operator adds a region or an org creates a private
/// location. It was being re-read on the hottest paths in the product —
/// `dispatcher_pools()` ran `list_visible("")` **every 2 s** forever, and the
/// scheduler called `get()` once *per location per firing check* (~200/min at
/// 100 checks). See `docs/synthetics-lcl/2026-07-31-synthetics-caching-and-db-load.md`
/// P3/P4.
///
/// Caching the whole table rather than one entry per key is deliberate: every
/// read shape (`get` by id, `find_by_pool`, `list_visible` per org) is a
/// different view of the same tiny row set, so one load serves all three and
/// there is no per-key stampede to reason about.
///
/// TTL-based rather than event-invalidated: `coordinator::synthetics` does not
/// exist yet, so a short TTL is what bounds staleness across pods. Writes in
/// this module invalidate eagerly, so the TTL only covers changes made by a
/// *different* node.
/// The cached rows and the instant they were loaded, absent until first load.
type CachedLocations = Option<(Vec<SyntheticsLocationRecord>, Instant)>;
static LOCATIONS_CACHE: LazyLock<RwLock<CachedLocations>> = LazyLock::new(|| RwLock::new(None));
const LOCATIONS_CACHE_TTL: Duration = Duration::from_secs(30);
/// Drops the cached table. Called by every write path in this module so a
/// change made on *this* node is visible immediately rather than after the TTL.
pub async fn invalidate_cache() {
*LOCATIONS_CACHE.write().await = None;
}
/// Invalidates locally **and** tells every other node. Write paths call this;
/// the coordinator watcher calls [`invalidate_cache`] so events do not echo.
async fn invalidate_and_publish() {
invalidate_cache().await;
if let Err(e) = crate::coordinator::synthetics::emit_locations_changed().await {
log::error!("[synthetics] emit location cache event failed: {e}");
}
}
/// Refreshes the cached table if it is missing or past its TTL.
///
/// Two callers racing a cold cache will both query and the second write wins —
/// harmless for an idempotent read, and cheaper than holding the write lock
/// across a DB round trip.
async fn ensure_fresh() -> Result<(), errors::Error> {
if let Some((_, loaded_at)) = LOCATIONS_CACHE.read().await.as_ref()
&& loaded_at.elapsed() < LOCATIONS_CACHE_TTL
{
return Ok(());
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let rows: Vec<SyntheticsLocationRecord> = Entity::find()
.order_by_asc(Column::Kind)
.order_by_asc(Column::Label)
.all(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?
.into_iter()
.map(Into::into)
.collect();
*LOCATIONS_CACHE.write().await = Some((rows, Instant::now()));
Ok(())
}
/// Runs `f` over the cached rows, refreshing first if stale.
///
/// Callers project what they need under the read lock instead of cloning the
/// whole table — `get`/`find_by_pool` run on every scheduler tick and every
/// probe lease, so cloning ~20 records per call to return one would be the bulk
/// of the work this cache exists to avoid.
async fn with_cached<T>(
f: impl FnOnce(&[SyntheticsLocationRecord]) -> T,
) -> Result<T, errors::Error> {
ensure_fresh().await?;
let guard = LOCATIONS_CACHE.read().await;
let rows = guard.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&[]);
Ok(f(rows))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SyntheticsLocationRecord {
pub id: String,
@ -78,6 +167,8 @@ pub async fn add(record: &SyntheticsLocationRecord) -> Result<(), errors::Error>
label: Set(record.label.clone()),
pool: Set(record.pool.clone()),
enabled: Set(record.enabled),
// A new location has never been notified as down.
down_notified_at: Set(0),
created_at: Set(record.created_at),
updated_at: Set(record.updated_at),
};
@ -85,24 +176,31 @@ pub async fn add(record: &SyntheticsLocationRecord) -> Result<(), errors::Error>
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish().await;
Ok(())
}
/// Locations visible to an org: all public rows + the org's private rows.
///
/// Served from the whole-table cache; the org filter is applied in Rust. Note
/// `list_visible("")` matches public rows only, which is how `dispatcher_pools()`
/// enumerates the public net pools.
pub async fn list_visible(org_id: &str) -> Result<Vec<SyntheticsLocationRecord>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let rows = Entity::find()
.filter(
Condition::any()
.add(Column::OrgId.is_null())
.add(Column::OrgId.eq(org_id)),
)
.order_by_asc(Column::Kind)
.order_by_asc(Column::Label)
.all(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(rows.into_iter().map(Into::into).collect())
with_cached(|rows| {
rows.iter()
.filter(|r| match &r.org_id {
None => true,
Some(o) => o == org_id,
})
.cloned()
.collect()
})
.await
}
/// All private rows across orgs — used by the staleness watcher.
@ -116,25 +214,14 @@ pub async fn list_private() -> Result<Vec<SyntheticsLocationRecord>, errors::Err
Ok(rows.into_iter().map(Into::into).collect())
}
/// Find one location by id.
/// Find one location by id. Served from the whole-table cache.
pub async fn get(id: &str) -> Result<Option<SyntheticsLocationRecord>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let row = Entity::find_by_id(id)
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(row.map(Into::into))
with_cached(|rows| rows.iter().find(|r| r.id == id).cloned()).await
}
/// Find one location by its pool routing key.
/// Find one location by its pool routing key. Served from the whole-table cache.
pub async fn find_by_pool(pool: &str) -> Result<Option<SyntheticsLocationRecord>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let row = Entity::find()
.filter(Column::Pool.eq(pool))
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(row.map(Into::into))
with_cached(|rows| rows.iter().find(|r| r.pool == pool).cloned()).await
}
/// Update label/enabled on a location.
@ -150,6 +237,12 @@ pub async fn update(id: &str, label: &str, enabled: bool) -> Result<(), errors::
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish().await;
Ok(())
}
@ -161,5 +254,57 @@ pub async fn remove(id: &str) -> Result<(), errors::Error> {
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish().await;
Ok(())
}
/// Claims the right to send this location's "location down" notification,
/// returning whether THIS caller won it.
///
/// The staleness watcher runs on **every** alert_manager node. Its suppression
/// flag used to be an in-process `HashSet`, so N nodes each decided
/// independently that they had not notified yet and N notifications went out for
/// one outage — repeating on every down → recover → down cycle.
///
/// `WHERE down_notified_at = 0` makes the transition a compare-and-swap: exactly
/// one node flips 0 → now and gets `rows_affected == 1`; the rest see 0 and stay
/// quiet. Same primitive as `synthetics_jobs::lease_batch` and
/// `synthetics_checks::try_claim_slot`.
///
/// Deliberately NOT cached — `LOCATIONS_CACHE` serves definition reads, and a
/// stale `down_notified_at` would reintroduce exactly the duplicate this fixes.
pub async fn try_claim_down_notification(id: &str, now_us: i64) -> Result<bool, errors::Error> {
let _lock = get_lock().await;
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let res = Entity::update_many()
.col_expr(Column::DownNotifiedAt, Expr::value(now_us))
.filter(Column::Id.eq(id))
.filter(Column::DownNotifiedAt.eq(0i64))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(res.rows_affected > 0)
}
/// Clears the down flag so a future outage notifies again.
///
/// Idempotent and unguarded on purpose: every node may call this on recovery and
/// the result is the same. Guarding it would leave the flag set if the one node
/// that "won" the clear died before the next tick, silencing the next outage.
pub async fn clear_down_notification(id: &str) -> Result<(), errors::Error> {
let _lock = get_lock().await;
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
Entity::update_many()
.col_expr(Column::DownNotifiedAt, Expr::value(0i64))
.filter(Column::Id.eq(id))
.filter(Column::DownNotifiedAt.ne(0i64))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}

View File

@ -19,6 +19,12 @@
//! (`/synthetics/jobs/resolve`, `/ack`, `/lease`). Separate from
//! `org_ingestion_tokens` (`o2oi_`) which is write-only ingest.
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use config::RwHashMap;
use sea_orm::{
ColumnTrait, EntityTrait, QueryFilter, Set, SqlErr, TransactionTrait, sea_query::Expr,
};
@ -34,6 +40,51 @@ use crate::{
pub const SYNTHETICS_PROBE_TOKEN_PREFIX: &str = "o2syn_";
/// Token → lookup result, with the time it was loaded.
///
/// `find_global` runs in the auth middleware on **every** probe request
/// (`api/common/src/auth/validator.rs`), so it fired once per lease, resolve and
/// ack — the single highest-frequency query in the feature, always for the same
/// handful of token strings.
///
/// `None` is cached as well as `Some`: an unknown or disabled token must not be
/// able to force a DB round trip per request, which is what makes an uncached
/// validator a cheap way to generate load from outside.
///
/// **The TTL is a security parameter, not a performance one.** It bounds how
/// long a disabled token keeps working. 10 s is short enough that revocation is
/// effectively immediate for an operator and long enough to remove ~99 % of the
/// queries. Writes in this module invalidate eagerly, so the TTL only covers a
/// disable performed on a *different* node.
static TOKEN_CACHE: LazyLock<RwHashMap<String, (Option<SyntheticsProbeTokenRecord>, Instant)>> =
LazyLock::new(Default::default);
/// Org → its default enabled token, same TTL rules as [`TOKEN_CACHE`].
static DEFAULT_TOKEN_CACHE: LazyLock<
RwHashMap<String, (Option<SyntheticsProbeTokenRecord>, Instant)>,
> = LazyLock::new(Default::default);
const TOKEN_CACHE_TTL: Duration = Duration::from_secs(10);
/// Clears both token caches. Called by every write path here so a disable,
/// rotate or default-change takes effect on this node immediately.
pub fn invalidate_cache() {
TOKEN_CACHE.clear();
DEFAULT_TOKEN_CACHE.clear();
}
/// Invalidates locally **and** tells every other node.
///
/// This is the revocation path: `set_enabled(false)` reaches other nodes as a
/// coordinator event rather than waiting out their TTL, which is what makes a
/// disable effectively fleet-wide-immediate.
async fn invalidate_and_publish(org_id: &str) {
invalidate_cache();
if let Err(e) = crate::coordinator::synthetics::emit_tokens_changed(org_id).await {
log::error!("[synthetics] emit probe token cache event failed for {org_id}: {e}");
}
}
/// Name of the token every org starts with (backfilled / created at org
/// creation). Named tokens minted later carry their own operator-chosen name.
pub const DEFAULT_TOKEN_NAME: &str = "default";
@ -93,7 +144,16 @@ pub async fn add(record: &SyntheticsProbeTokenRecord) -> Result<(), errors::Erro
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match Entity::insert(model).exec(client).await {
Ok(_) => Ok(()),
Ok(_) => {
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
// A new token can become the org default, so both caches are stale.
invalidate_and_publish(&record.org_id).await;
Ok(())
}
Err(e) => match e.sql_err() {
Some(SqlErr::UniqueConstraintViolation(_)) => {
Err(Error::DbError(DbError::SeaORMError(format!(
@ -112,14 +172,23 @@ pub async fn add(record: &SyntheticsProbeTokenRecord) -> Result<(), errors::Erro
/// path (the tenant boundary). Matches ANY enabled token, so old + new tokens
/// coexist during a rotation overlap window.
pub async fn find_global(token: &str) -> Result<Option<SyntheticsProbeTokenRecord>, errors::Error> {
if let Some(entry) = TOKEN_CACHE.get(token)
&& entry.1.elapsed() < TOKEN_CACHE_TTL
{
return Ok(entry.0.clone());
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let record = Entity::find()
.filter(Column::Token.eq(token))
.filter(Column::Enabled.eq(true))
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(record.map(SyntheticsProbeTokenRecord::from))
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?
.map(SyntheticsProbeTokenRecord::from);
TOKEN_CACHE.insert(token.to_string(), (record.clone(), Instant::now()));
Ok(record)
}
/// Find the org's default enabled probe token — the one handed out by
@ -127,6 +196,12 @@ pub async fn find_global(token: &str) -> Result<Option<SyntheticsProbeTokenRecor
pub async fn find_default(
org_id: &str,
) -> Result<Option<SyntheticsProbeTokenRecord>, errors::Error> {
if let Some(entry) = DEFAULT_TOKEN_CACHE.get(org_id)
&& entry.1.elapsed() < TOKEN_CACHE_TTL
{
return Ok(entry.0.clone());
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let record = Entity::find()
.filter(Column::OrgId.eq(org_id))
@ -134,8 +209,11 @@ pub async fn find_default(
.filter(Column::Enabled.eq(true))
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(record.map(SyntheticsProbeTokenRecord::from))
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?
.map(SyntheticsProbeTokenRecord::from);
DEFAULT_TOKEN_CACHE.insert(org_id.to_string(), (record.clone(), Instant::now()));
Ok(record)
}
/// List all probe tokens for an org (enabled + disabled), newest-default first.
@ -172,8 +250,12 @@ pub async fn get_by_name(
}
/// Enable or disable a token by `(org_id, name)`. Disabling is how a token is
/// revoked — because there is no validator cache for probe tokens, it takes
/// effect on the next request (no invalidation step needed).
/// revoked.
///
/// Revocation is immediate **on this node** — the caches are cleared below. On
/// other nodes it takes effect within `TOKEN_CACHE_TTL` (10 s), because there is
/// no coordinator channel for synthetics yet. If instant fleet-wide revocation
/// is ever required, that channel is the thing to build; do not remove the cache.
pub async fn set_enabled(org_id: &str, name: &str, enabled: bool) -> Result<(), errors::Error> {
let _lock = get_lock().await;
let now = chrono::Utc::now().timestamp_micros();
@ -186,6 +268,12 @@ pub async fn set_enabled(org_id: &str, name: &str, enabled: bool) -> Result<(),
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(org_id).await;
Ok(())
}
@ -219,6 +307,12 @@ pub async fn set_default(org_id: &str, name: &str) -> Result<(), errors::Error>
txn.commit()
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
// Release the SQLite write mutex BEFORE emitting. On a sqlite meta_store
// the coordinator's put() takes the *same* CLIENT_RW lock `get_lock()`
// returns, so emitting while holding it deadlocks the process — and the
// mutex is then held forever, hanging every later synthetics query.
drop(_lock);
invalidate_and_publish(org_id).await;
Ok(())
}

View File

@ -95,7 +95,7 @@ pub struct ListRunsParams<'a> {
pub page_size: i64,
}
/// Lists runs for a monitor in reverse chronological order, with optional time range filter.
/// Lists runs for a check in reverse chronological order, with optional time range filter.
/// Returns (rows, total_count).
pub async fn list_runs<C: ConnectionTrait>(
conn: &C,
@ -221,20 +221,53 @@ pub async fn get_run<C: ConnectionTrait>(
.map_err(errors::Error::from)
}
/// Atomically increments `jobs_done` and updates `run_result` to worst-case severity.
/// Sets `completed_at` when `jobs_done + 1 >= job_count`.
/// Records one job's outcome against its run, and returns `Some((run_result,
/// job_count))` **only to the caller that actually completed the run** — `None`
/// to everyone else.
///
/// `job_result` uses SyntheticStatus DB integers: 1=Passed, 2=Warning, 3=Failed, 4=Error.
/// Higher integer = higher severity, so `CASE WHEN ... > run_result` naturally picks worst.
/// Higher integer = higher severity, so the `CASE` naturally keeps the worst.
///
/// Returns `Some((run_result, job_count))` when the run is now complete (all jobs have acked),
/// or `None` if jobs are still in progress.
/// # Why completion is claimed rather than observed
///
/// The increment was always atomic (`jobs_done = jobs_done + 1` is computed by
/// the database). **The completion decision was not:** it re-read the row in a
/// second statement and returned `Some` whenever `jobs_done >= job_count`. Two
/// jobs of the same run acking concurrently both saw the final count:
///
/// ```text
/// ack A: UPDATE -> jobs_done = 1
/// ack B: UPDATE -> jobs_done = 2
/// ack A: SELECT -> 2 >= 2 -> Some "I completed the run"
/// ack B: SELECT -> 2 >= 2 -> Some "I completed the run" <-- twice
/// ```
///
/// Everything the caller does on completion then happened twice: the run
/// rollup, the check status write, and the alert dispatch — a duplicate
/// customer-facing notification. This needed no multiple alert managers to
/// reach: acks are served on ingesters (2 replicas in every environment) and the
/// probes of one run finish independently.
///
/// Now `completed_at` is the guard. Exactly one caller flips it from NULL, and
/// only that caller is told the run is complete. `completed_at IS NULL` is a
/// compare-and-swap in the same shape as `synthetics_jobs::lease_batch`'s
/// `status = 0`.
///
/// Deliberately not `RETURNING` (which would fold this into one statement):
/// SQLite only gained it in 3.35 and the bundled version is not pinned here,
/// whereas a guarded UPDATE plus `rows_affected` behaves identically on every
/// backend.
pub async fn increment_jobs_done<C: ConnectionTrait>(
conn: &C,
run_id: &str,
job_result: i32,
now_us: i64,
) -> Result<Option<(i32, i32)>, errors::Error> {
// Step 1: count this job and fold its severity in.
//
// `jobs_done < job_count` stops a duplicate or replayed ack from pushing the
// counter past the total, which would leave the run permanently "over
// complete" and, before the guard below existed, complete it twice.
let update_sql = r#"
UPDATE synthetics_runs
SET
@ -242,26 +275,43 @@ pub async fn increment_jobs_done<C: ConnectionTrait>(
run_result = CASE
WHEN COALESCE(run_result, 0) >= $1 THEN run_result
ELSE $1
END,
completed_at = CASE
WHEN jobs_done + 1 >= job_count THEN $2
ELSE completed_at
END
WHERE id = $3
WHERE id = $2 AND jobs_done < job_count
"#;
conn.execute(Statement::from_sql_and_values(
conn.get_database_backend(),
update_sql,
[
Value::from(job_result),
Value::from(now_us),
Value::from(run_id.to_owned()),
],
[Value::from(job_result), Value::from(run_id.to_owned())],
))
.await?;
// Step 2: claim completion. Only the caller whose increment brought
// `jobs_done` up to `job_count` finds `completed_at` still NULL.
let complete_sql = r#"
UPDATE synthetics_runs
SET completed_at = $1
WHERE id = $2 AND jobs_done >= job_count AND completed_at IS NULL
"#;
let claimed = conn
.execute(Statement::from_sql_and_values(
conn.get_database_backend(),
complete_sql,
[Value::from(now_us), Value::from(run_id.to_owned())],
))
.await?
.rows_affected();
if claimed == 0 {
// Either jobs are still outstanding, or another ack already completed
// this run. Both mean: not ours to report.
return Ok(None);
}
// Step 3: we own the completion, so read back what the caller needs to
// report. Safe to read outside a transaction — nothing rewrites these two
// once `completed_at` is set.
let check_sql = r#"
SELECT jobs_done, job_count, run_result FROM synthetics_runs WHERE id = $1
SELECT job_count, run_result FROM synthetics_runs WHERE id = $1
"#;
let rows = conn
.query_all(Statement::from_sql_and_values(
@ -271,17 +321,13 @@ pub async fn increment_jobs_done<C: ConnectionTrait>(
))
.await?;
if let Some(row) = rows.into_iter().next() {
let done: i32 = row.try_get("", "jobs_done").unwrap_or(0);
let count: i32 = row.try_get("", "job_count").unwrap_or(1);
if done >= count {
match rows.into_iter().next() {
Some(row) => {
let count: i32 = row.try_get("", "job_count").unwrap_or(1);
let result: Option<i32> = row.try_get("", "run_result").unwrap_or(None);
Ok(Some((result.unwrap_or(job_result), count)))
} else {
Ok(None)
}
} else {
Ok(None)
None => Ok(None),
}
}
@ -323,4 +369,117 @@ mod tests {
assert_eq!(r.jobs_done, 1);
assert_eq!(r.run_result, Some(1));
}
/// One raw row as `query_all` sees it — the completion read-back selects
/// only these two columns.
fn run_row(
job_count: i32,
run_result: i32,
) -> std::collections::BTreeMap<String, sea_orm::Value> {
let mut m = std::collections::BTreeMap::new();
m.insert("job_count".to_owned(), sea_orm::Value::from(job_count));
m.insert("run_result".to_owned(), sea_orm::Value::from(run_result));
m
}
/// Two acks of the same run must not both be told they completed it.
/// Before `completed_at` became the guard, both re-read `jobs_done >=
/// job_count` and both returned Some — so the run rollup, the check status
/// write and the ALERT all fired twice.
#[tokio::test]
async fn test_only_one_ack_is_told_it_completed_the_run() {
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
// Ack A: increment succeeds, and it wins the completed_at claim.
let winner = MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results(vec![
MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}, // increment
MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}, // claimed completion
])
.append_query_results(vec![vec![run_row(2, 3)]])
.into_connection();
let a = increment_jobs_done(&winner, "run-1", 3, 100).await.unwrap();
assert_eq!(a, Some((3, 2)), "the ack that completed the run reports it");
// Ack B: increment is a no-op (already at job_count) and the
// completed_at claim matches nothing, because A already set it.
let loser = MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results(vec![
MockExecResult {
last_insert_id: 0,
rows_affected: 0,
}, // increment guarded out
MockExecResult {
last_insert_id: 0,
rows_affected: 0,
}, // completion already claimed
])
.into_connection();
let b = increment_jobs_done(&loser, "run-1", 3, 100).await.unwrap();
assert_eq!(
b, None,
"a second ack must NOT be told it completed the run — that is the duplicate alert"
);
}
/// A run with jobs still outstanding reports nothing: the completion claim
/// matches no row because `jobs_done < job_count`.
#[tokio::test]
async fn test_incomplete_run_reports_none() {
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results(vec![
MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}, // counted
MockExecResult {
last_insert_id: 0,
rows_affected: 0,
}, // not complete yet
])
.into_connection();
assert_eq!(
increment_jobs_done(&db, "run-1", 1, 100).await.unwrap(),
None
);
}
/// The completion claim must guard on `completed_at IS NULL` — that clause
/// is the entire fix.
#[tokio::test]
async fn test_completion_claim_guards_on_completed_at() {
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results(vec![
MockExecResult {
last_insert_id: 0,
rows_affected: 1,
},
MockExecResult {
last_insert_id: 0,
rows_affected: 0,
},
])
.into_connection();
let _ = increment_jobs_done(&db, "run-1", 1, 100).await.unwrap();
let sql = format!("{:?}", db.into_transaction_log());
assert!(
sql.contains("completed_at IS NULL"),
"without this guard both acks complete the run: {sql}"
);
assert!(
sql.contains("jobs_done < job_count"),
"the increment must be guarded so a replayed ack cannot overshoot: {sql}"
);
}
}

View File

@ -500,6 +500,10 @@ pub async fn init() -> Result<(), anyhow::Error> {
tokio::task::spawn(db::alerts::destinations::watch());
tokio::task::spawn(db::alerts::realtime_triggers::watch());
tokio::task::spawn(db::alerts::alert::watch());
// Synthetics config caches (checks, locations, probe tokens, agents) live on
// every node, so every node must hear invalidations — including routers,
// which serve the probe auth path.
tokio::task::spawn(infra::coordinator::synthetics::watch());
// org_settings_watch already started above for all nodes including routers
// Watch needed on queriers (UI APIs) and on whichever node role is the configured
// processing node (ingester or compactor) so their local cache stays in sync with

View File

@ -541,6 +541,32 @@ async fn init_enterprise() -> Result<(), anyhow::Error> {
panic!("ratelimit config error: {e}");
}
// Push the synthetics limits from enterprise config into the OSS validator.
// Synthetics is enterprise-only, so the values live in SyntheticsConfig, but
// the check validation they bound lives in `config` — which cannot depend on
// o2_enterprise. This is the seam.
//
// Deliberately NOT fatal, unlike ratelimit above: synthetics is one feature,
// and refusing to start the whole application — ingest, search, dashboards —
// because a probe ceiling is misconfigured would be the worse outage. On
// rejection nothing is installed and validation keeps using the conservative
// built-in defaults, so the failure mode is "stricter than intended", not
// "accepts checks that get killed mid-run".
if let Err(e) =
config::meta::synthetics::init_limits(config::meta::synthetics::SyntheticsLimits {
job_lease_secs: o2cfg.synthetics.job_lease_secs,
max_check_budget_secs: o2cfg.synthetics.max_check_budget_secs,
max_net_timeout_ms: o2cfg.synthetics.max_net_timeout_ms,
})
{
log::error!(
"synthetics limits config rejected, falling back to defaults \
(budget={}s lease={}s): {e}",
config::meta::synthetics::DEFAULT_MAX_CHECK_BUDGET_SECS,
config::meta::synthetics::DEFAULT_JOB_LEASE_SECS,
);
}
o2_enterprise::enterprise::pipeline::pipeline_file_server::PipelineFileServer::run().await?;
if o2cfg.rate_limit.rate_limit_enabled && o2_openfga::config::get_config().enabled {
o2_ratelimit::init(

View File

@ -29,6 +29,7 @@ flight.workspace = true
futures.workspace = true
futures-util.workspace = true
hashbrown.workspace = true
hashlink.workspace = true
infra.workspace = true
itertools.workspace = true
log.workspace = true

16
src/search/src/cache/mod.rs vendored Normal file
View File

@ -0,0 +1,16 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod streaming_agg;

View File

@ -0,0 +1,484 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::search::Interval;
use super::files::STREAMING_AGGS_CACHE_DIR;
/// Time conversion constants
pub const MICROS_PER_SECOND: i64 = 1_000_000;
pub const MICROS_PER_MINUTE: i64 = 60 * MICROS_PER_SECOND;
/// Represents a single cache file entry with its metadata
#[derive(Debug, Clone, PartialEq)]
pub struct CacheEntry {
pub file_path: String,
pub start_time: i64,
pub end_time: i64,
pub interval: Interval,
}
impl CacheEntry {
/// Checks if this cache entry overlaps with the given time range
pub fn overlaps_with(&self, start: i64, end: i64, interval: Interval) -> bool {
self.start_time < end && self.end_time > start && self.interval <= interval
}
}
/// Represents a time range that is not covered by cache
#[derive(Debug, Clone, PartialEq)]
pub struct TimeRange {
pub start_time: i64,
pub end_time: i64,
}
impl TimeRange {
pub fn new(start_time: i64, end_time: i64) -> Self {
Self {
start_time,
end_time,
}
}
}
/// Result of cache discovery operation
#[derive(Debug, Clone)]
pub struct CacheDiscoveryResult {
pub cached_ranges: Vec<CacheEntry>,
pub uncached_ranges: Vec<TimeRange>,
pub cache_coverage_ratio: f64,
}
impl CacheDiscoveryResult {
pub fn new(
cached_ranges: Vec<CacheEntry>,
uncached_ranges: Vec<TimeRange>,
cache_coverage_ratio: f64,
) -> Self {
Self {
cached_ranges,
uncached_ranges,
cache_coverage_ratio,
}
}
/// Creates an empty discovery result (no cache available) for the given time range
pub fn empty(start_time: i64, end_time: i64) -> Self {
Self {
cached_ranges: vec![],
uncached_ranges: vec![TimeRange::new(start_time, end_time)],
cache_coverage_ratio: 0.0,
}
}
/// Returns true if the entire query range is cached
pub fn is_fully_cached(&self) -> bool {
self.uncached_ranges.is_empty() && self.cache_coverage_ratio >= 1.0
}
/// Returns true if no cache is available
pub fn has_no_cache(&self) -> bool {
self.cache_coverage_ratio == 0.0
}
}
/// Discovers existing cache files for a given query
///
/// # Arguments
/// * `cache_file_path` - The base cache file path (e.g., "org_id/stream_type/stream_name/hash")
/// * `query_start` - Query start time in microseconds
/// * `query_end` - Query end time in microseconds
/// * `query_interval` - The interval of the query
///
/// # Returns
/// * `Ok(CacheDiscoveryResult)` - Discovery result with cached and uncached ranges
/// * `Err(std::io::Error)` - If cache directory cannot be accessed
pub async fn discover_cache_for_query(
cache_file_path: &str,
query_start: i64,
query_end: i64,
query_interval: Interval,
) -> std::io::Result<CacheDiscoveryResult> {
let cache_path = format!(
"{}{}/{}",
config::get_config().common.data_cache_dir,
STREAMING_AGGS_CACHE_DIR,
cache_file_path
);
// If cache directory doesn't exist, return empty result
if !tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
return Ok(CacheDiscoveryResult::new(
vec![],
vec![TimeRange::new(query_start, query_end)],
0.0,
));
}
// Read all cache files in the directory asynchronously
let mut read_dir = tokio::fs::read_dir(&cache_path).await?;
let mut files = Vec::new();
// Collect all directory entries
while let Some(entry) = read_dir.next_entry().await? {
files.push(entry);
}
let mut cache_entries = Vec::new();
// Parse each cache file to extract metadata
for file in files {
let file_name = file.file_name();
let file_name_str = match file_name.to_str() {
Some(name) => name,
None => continue,
};
// Skip temporary files
if file_name_str.contains("_tmp") {
continue;
}
// Parse the cache entry
if let Some(entry) = parse_cache_file_name(cache_file_path, file_name_str) {
// Only include entries that overlap with the query range
if entry.overlaps_with(query_start, query_end, query_interval) {
cache_entries.push(entry);
}
}
}
// Sort cache entries by start time
cache_entries.sort_by_key(|e| e.start_time);
// Calculate uncached ranges (gaps in coverage)
let uncached_ranges = calculate_uncached_ranges(&cache_entries, query_start, query_end);
// Calculate cache coverage ratio
let total_duration = query_end - query_start;
let cached_duration = calculate_cached_duration(&cache_entries, query_start, query_end);
let cache_coverage_ratio = if total_duration > 0 {
cached_duration as f64 / total_duration as f64
} else {
0.0
};
Ok(CacheDiscoveryResult::new(
cache_entries,
uncached_ranges,
cache_coverage_ratio,
))
}
/// Parses a cache file name to extract cache entry metadata
fn parse_cache_file_name(cache_file_path: &str, file_name: &str) -> Option<CacheEntry> {
// Remove file extension before parsing timestamps
let name_without_ext = file_name
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(file_name);
let parts: Vec<&str> = name_without_ext.split('_').collect();
if parts.len() < 2 {
return None;
}
let start_time = parts[0].parse::<i64>().ok()?;
let end_time = parts[1].parse::<i64>().ok()?;
// Calculate interval from the time range duration in the filename
// The interval is inferred from the duration (end_time - start_time)
let duration_micros = end_time - start_time;
let interval = interval_from_duration_micros(duration_micros);
// Build the full cache file path
let full_cache_path = format!("{STREAMING_AGGS_CACHE_DIR}/{cache_file_path}/{file_name}",);
Some(CacheEntry {
file_path: full_cache_path,
start_time,
end_time,
interval,
})
}
/// Infers the interval enum from duration in microseconds
///
/// # Arguments
/// * `duration_micros` - Duration in microseconds (expected range: 5 min to 1 day)
///
/// # Returns
/// The corresponding `Interval` enum variant based on the duration in minutes
fn interval_from_duration_micros(duration_micros: i64) -> Interval {
// Convert microseconds to minutes
let duration_minutes = duration_micros / MICROS_PER_MINUTE;
Interval::from(duration_minutes)
}
/// Calculates the uncached ranges (gaps) in the query time range
fn calculate_uncached_ranges(
cache_entries: &[CacheEntry],
query_start: i64,
query_end: i64,
) -> Vec<TimeRange> {
if cache_entries.is_empty() {
return vec![TimeRange::new(query_start, query_end)];
}
let mut uncached_ranges = Vec::new();
let mut current_time = query_start;
for entry in cache_entries {
// If there's a gap before this entry, add it as uncached
if current_time < entry.start_time {
uncached_ranges.push(TimeRange::new(
current_time,
entry.start_time.min(query_end),
));
}
// Move current time to the end of this cached entry
current_time = current_time.max(entry.end_time);
// If we've covered the entire query range, stop
if current_time >= query_end {
break;
}
}
// If there's still time left after the last cache entry, add it as uncached
if current_time < query_end {
uncached_ranges.push(TimeRange::new(current_time, query_end));
}
uncached_ranges
}
/// Calculates the total cached duration within the query range
fn calculate_cached_duration(
cache_entries: &[CacheEntry],
query_start: i64,
query_end: i64,
) -> i64 {
let mut total_cached = 0i64;
let mut last_covered_time = query_start;
for entry in cache_entries {
// Calculate the overlap between this cache entry and the query range
let overlap_start = entry.start_time.max(last_covered_time);
let overlap_end = entry.end_time.min(query_end);
if overlap_start < overlap_end {
total_cached += overlap_end - overlap_start;
last_covered_time = overlap_end;
}
if last_covered_time >= query_end {
break;
}
}
total_cached
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_entry_overlaps_with() {
let interval = Interval::OneHour;
let entry = CacheEntry {
file_path: "test.arrow".to_string(),
start_time: 1000,
end_time: 2000,
interval,
};
// Test overlapping ranges
assert!(entry.overlaps_with(500, 1500, interval)); // Starts before, ends during
assert!(entry.overlaps_with(1500, 2500, interval)); // Starts during, ends after
assert!(entry.overlaps_with(1200, 1800, interval)); // Completely within
assert!(entry.overlaps_with(500, 2500, interval)); // Completely encompasses
// Test non-overlapping ranges
assert!(!entry.overlaps_with(0, 1000, interval)); // Ends exactly at start
assert!(!entry.overlaps_with(2000, 3000, interval)); // Starts exactly at end
assert!(!entry.overlaps_with(0, 500, interval)); // Completely before
assert!(!entry.overlaps_with(2500, 3000, interval)); // Completely after
}
#[test]
fn test_interval_from_minutes() {
assert_eq!(Interval::from(0), Interval::Zero);
assert_eq!(Interval::from(5), Interval::FiveMinutes);
assert_eq!(Interval::from(10), Interval::TenMinutes);
assert_eq!(Interval::from(30), Interval::ThirtyMinutes);
assert_eq!(Interval::from(60), Interval::OneHour);
assert_eq!(Interval::from(120), Interval::TwoHours);
assert_eq!(Interval::from(360), Interval::SixHours);
assert_eq!(Interval::from(720), Interval::TwelveHours);
assert_eq!(Interval::from(1440), Interval::OneDay);
assert_eq!(Interval::from(999), Interval::Zero); // Unknown interval
}
#[test]
fn test_calculate_uncached_ranges_no_cache() {
let cache_entries = vec![];
let ranges = calculate_uncached_ranges(&cache_entries, 0, 10000);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_time, 0);
assert_eq!(ranges[0].end_time, 10000);
}
#[test]
fn test_calculate_uncached_ranges_fully_cached() {
let cache_entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 5000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 5000,
end_time: 10000,
interval: Interval::OneHour,
},
];
let ranges = calculate_uncached_ranges(&cache_entries, 0, 10000);
assert_eq!(ranges.len(), 0); // No gaps
}
#[test]
fn test_calculate_uncached_ranges_with_gaps() {
let cache_entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 1000,
end_time: 2000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 5000,
end_time: 7000,
interval: Interval::OneHour,
},
];
let ranges = calculate_uncached_ranges(&cache_entries, 0, 10000);
assert_eq!(ranges.len(), 3);
assert_eq!(ranges[0].start_time, 0);
assert_eq!(ranges[0].end_time, 1000);
assert_eq!(ranges[1].start_time, 2000);
assert_eq!(ranges[1].end_time, 5000);
assert_eq!(ranges[2].start_time, 7000);
assert_eq!(ranges[2].end_time, 10000);
}
#[test]
fn test_calculate_cached_duration_no_cache() {
let cache_entries = vec![];
let duration = calculate_cached_duration(&cache_entries, 0, 10000);
assert_eq!(duration, 0);
}
#[test]
fn test_calculate_cached_duration_fully_cached() {
let cache_entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 5000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 5000,
end_time: 10000,
interval: Interval::OneHour,
},
];
let duration = calculate_cached_duration(&cache_entries, 0, 10000);
assert_eq!(duration, 10000);
}
#[test]
fn test_calculate_cached_duration_partial() {
let cache_entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 1000,
end_time: 4000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 6000,
end_time: 9000,
interval: Interval::OneHour,
},
];
let duration = calculate_cached_duration(&cache_entries, 0, 10000);
assert_eq!(duration, 6000); // 3000 + 3000
}
#[test]
fn test_calculate_cached_duration_overlapping() {
// This tests the case where cache entries might overlap (shouldn't double count)
let cache_entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 6000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 4000,
end_time: 10000,
interval: Interval::OneHour,
},
];
let duration = calculate_cached_duration(&cache_entries, 0, 10000);
assert_eq!(duration, 10000); // Should cover 0-10000 without double counting
}
#[test]
fn test_cache_discovery_result_states() {
// Fully cached
let result = CacheDiscoveryResult::new(vec![], vec![], 1.0);
assert!(result.is_fully_cached());
assert!(!result.has_no_cache());
// Partial cache
let result = CacheDiscoveryResult::new(vec![], vec![], 0.5);
assert!(!result.is_fully_cached());
assert!(!result.has_no_cache());
// No cache
let result = CacheDiscoveryResult::new(vec![], vec![], 0.0);
assert!(!result.is_fully_cached());
assert!(result.has_no_cache());
}
}

View File

@ -0,0 +1,657 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
io::Cursor,
path::Path,
sync::{Arc, LazyLock},
};
use arrow::{
array::{ArrayRef, RecordBatch, RecordBatchOptions},
compute::cast,
datatypes::{DataType, Field, SchemaRef},
ipc::{reader::FileReader as ArrowFileReader, writer::FileWriter as ArrowFileWriter},
};
use config::{
meta::search::SearchPartitionRequest,
utils::{
record_batch_ext::RecordBatchExt,
time::{now_micros, second_micros},
},
};
use hashbrown::HashMap;
use infra::cache::file_data::disk;
use tokio::sync::mpsc;
use crate::datafusion::aggregates::gc_string_view_batch;
pub const STREAMING_AGGS_CACHE_DIR: &str = "aggregations";
#[derive(Debug)]
pub struct RecordBatchCacheRequest {
pub streaming_id: String,
pub file_path: String,
pub schema: SchemaRef,
pub records: Vec<Arc<RecordBatch>>,
pub overwrite_cache: bool,
}
// Global queue for cache requests
static CACHE_QUEUE: LazyLock<mpsc::UnboundedSender<(String, String)>> = LazyLock::new(|| {
let (sender, mut receiver) = mpsc::unbounded_channel::<(String, String)>();
// Spawn background task to process cache requests
tokio::spawn(async move {
while let Some((streaming_id, file_key)) = receiver.recv().await {
log::debug!("[streaming_id: {streaming_id}] Received cache request");
if let Err(e) = load_record_batches_file_to_disk_cache(&streaming_id, &file_key).await {
log::error!(
"[streaming_id: {streaming_id}] Failed to load record batches file to disk cache: {e:?}"
);
}
}
});
sender
});
async fn load_record_batches_file_to_disk_cache(
streaming_id: &str,
file_key: &str,
) -> Result<(), std::io::Error> {
// Skip caching if record batch for the time range is already cached
let Some(file_path) = disk::get_file_path(file_key) else {
return Ok(()); // no need to cache, it's not a valid file path
};
let Some(file_meta) = config::utils::file::get_file_meta(&file_path).ok() else {
return Ok(()); // no need to cache, it's not a valid file path
};
let file_size = file_meta.len();
if file_size == 0 {
return Ok(()); // no need to cache, it's not a valid file
}
log::debug!(
"load_record_batches_file_to_disk_cache: streaming_id: {streaming_id}, file_key: {file_key}, file_size: {file_size}"
);
// set to disk cache
disk::set_size(file_key, file_size as usize)
.await
.map_err(|e| std::io::Error::other(format!("Failed to set size to disk cache: {e}")))?;
Ok(())
}
// Main handler to write record batches to disk
pub fn cache_record_batches_to_disk(
request: RecordBatchCacheRequest,
) -> Result<(), std::io::Error> {
let start = std::time::Instant::now();
let RecordBatchCacheRequest {
streaming_id,
file_path,
schema,
records,
overwrite_cache,
} = request;
// Skip caching if record batch for the time range is already cached
let file_key = file_path.clone();
let Some(file_path) = disk::get_file_path(&file_path) else {
return Err(std::io::Error::other(
"ZO_DISK_CACHE_ENABLED is not enabled",
));
};
let file_meta = config::utils::file::get_file_meta(&file_path).ok();
let file_exists = file_meta.is_some() && file_meta.unwrap().is_file();
if file_exists && !overwrite_cache {
log::warn!(
"[streaming_id: {streaming_id}] file_exists: {file_exists}, Skipping cache to disk because the data for the time range is already cached",
);
return Ok(());
}
if file_exists && overwrite_cache {
log::info!(
"[streaming_id: {streaming_id}] file_exists: {file_exists}, overwrite_cache: {overwrite_cache}, Overwriting existing cache file",
);
}
let batches_num = records.len();
let rows_num = records.iter().map(|r| r.num_rows()).sum::<usize>();
let batches = records
.iter()
.map(|r| Arc::new(gc_string_view_batch(r)))
.collect::<Vec<Arc<RecordBatch>>>();
// Serialize the record batches into bytes
let data = match serialize_record_batches(schema, batches) {
Ok(data) => data,
Err(e) => {
log::error!("[streaming_id: {streaming_id}] Failed to serialize record batches: {e:?}",);
return Err(std::io::Error::other("Serialization failed"));
}
};
// create the directory if it doesn't exist
std::fs::create_dir_all(Path::new(&file_path).parent().unwrap())?;
// write the data to the file
match config::utils::file::put_file_contents(&file_path, &data) {
Ok(_) => {
log::info!(
"cache_record_batches_to_disk: streaming_id: {streaming_id}, file_path: {file_path}, batches: {batches_num}, rows: {rows_num}, write to file took: {} ms",
start.elapsed().as_millis()
);
// add to cache list
// Send to background queue (non-blocking)
if let Err(e) = CACHE_QUEUE.send((streaming_id.clone(), file_key)) {
log::error!(
"[streaming_id: {streaming_id}] Failed to queue cache file to disk: {file_path}, error: {e:?}",
);
}
Ok(())
}
Err(e) => {
log::error!("Error caching results to disk: {e:?}");
Err(std::io::Error::other(format!(
"[streaming_id: {streaming_id}] Error caching results to disk: file_path={file_path}"
)))
}
}
}
// write to arrow ipc format
fn serialize_record_batches(
schema: SchemaRef,
batches: Vec<Arc<RecordBatch>>,
) -> arrow::error::Result<Vec<u8>> {
let mut buffer = Cursor::new(Vec::new());
let mut writer = ArrowFileWriter::try_new(&mut buffer, &schema)?;
for batch in batches {
writer.write(&batch)?;
}
writer.finish()?;
Ok(buffer.into_inner())
}
pub fn get_record_batches(
streaming_id: &str,
file_path: &str,
schema: SchemaRef,
) -> std::io::Result<Vec<RecordBatch>> {
let start = std::time::Instant::now();
let file_path = disk::get_file_path(file_path).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {file_path}"),
)
})?;
let data = config::utils::file::get_file_contents(&file_path, None).map_err(|e| {
log::error!("Error getting file contents: {e}, file_path: {file_path}");
e
})?;
let reader = unsafe {
ArrowFileReader::try_new(Cursor::new(data), None)
.map_err(|e| {
log::error!("Error creating arrow reader: {e}, file_path: {file_path}");
std::io::Error::other(format!("Arrow error: {e}"))
})?
.with_skip_validation(true)
};
let schema_field_map = schema
.fields()
.iter()
.map(|f| (f.name(), f.data_type()))
.collect::<HashMap<&String, &DataType>>();
let mut batches = Vec::new();
for batch in reader {
let batch = batch.map_err(|e| std::io::Error::other(format!("Arrow error: {e}")))?;
let new_columns: Vec<ArrayRef> = batch
.columns()
.iter()
.zip(batch.schema().fields().iter())
.map(|(c, f)| {
let file_datatype = f.data_type();
let need_datatype = *schema_field_map.get(f.name()).unwrap_or(&&DataType::Null);
// Use the recursive cast function
if let Some(casted) = cast_array_recursive(c, file_datatype, need_datatype) {
casted
} else {
Arc::clone(c)
}
})
.collect();
let mut options = RecordBatchOptions::new();
options = options.with_row_count(Some(batch.num_rows()));
let batch = RecordBatch::try_new_with_options(schema.clone(), new_columns, &options)
.expect("Failed to re-create the record batch");
batches.push(batch);
}
log::debug!(
"get_record_batches: streaming_id: {streaming_id}, file_path: {file_path}, batches: {}, rows: {}, arrow_size: {}, took: {} ms",
batches.len(),
batches.iter().map(|r| r.num_rows()).sum::<usize>(),
batches.iter().map(|r| r.size()).sum::<usize>(),
start.elapsed().as_millis()
);
Ok(batches)
}
/// Recursively checks if two data types need casting and performs the cast if needed.
/// This function handles:
/// - String type conversions (Utf8, LargeUtf8, Utf8View)
/// - Nested List types (recursively checks inner types)
/// - Nested Struct types (recursively checks field types)
/// - Other types (returns None if no cast needed)
///
/// # Arguments
/// * `array` - The array to potentially cast
/// * `from_type` - The source data type
/// * `to_type` - The target data type
///
/// # Returns
/// * `Some(ArrayRef)` - If casting was needed and successful
/// * `None` - If no casting is needed (types are compatible)
fn cast_array_recursive(
array: &ArrayRef,
from_type: &DataType,
to_type: &DataType,
) -> Option<ArrayRef> {
// If types are identical, no cast needed
if from_type == to_type {
return None;
}
match (from_type, to_type) {
// Handle string type conversions
(
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View,
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View,
) => {
// Cast between string types
cast(array, to_type).ok()
}
// Handle List types recursively
(DataType::List(from_field), DataType::List(to_field)) => {
let from_inner = from_field.data_type();
let to_inner = to_field.data_type();
// If inner types need casting, create a new List type with the target inner type
if needs_recursive_cast(from_inner, to_inner) {
// Create a new field with the target data type
let new_field = Arc::new(Field::new(
to_field.name(),
to_inner.clone(),
to_field.is_nullable(),
));
let new_list_type = DataType::List(new_field);
cast(array, &new_list_type).ok()
} else {
None
}
}
// Handle LargeList types recursively
(DataType::LargeList(from_field), DataType::LargeList(to_field)) => {
let from_inner = from_field.data_type();
let to_inner = to_field.data_type();
if needs_recursive_cast(from_inner, to_inner) {
let new_field = Arc::new(Field::new(
to_field.name(),
to_inner.clone(),
to_field.is_nullable(),
));
let new_list_type = DataType::LargeList(new_field);
cast(array, &new_list_type).ok()
} else {
None
}
}
// Handle Struct types recursively
(DataType::Struct(from_fields), DataType::Struct(to_fields)) => {
// Check if any field needs casting
let needs_cast =
from_fields
.iter()
.zip(to_fields.iter())
.any(|(from_field, to_field)| {
needs_recursive_cast(from_field.data_type(), to_field.data_type())
});
if needs_cast {
cast(array, to_type).ok()
} else {
None
}
}
// For all other type combinations, no cast is performed
_ => None,
}
}
/// Helper function to check if two types need recursive casting
fn needs_recursive_cast(from_type: &DataType, to_type: &DataType) -> bool {
if from_type == to_type {
return false;
}
match (from_type, to_type) {
// String types can be cast between each other
(
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View,
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View,
) => true,
// Recursively check List types
(DataType::List(from_field), DataType::List(to_field)) => {
needs_recursive_cast(from_field.data_type(), to_field.data_type())
}
// Recursively check LargeList types
(DataType::LargeList(from_field), DataType::LargeList(to_field)) => {
needs_recursive_cast(from_field.data_type(), to_field.data_type())
}
// Recursively check Struct types
(DataType::Struct(from_fields), DataType::Struct(to_fields)) => {
from_fields.len() == to_fields.len()
&& from_fields
.iter()
.zip(to_fields.iter())
.any(|(from_field, to_field)| {
needs_recursive_cast(from_field.data_type(), to_field.data_type())
})
}
_ => false,
}
}
pub fn create_aggregation_cache_file_path(
org_id: &str,
stream_type: &str,
stream_name: &str,
hashed_query: u64,
) -> String {
if org_id.is_empty() || stream_type.is_empty() || stream_name.is_empty() {
return "".to_string();
}
// eg: /org_id/stream_type/stream_name/12345678
// Note: interval is NOT included in the path anymore - all intervals share the same directory
format!("{org_id}/{stream_type}/{stream_name}/{hashed_query}")
}
pub fn generate_aggregation_cache_file_name(
id: &str,
start_time: i64,
end_time: i64,
is_complete_partition_window: bool,
) -> String {
// set cache as tmp if the time range is within the delay window
let delay_window_micros = second_micros(config::get_config().limit.cache_delay_secs);
let skip_cache = now_micros() - delay_window_micros;
let can_be_cached = end_time < skip_cache;
let is_tmp_file = if is_complete_partition_window && can_be_cached {
"".to_string()
} else {
format!("_{id}_tmp")
};
format!("{start_time}_{end_time}{is_tmp_file}.arrow")
}
pub fn get_cache_file_path(file_path: &str, file_name: &str) -> String {
format!("{STREAMING_AGGS_CACHE_DIR}/{file_path}/{file_name}")
}
pub fn get_aggregation_cache_key_from_request(req: &SearchPartitionRequest) -> u64 {
let origin_sql = req.sql.clone();
let mut hash_body = vec![origin_sql];
if let Some(vrl_function) = &req.query_fn {
hash_body.push(vrl_function.to_string());
}
if !req.regions.is_empty() {
hash_body.extend(req.regions.clone());
}
if !req.clusters.is_empty() {
hash_body.extend(req.clusters.clone());
}
config::utils::hash::sum64(&hash_body.join(","))
}
#[cfg(test)]
mod tests {
use arrow::array::StringArray;
use super::*;
#[test]
fn test_cast_array_recursive_string_types() {
use arrow::array::StringArray;
// Test Utf8 to Utf8View
let array: ArrayRef = Arc::new(StringArray::from(vec!["hello", "world"]));
let result = cast_array_recursive(&array, &DataType::Utf8, &DataType::Utf8View);
assert!(result.is_some());
let casted = result.unwrap();
assert_eq!(casted.data_type(), &DataType::Utf8View);
// Test Utf8View to LargeUtf8
let array: ArrayRef = Arc::new(StringArray::from(vec!["foo", "bar"]));
let result = cast_array_recursive(&array, &DataType::Utf8View, &DataType::LargeUtf8);
assert!(result.is_some());
// Test LargeUtf8 to Utf8
let array: ArrayRef = Arc::new(StringArray::from(vec!["test"]));
let result = cast_array_recursive(&array, &DataType::LargeUtf8, &DataType::Utf8);
assert!(result.is_some());
}
#[test]
fn test_cast_array_recursive_identical_types() {
use arrow::array::Int32Array;
// Test that identical types return None (no cast needed)
let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3]));
let result = cast_array_recursive(&array, &DataType::Int32, &DataType::Int32);
assert!(result.is_none());
// Test string types
let array: ArrayRef = Arc::new(StringArray::from(vec!["test"]));
let result = cast_array_recursive(&array, &DataType::Utf8, &DataType::Utf8);
assert!(result.is_none());
}
#[test]
fn test_cast_array_recursive_list_with_string() {
use arrow::array::{ListArray, StringArray};
// Create a List<Utf8> array
let values = StringArray::from(vec!["a", "b", "c", "d"]);
let offsets = arrow::buffer::OffsetBuffer::new(vec![0, 2, 4].into());
let field = Arc::new(Field::new("item", DataType::Utf8, true));
let list_array = ListArray::new(field.clone(), offsets, Arc::new(values), None);
let array: ArrayRef = Arc::new(list_array);
// Cast from List<Utf8> to List<Utf8View>
let from_type = DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)));
let to_type = DataType::List(Arc::new(Field::new("item", DataType::Utf8View, true)));
let result = cast_array_recursive(&array, &from_type, &to_type);
assert!(result.is_some());
let casted = result.unwrap();
if let DataType::List(inner_field) = casted.data_type() {
assert_eq!(inner_field.data_type(), &DataType::Utf8View);
} else {
panic!("Expected List type");
}
}
#[test]
fn test_cast_array_recursive_nested_list() {
use arrow::array::{ListArray, StringArray};
// Create a List<List<Utf8>> structure
let inner_values = StringArray::from(vec!["x", "y"]);
let inner_offsets = arrow::buffer::OffsetBuffer::new(vec![0, 1, 2].into());
let inner_field = Arc::new(Field::new("item", DataType::Utf8, true));
let inner_list = ListArray::new(
inner_field.clone(),
inner_offsets,
Arc::new(inner_values),
None,
);
let outer_offsets = arrow::buffer::OffsetBuffer::new(vec![0, 2].into());
let outer_field = Arc::new(Field::new(
"item",
DataType::List(inner_field.clone()),
true,
));
let outer_list = ListArray::new(outer_field, outer_offsets, Arc::new(inner_list), None);
let array: ArrayRef = Arc::new(outer_list);
// Cast from List<List<Utf8>> to List<List<LargeUtf8>>
let from_type = DataType::List(Arc::new(Field::new(
"item",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
)));
let to_type = DataType::List(Arc::new(Field::new(
"item",
DataType::List(Arc::new(Field::new("item", DataType::LargeUtf8, true))),
true,
)));
let result = cast_array_recursive(&array, &from_type, &to_type);
assert!(result.is_some());
}
#[test]
fn test_cast_array_recursive_struct_with_string() {
use arrow::{
array::{Int32Array, StringArray, StructArray},
datatypes::Fields,
};
// Create a Struct with (name: Utf8, age: Int32)
let name_array = Arc::new(StringArray::from(vec!["Alice", "Bob"]));
let age_array = Arc::new(Int32Array::from(vec![30, 25]));
let from_fields = Fields::from(vec![
Field::new("name", DataType::Utf8, false),
Field::new("age", DataType::Int32, false),
]);
let struct_array = StructArray::new(
from_fields.clone(),
vec![name_array as ArrayRef, age_array as ArrayRef],
None,
);
let array: ArrayRef = Arc::new(struct_array);
// Cast to Struct with (name: Utf8View, age: Int32)
let from_type = DataType::Struct(from_fields);
let to_fields = Fields::from(vec![
Field::new("name", DataType::Utf8View, false),
Field::new("age", DataType::Int32, false),
]);
let to_type = DataType::Struct(to_fields.clone());
let result = cast_array_recursive(&array, &from_type, &to_type);
assert!(result.is_some());
let casted = result.unwrap();
if let DataType::Struct(fields) = casted.data_type() {
assert_eq!(fields[0].data_type(), &DataType::Utf8View);
assert_eq!(fields[1].data_type(), &DataType::Int32);
} else {
panic!("Expected Struct type");
}
}
#[test]
fn test_needs_recursive_cast_string_types() {
// String type variations should return true
assert!(needs_recursive_cast(&DataType::Utf8, &DataType::Utf8View));
assert!(needs_recursive_cast(&DataType::LargeUtf8, &DataType::Utf8));
assert!(needs_recursive_cast(
&DataType::Utf8View,
&DataType::LargeUtf8
));
// Same string type should return false
assert!(!needs_recursive_cast(&DataType::Utf8, &DataType::Utf8));
}
#[test]
fn test_needs_recursive_cast_list_types() {
// List with different inner types
let from_list = DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)));
let to_list = DataType::List(Arc::new(Field::new("item", DataType::Utf8View, true)));
assert!(needs_recursive_cast(&from_list, &to_list));
// List with same inner types
let same_list = DataType::List(Arc::new(Field::new("item", DataType::Int32, true)));
assert!(!needs_recursive_cast(&same_list, &same_list));
}
#[test]
fn test_needs_recursive_cast_struct_types() {
use arrow::datatypes::Fields;
// Struct with different field types
let from_fields = Fields::from(vec![Field::new("name", DataType::Utf8, false)]);
let to_fields = Fields::from(vec![Field::new("name", DataType::Utf8View, false)]);
let from_struct = DataType::Struct(from_fields);
let to_struct = DataType::Struct(to_fields);
assert!(needs_recursive_cast(&from_struct, &to_struct));
// Struct with same field types
let same_fields = Fields::from(vec![Field::new("id", DataType::Int32, false)]);
let same_struct = DataType::Struct(same_fields);
assert!(!needs_recursive_cast(&same_struct, &same_struct));
}
#[test]
fn test_cast_array_recursive_incompatible_types() {
use arrow::array::{Int32Array, StringArray};
// Test that incompatible types return None
let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3]));
let result = cast_array_recursive(&array, &DataType::Int32, &DataType::Float64);
assert!(result.is_none());
// Test string to int (should return None as we don't handle this)
let array: ArrayRef = Arc::new(StringArray::from(vec!["test"]));
let result = cast_array_recursive(&array, &DataType::Utf8, &DataType::Int32);
assert!(result.is_none());
}
}

View File

@ -0,0 +1,27 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Streaming aggregation cache implementation
//!
//! This module provides cache-aware partition generation for streaming aggregations,
//! including cache discovery, loading, and partition optimization.
mod discovery;
mod files;
mod partition_optimizer;
// Re-export public APIs
pub use discovery::*;
pub use files::*;
pub use partition_optimizer::*;

View File

@ -0,0 +1,615 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::{
search::{CardinalityLevel, Interval, generate_aggregation_search_interval},
sql::OrderBy,
};
use super::discovery::{CacheDiscoveryResult, CacheEntry, TimeRange};
/// Strategy for handling query partitions based on cache availability
#[derive(Debug, Clone)]
pub enum StreamingAggsPartitionStrategy {
/// All data is available in cache - no query execution needed
FullyCached { cache_files: Vec<CacheEntry> },
/// Mix of cached and uncached data
Hybrid {
cached_partitions: Vec<CachedPartition>,
uncached_partitions: Vec<UncachedPartition>,
},
/// No cache available - execute query normally
NoCacheAvailable { partitions: Vec<UncachedPartition> },
}
impl StreamingAggsPartitionStrategy {
/// Strategy Name
pub fn strategy_name(&self) -> &str {
match self {
StreamingAggsPartitionStrategy::FullyCached { .. } => "FullyCached",
StreamingAggsPartitionStrategy::Hybrid { .. } => "Hybrid",
StreamingAggsPartitionStrategy::NoCacheAvailable { .. } => "NoCacheAvailable",
}
}
/// Returns true if this strategy requires query execution
pub fn requires_execution(&self) -> bool {
match self {
StreamingAggsPartitionStrategy::FullyCached { .. } => false,
StreamingAggsPartitionStrategy::Hybrid { .. }
| StreamingAggsPartitionStrategy::NoCacheAvailable { .. } => true,
}
}
/// Returns the number of partitions that need to be executed
pub fn execution_partition_count(&self) -> usize {
match self {
StreamingAggsPartitionStrategy::FullyCached { .. } => 0,
StreamingAggsPartitionStrategy::Hybrid {
uncached_partitions,
..
} => uncached_partitions.len(),
StreamingAggsPartitionStrategy::NoCacheAvailable { partitions } => partitions.len(),
}
}
/// Converts the partition strategy into time range partitions [start, end]
/// This is the format expected by the rest of the search system
///
/// # Arguments
/// * `order_by` - The sort order (Asc or Desc) for partitions
///
/// # Returns
/// Vector of [start_time, end_time] pairs in the requested order
pub fn to_time_partitions(&self, order_by: OrderBy) -> Vec<[i64; 2]> {
let mut partitions = Vec::new();
match self {
StreamingAggsPartitionStrategy::FullyCached { cache_files } => {
// For fully cached queries, return a SINGLE partition covering the entire range
// This avoids unnecessary iteration over individual cache files
if !cache_files.is_empty() {
let min_start = cache_files.iter().map(|f| f.start_time).min().unwrap();
let max_end = cache_files.iter().map(|f| f.end_time).max().unwrap();
partitions.push([min_start, max_end]);
}
}
StreamingAggsPartitionStrategy::Hybrid {
cached_partitions,
uncached_partitions,
} => {
// Add cached partitions
for cached in cached_partitions {
partitions.push([cached.start_time, cached.end_time]);
}
// Add uncached partitions
for uncached in uncached_partitions {
partitions.push([uncached.start_time, uncached.end_time]);
}
// Sort by start time to ensure chronological order
partitions.sort_by_key(|p| p[0]);
}
StreamingAggsPartitionStrategy::NoCacheAvailable { partitions: p } => {
for partition in p {
partitions.push([partition.start_time, partition.end_time]);
}
}
}
// Apply ordering
if order_by == OrderBy::Desc {
partitions.reverse();
}
partitions
}
}
/// Represents a partition that is fully covered by cache
#[derive(Debug, Clone)]
pub struct CachedPartition {
pub cache_files: Vec<CacheEntry>,
pub start_time: i64,
pub end_time: i64,
pub interval: Interval,
}
impl CachedPartition {
pub fn new(
cache_files: Vec<CacheEntry>,
start_time: i64,
end_time: i64,
interval: Interval,
) -> Self {
Self {
cache_files,
start_time,
end_time,
interval,
}
}
}
/// Represents a partition that needs to be executed (not in cache)
#[derive(Debug, Clone)]
pub struct UncachedPartition {
pub start_time: i64,
pub end_time: i64,
}
impl UncachedPartition {
pub fn new(start_time: i64, end_time: i64) -> Self {
Self {
start_time,
end_time,
}
}
}
/// Generates optimal partition strategy based on cache discovery results
///
/// # Arguments
/// * `discovery_result` - Result from cache discovery
/// * `query_start` - Query start time in microseconds
/// * `query_end` - Query end time in microseconds
/// * `cardinality_level` - Cardinality level for determining cache intervals
///
/// # Returns
/// * `PartitionStrategy` - Optimal strategy for executing the query
pub fn generate_optimal_partitions(
discovery_result: CacheDiscoveryResult,
query_start: i64,
query_end: i64,
cardinality_level: CardinalityLevel,
) -> StreamingAggsPartitionStrategy {
// Case 1: Fully cached - no execution needed
if discovery_result.is_fully_cached() {
return StreamingAggsPartitionStrategy::FullyCached {
cache_files: discovery_result.cached_ranges,
};
}
// Calculate the target interval for the entire query based on query duration
let query_target_interval =
generate_aggregation_search_interval(query_start, query_end, cardinality_level);
// Case 2: No cache available - use standard ladder logic
if discovery_result.has_no_cache() {
let partitions =
generate_uncached_partitions_from_range(query_start, query_end, query_target_interval);
return StreamingAggsPartitionStrategy::NoCacheAvailable { partitions };
}
// Case 3: Hybrid - mix of cached and uncached
let cached_partitions = group_cache_entries_into_partitions(discovery_result.cached_ranges);
let uncached_partitions =
generate_uncached_partitions(discovery_result.uncached_ranges, query_target_interval);
StreamingAggsPartitionStrategy::Hybrid {
cached_partitions,
uncached_partitions,
}
}
/// Groups consecutive cache entries with the same interval into cached partitions
///
/// # Preconditions
/// - `cache_entries` must be sorted by `start_time` (ascending)
/// - This is guaranteed by `discover_cache_for_query()` which sorts entries before returning
///
/// # Behavior
/// - Creates separate partitions when interval changes
/// - Creates separate partitions when there's a time gap between entries
/// - Assumes entries are non-overlapping (guaranteed by cache write logic)
fn group_cache_entries_into_partitions(cache_entries: Vec<CacheEntry>) -> Vec<CachedPartition> {
if cache_entries.is_empty() {
return vec![];
}
let mut partitions = Vec::new();
let mut current_group: Vec<CacheEntry> = vec![];
let mut current_interval = cache_entries[0].interval;
for entry in cache_entries {
// If the interval changes or there's a gap, create a new partition
if entry.interval != current_interval
|| (!current_group.is_empty()
&& entry.start_time > current_group.last().unwrap().end_time)
{
if !current_group.is_empty() {
let start_time = current_group.first().unwrap().start_time;
let end_time = current_group.last().unwrap().end_time;
partitions.push(CachedPartition::new(
current_group,
start_time,
end_time,
current_interval,
));
}
current_group = vec![entry.clone()];
current_interval = entry.interval;
} else {
current_group.push(entry);
}
}
// Don't forget the last group
if !current_group.is_empty() {
let start_time = current_group.first().unwrap().start_time;
let end_time = current_group.last().unwrap().end_time;
partitions.push(CachedPartition::new(
current_group,
start_time,
end_time,
current_interval,
));
}
partitions
}
/// Generates uncached partitions from time ranges using the query's target interval
fn generate_uncached_partitions(
uncached_ranges: Vec<TimeRange>,
target_interval: Interval,
) -> Vec<UncachedPartition> {
let mut partitions = Vec::new();
for range in uncached_ranges {
let range_partitions = generate_uncached_partitions_from_range(
range.start_time,
range.end_time,
target_interval,
);
partitions.extend(range_partitions);
}
partitions
}
/// Generates uncached partitions for a single time range using the specified target interval
fn generate_uncached_partitions_from_range(
start_time: i64,
end_time: i64,
target_interval: Interval,
) -> Vec<UncachedPartition> {
// Use the query's target interval (already calculated based on total query duration)
// This ensures all uncached partitions use the same interval regardless of gap size
// If interval is Zero, we don't cache (e.g., for Huge cardinality)
if target_interval == Interval::Zero {
return vec![UncachedPartition::new(start_time, end_time)];
}
let interval_micros = target_interval.get_interval_microseconds();
let mut partitions = Vec::new();
// Align start time to UTC boundary
let aligned_start = align_time_to_interval(start_time, interval_micros, true);
// If query starts before the first aligned boundary, create a non-aligned partition
if start_time < aligned_start {
partitions.push(UncachedPartition::new(
start_time,
aligned_start.min(end_time),
));
}
// Generate UTC-aligned partitions
let mut current_time = aligned_start;
while current_time < end_time {
let partition_end = (current_time + interval_micros).min(end_time);
partitions.push(UncachedPartition::new(current_time, partition_end));
current_time = partition_end;
}
partitions
}
/// Aligns a timestamp to the nearest interval boundary
///
/// # Arguments
/// * `timestamp` - Timestamp in microseconds (assumed to be within reasonable bounds: year
/// 1970-2200)
/// * `interval_micros` - Interval duration in microseconds (max: 1 day = 86,400,000,000)
/// * `round_up` - If true, rounds up to next boundary; if false, rounds down
///
/// # Safety
/// This function uses unchecked arithmetic. Integer overflow is not possible with realistic
/// timestamp values (years 1970-2200) and supported intervals (5min to 1day). The maximum
/// result is bounded by `timestamp + interval_micros`, which is well within i64::MAX for
/// any reasonable query timestamp.
fn align_time_to_interval(timestamp: i64, interval_micros: i64, round_up: bool) -> i64 {
let remainder = timestamp % interval_micros;
if remainder == 0 {
timestamp
} else if round_up {
timestamp + (interval_micros - remainder)
} else {
timestamp - remainder
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_partition_strategy_fully_cached() {
let cache_files = vec![CacheEntry {
file_path: "test.arrow".to_string(),
start_time: 0,
end_time: 1000,
interval: Interval::OneHour,
}];
let strategy = StreamingAggsPartitionStrategy::FullyCached {
cache_files: cache_files.clone(),
};
assert!(!strategy.requires_execution());
assert_eq!(strategy.execution_partition_count(), 0);
}
#[test]
fn test_partition_strategy_no_cache() {
let partitions = vec![UncachedPartition::new(0, 1000)];
let strategy = StreamingAggsPartitionStrategy::NoCacheAvailable {
partitions: partitions.clone(),
};
assert!(strategy.requires_execution());
assert_eq!(strategy.execution_partition_count(), 1);
}
#[test]
fn test_partition_strategy_hybrid() {
let cached = vec![CachedPartition::new(vec![], 0, 500, Interval::OneHour)];
let uncached = vec![UncachedPartition::new(500, 1000)];
let strategy = StreamingAggsPartitionStrategy::Hybrid {
cached_partitions: cached.clone(),
uncached_partitions: uncached.clone(),
};
assert!(strategy.requires_execution());
assert_eq!(strategy.execution_partition_count(), 1);
}
#[test]
fn test_align_time_to_interval() {
let one_hour_micros = 3_600_000_000i64;
// Already aligned
assert_eq!(align_time_to_interval(0, one_hour_micros, true), 0);
assert_eq!(align_time_to_interval(0, one_hour_micros, false), 0);
assert_eq!(
align_time_to_interval(one_hour_micros, one_hour_micros, true),
one_hour_micros
);
// Not aligned - round up
assert_eq!(
align_time_to_interval(100, one_hour_micros, true),
one_hour_micros
);
assert_eq!(
align_time_to_interval(one_hour_micros + 100, one_hour_micros, true),
one_hour_micros * 2
);
// Not aligned - round down
assert_eq!(align_time_to_interval(100, one_hour_micros, false), 0);
assert_eq!(
align_time_to_interval(one_hour_micros + 100, one_hour_micros, false),
one_hour_micros
);
}
#[test]
fn test_generate_uncached_partitions_from_range_zero_interval() {
// For Zero interval, we don't partition
let partitions = generate_uncached_partitions_from_range(0, 10000, Interval::Zero);
assert_eq!(partitions.len(), 1);
assert_eq!(partitions[0].start_time, 0);
assert_eq!(partitions[0].end_time, 10000);
}
#[test]
fn test_generate_uncached_partitions_from_range_aligned() {
let five_min_micros = 300_000_000i64;
// Query range that's perfectly aligned: 0 to 15 minutes (3 x 5-minute intervals)
let partitions =
generate_uncached_partitions_from_range(0, five_min_micros * 3, Interval::FiveMinutes);
assert_eq!(partitions.len(), 3);
for (i, partition) in partitions.iter().enumerate() {
assert_eq!(partition.start_time, five_min_micros * i as i64);
assert_eq!(partition.end_time, five_min_micros * (i as i64 + 1));
}
}
#[test]
fn test_generate_uncached_partitions_from_range_unaligned() {
let five_min_micros = 300_000_000i64;
// Query range that starts at a non-aligned time
let start = 100_000; // 100ms offset
let end = five_min_micros * 2 + 100_000; // 10min + 100ms
let partitions = generate_uncached_partitions_from_range(start, end, Interval::FiveMinutes);
// Should create: [100ms -> 5min], [5min -> 10min], [10min -> 10min+100ms]
assert_eq!(partitions.len(), 3);
// First partition: non-aligned start
assert_eq!(partitions[0].start_time, start);
assert_eq!(partitions[0].end_time, five_min_micros);
// Middle partition: fully aligned
assert_eq!(partitions[1].start_time, five_min_micros);
assert_eq!(partitions[1].end_time, five_min_micros * 2);
// Last partition: non-aligned end
assert_eq!(partitions[2].start_time, five_min_micros * 2);
assert_eq!(partitions[2].end_time, end);
}
#[test]
fn test_group_cache_entries_same_interval() {
let entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 1000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 1000,
end_time: 2000,
interval: Interval::OneHour,
},
];
let partitions = group_cache_entries_into_partitions(entries);
assert_eq!(partitions.len(), 1);
assert_eq!(partitions[0].start_time, 0);
assert_eq!(partitions[0].end_time, 2000);
assert_eq!(partitions[0].cache_files.len(), 2);
assert_eq!(partitions[0].interval, Interval::OneHour);
}
#[test]
fn test_group_cache_entries_different_intervals() {
let entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 1000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 1000,
end_time: 2000,
interval: Interval::FiveMinutes,
},
];
let partitions = group_cache_entries_into_partitions(entries);
assert_eq!(partitions.len(), 2);
assert_eq!(partitions[0].interval, Interval::OneHour);
assert_eq!(partitions[1].interval, Interval::FiveMinutes);
}
#[test]
fn test_group_cache_entries_with_gap() {
let entries = vec![
CacheEntry {
file_path: "test1.arrow".to_string(),
start_time: 0,
end_time: 1000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "test2.arrow".to_string(),
start_time: 2000, // Gap between 1000 and 2000
end_time: 3000,
interval: Interval::OneHour,
},
];
let partitions = group_cache_entries_into_partitions(entries);
assert_eq!(partitions.len(), 2);
assert_eq!(partitions[0].cache_files.len(), 1);
assert_eq!(partitions[1].cache_files.len(), 1);
}
#[test]
fn test_generate_optimal_partitions_fully_cached() {
let discovery = CacheDiscoveryResult::new(
vec![CacheEntry {
file_path: "test.arrow".to_string(),
start_time: 0,
end_time: 10000,
interval: Interval::OneHour,
}],
vec![],
1.0,
);
let strategy = generate_optimal_partitions(discovery, 0, 10000, CardinalityLevel::Low);
match strategy {
StreamingAggsPartitionStrategy::FullyCached { cache_files } => {
assert_eq!(cache_files.len(), 1);
}
_ => panic!("Expected FullyCached strategy"),
}
}
#[test]
fn test_generate_optimal_partitions_no_cache() {
let discovery = CacheDiscoveryResult::new(vec![], vec![TimeRange::new(0, 10000)], 0.0);
let strategy = generate_optimal_partitions(discovery, 0, 10000, CardinalityLevel::Low);
match strategy {
StreamingAggsPartitionStrategy::NoCacheAvailable { partitions } => {
assert!(!partitions.is_empty());
}
_ => panic!("Expected NoCacheAvailable strategy"),
}
}
#[test]
fn test_generate_optimal_partitions_hybrid() {
let discovery = CacheDiscoveryResult::new(
vec![CacheEntry {
file_path: "test.arrow".to_string(),
start_time: 0,
end_time: 5000,
interval: Interval::OneHour,
}],
vec![TimeRange::new(5000, 10000)],
0.5,
);
let strategy = generate_optimal_partitions(discovery, 0, 10000, CardinalityLevel::Low);
match strategy {
StreamingAggsPartitionStrategy::Hybrid {
cached_partitions,
uncached_partitions,
} => {
assert_eq!(cached_partitions.len(), 1);
assert!(!uncached_partitions.is_empty());
}
_ => panic!("Expected Hybrid strategy"),
}
}
}

View File

@ -0,0 +1,272 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Hash aggregation
use std::sync::Arc;
use arrow::{array::*, datatypes::SchemaRef};
use datafusion::{
common::Result,
logical_expr::{EmitTo, GroupsAccumulator},
physical_expr::{GroupsAccumulatorAdapter, aggregate::AggregateFunctionExpr},
physical_plan::{
PhysicalExpr,
aggregates::{
AggregateExec, AggregateMode, PhysicalGroupBy, aggregate_expressions,
evaluate_group_by, evaluate_many,
group_values::{GroupValues, new_group_values},
order::GroupOrdering,
},
},
};
use log::debug;
#[derive(Debug, Clone)]
/// This object tracks the aggregation phase (input/output)
pub(crate) enum ExecutionState {
ReadingInput,
/// When producing output, the remaining rows to output are stored
/// here and are sliced off as needed in batch_size chunks
ProducingOutput(RecordBatch),
/// All input has been consumed and all groups have been emitted
Done,
}
pub struct GroupedHashAggregateStream {
// ========================================================================
// PROPERTIES:
// These fields are initialized at the start and remain constant throughout
// the execution.
// ========================================================================
schema: SchemaRef,
mode: AggregateMode,
/// Arguments to pass to each accumulator.
///
/// The arguments in `accumulator[i]` is passed `aggregate_arguments[i]`
///
/// The argument to each accumulator is itself a `Vec` because
/// some aggregates such as `CORR` can accept more than one
/// argument.
aggregate_arguments: Vec<Vec<Arc<dyn PhysicalExpr>>>,
/// GROUP BY expressions
group_by: PhysicalGroupBy,
// ========================================================================
// STATE FLAGS:
// These fields will be updated during the execution. And control the flow of
// the execution.
// ========================================================================
/// Tracks if this stream is generating input or output
exec_state: ExecutionState,
/// Have we seen the end of the input
input_done: bool,
// ========================================================================
// STATE BUFFERS:
// These fields will accumulate intermediate results during the execution.
// ========================================================================
/// An interning store of group keys
group_values: Box<dyn GroupValues>,
/// scratch space for the current input [`RecordBatch`] being
/// processed. Reused across batches here to avoid reallocations
current_group_indices: Vec<usize>,
/// Accumulators, one for each `AggregateFunctionExpr` in the query
///
/// For example, if the query has aggregates, `SUM(x)`,
/// `COUNT(y)`, there will be two accumulators, each one
/// specialized for that particular aggregate and its input types
accumulators: Vec<Box<dyn GroupsAccumulator>>,
}
impl GroupedHashAggregateStream {
/// Create a new GroupedHashAggregateStream
pub fn new(agg: &AggregateExec) -> Result<Self> {
debug!("Creating GroupedHashAggregateStream");
let agg_schema = agg.input().schema();
let agg_group_by = agg.group_expr().clone();
let aggregate_exprs = agg.aggr_expr();
let aggregate_arguments =
aggregate_expressions(agg.aggr_expr(), agg.mode(), agg_group_by.num_group_exprs())?;
// Instantiate the accumulators
let accumulators: Vec<_> = aggregate_exprs
.iter()
.map(create_group_accumulator)
.collect::<Result<_>>()?;
let group_schema = agg_group_by.group_schema(&agg.input().schema())?;
let group_values = new_group_values(group_schema, &GroupOrdering::None)?;
let exec_state = ExecutionState::ReadingInput;
Ok(GroupedHashAggregateStream {
schema: agg_schema,
mode: *agg.mode(),
accumulators,
aggregate_arguments,
group_by: agg_group_by,
group_values,
current_group_indices: Default::default(),
exec_state,
input_done: false,
})
}
pub fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
}
/// Create an accumulator for `agg_expr` -- a [`GroupsAccumulator`] if
/// that is supported by the aggregate, or a
/// [`GroupsAccumulatorAdapter`] if not.
pub(crate) fn create_group_accumulator(
agg_expr: &Arc<AggregateFunctionExpr>,
) -> Result<Box<dyn GroupsAccumulator>> {
if agg_expr.groups_accumulator_supported() {
agg_expr.create_groups_accumulator()
} else {
// Note in the log when the slow path is used
debug!(
"Creating GroupsAccumulatorAdapter for {}: {agg_expr:?}",
agg_expr.name()
);
let agg_expr_captured = Arc::clone(agg_expr);
let factory = move || agg_expr_captured.create_accumulator();
Ok(Box::new(GroupsAccumulatorAdapter::new(factory)))
}
}
impl GroupedHashAggregateStream {
/// Perform group-by aggregation for the given [`RecordBatch`].
pub fn group_aggregate_batch(&mut self, batch: RecordBatch) -> Result<()> {
// Evaluate the grouping expressions
let group_by_values = evaluate_group_by(&self.group_by, &batch)?;
// Evaluate the aggregation expressions.
let input_values = evaluate_many(&self.aggregate_arguments, &batch)?;
for group_values in &group_by_values {
// calculate the group indices for each input row
self.group_values
.intern(group_values, &mut self.current_group_indices)?;
let group_indices = &self.current_group_indices;
// Update ordering information if necessary
let total_num_groups = self.group_values.len();
// Gather the inputs to call the actual accumulator
let t = self.accumulators.iter_mut().zip(input_values.iter());
for (acc, values) in t {
// Call the appropriate method on each aggregator with
// the entire input row and the relevant group indexes
match self.mode {
AggregateMode::Partial
| AggregateMode::Single
| AggregateMode::SinglePartitioned => {
acc.update_batch(values, group_indices, None, total_num_groups)?;
}
_ => {
// if aggregation is over intermediate states,
// use merge
acc.merge_batch(values, group_indices, None, total_num_groups)?;
}
}
}
}
Ok(())
}
/// Create an output RecordBatch with the group keys and
/// accumulator states/values specified in emit_to
fn emit(&mut self, emit_to: EmitTo) -> Result<Option<RecordBatch>> {
let schema = self.schema();
if self.group_values.is_empty() {
return Ok(None);
}
let mut output = self.group_values.emit(emit_to)?;
// Next output each aggregate value
for acc in self.accumulators.iter_mut() {
output.extend(acc.state(emit_to)?);
}
let batch = RecordBatch::try_new(schema, output)?;
debug_assert!(batch.num_rows() > 0);
Ok(Some(batch))
}
/// Clear memory and shirk capacities to the size of the batch.
fn clear_shrink(&mut self, num_rows: usize) {
self.group_values.clear_shrink(num_rows);
self.current_group_indices.clear();
self.current_group_indices.shrink_to(num_rows);
}
/// Clear memory and shirk capacities to zero.
fn clear_all(&mut self) {
self.clear_shrink(0);
}
/// common function for signalling end of processing of the input stream
fn set_input_done_and_produce_output(&mut self) -> Result<()> {
self.input_done = true;
let batch = self.emit(EmitTo::All)?;
self.exec_state = batch.map_or(ExecutionState::Done, ExecutionState::ProducingOutput);
Ok(())
}
pub fn get_final_result(&mut self) -> Result<Vec<RecordBatch>> {
self.set_input_done_and_produce_output()?;
let batch = match &self.exec_state {
ExecutionState::ProducingOutput(batch) => batch.clone(),
_ => RecordBatch::new_empty(self.schema()),
};
self.exec_state = ExecutionState::Done;
self.clear_all();
// split the batch into multiple batches
let num_rows = batch.num_rows();
// early return for empty batches
if num_rows == 0 {
return Ok(vec![]);
}
// calculate optimal batch size and pre-allocate vector
let batch_size = 8192;
let full_batches = num_rows / batch_size;
let has_remaining = !num_rows.is_multiple_of(batch_size);
let total_batches = full_batches + if has_remaining { 1 } else { 0 };
let mut result_vec = Vec::with_capacity(total_batches);
for i in 0..full_batches {
result_vec.push(batch.slice(i * batch_size, batch_size));
}
if has_remaining {
let start_idx = full_batches * batch_size;
let remaining_rows = num_rows - start_idx;
result_vec.push(batch.slice(start_idx, remaining_rows));
}
Ok(result_vec)
}
}

View File

@ -0,0 +1,101 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod merge_phase;
pub mod no_grouping_merge_phase;
use std::sync::Arc;
use arrow::array::{Array, ArrayRef, AsArray, RecordBatch, RecordBatchOptions, StringViewBuilder};
/// refer to: https://github.com/apache/datafusion/pull/11587
/// Heuristically compact `StringViewArray`s to reduce memory usage, if needed
///
/// Decides when to consolidate the StringView into a new buffer to reduce
/// memory usage and improve string locality for better performance.
///
/// This differs from `StringViewArray::gc` because:
/// 1. It may not compact the array depending on a heuristic.
/// 2. It uses a precise block size to reduce the number of buffers to track.
///
/// # Heuristic
///
/// If the average size of each view is larger than 32 bytes, we compact the array.
///
/// `StringViewArray` include pointers to buffer that hold the underlying data.
/// One of the great benefits of `StringViewArray` is that many operations
/// (e.g., `filter`) can be done without copying the underlying data.
///
/// However, after a while (e.g., after `FilterExec` or `HashJoinExec`) the
/// `StringViewArray` may only refer to a small portion of the buffer,
/// significantly increasing memory usage.
pub(crate) fn gc_string_view_batch(batch: &RecordBatch) -> RecordBatch {
let new_columns: Vec<ArrayRef> = batch
.columns()
.iter()
.map(|c| {
// Try to re-create the `StringViewArray` to prevent holding the underlying buffer too
// long.
let Some(s) = c.as_string_view_opt() else {
return Arc::clone(c);
};
// Fast path: if the data buffers are empty, we can return the original array
if s.data_buffers().is_empty() {
return Arc::clone(c);
}
let ideal_buffer_size: usize = s
.views()
.iter()
.map(|v| {
let len = (*v as u32) as usize;
if len > 12 { len } else { 0 }
})
.sum();
// We don't use get_buffer_memory_size here, because gc is for the contents of the
// data buffers, not views and nulls.
let actual_buffer_size = s.data_buffers().iter().map(|b| b.capacity()).sum::<usize>();
// Re-creating the array copies data and can be time consuming.
// We only do it if the array is sparse
if actual_buffer_size > (ideal_buffer_size * 2) {
// We set the block size to `ideal_buffer_size` so that the new StringViewArray only
// has one buffer, which accelerate later concat_batches. See https://github.com/apache/arrow-rs/issues/6094 for more details.
let mut builder = StringViewBuilder::with_capacity(s.len());
if ideal_buffer_size > 0 {
builder = builder.with_fixed_block_size(ideal_buffer_size as u32);
}
for v in s.iter() {
builder.append_option(v);
}
let gc_string = builder.finish();
debug_assert!(gc_string.data_buffers().len() <= 1); // buffer count can be 0 if the `ideal_buffer_size` is 0
Arc::new(gc_string)
} else {
Arc::clone(c)
}
})
.collect();
let mut options = RecordBatchOptions::new();
options = options.with_row_count(Some(batch.num_rows()));
RecordBatch::try_new_with_options(batch.schema(), new_columns, &options)
.expect("Failed to re-create the gc'ed record batch")
}

View File

@ -0,0 +1,178 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Aggregate without grouping columns
use std::{borrow::Cow, sync::Arc};
use arrow::{array::ArrayRef, datatypes::SchemaRef, record_batch::RecordBatch};
use datafusion::{
common::Result,
physical_plan::{
PhysicalExpr,
aggregates::{
AccumulatorItem, AggregateExec, AggregateMode, aggregate_expressions,
create_accumulators,
},
filter::batch_filter,
},
};
use itertools::Itertools;
/// stream struct for aggregation without grouping columns
pub struct AggregateStream {
schema: SchemaRef,
mode: AggregateMode,
aggregate_expressions: Vec<Vec<Arc<dyn PhysicalExpr>>>,
filter_expressions: Vec<Option<Arc<dyn PhysicalExpr>>>,
accumulators: Vec<AccumulatorItem>,
}
impl AggregateStream {
/// Create a new AggregateStream
pub fn new(agg: &AggregateExec) -> Result<Self> {
let agg_schema = agg.input().schema();
let agg_filter_expr = agg.filter_expr().to_vec();
let aggregate_expressions = aggregate_expressions(agg.aggr_expr(), agg.mode(), 0)?;
let filter_expressions = match *agg.mode() {
AggregateMode::Partial | AggregateMode::Single | AggregateMode::SinglePartitioned => {
agg_filter_expr
}
AggregateMode::Final
| AggregateMode::FinalPartitioned
| AggregateMode::PartialReduce => {
vec![None; agg.aggr_expr().len()]
}
};
let accumulators = create_accumulators(agg.aggr_expr())?;
Ok(AggregateStream {
schema: agg_schema,
mode: *agg.mode(),
aggregate_expressions,
filter_expressions,
accumulators,
})
}
pub fn aggregate_batch(&mut self, batch: RecordBatch) -> Result<()> {
let _ = aggregate_batch(
&self.mode,
batch,
&mut self.accumulators,
&self.aggregate_expressions,
&self.filter_expressions,
)?;
Ok(())
}
pub fn finalize_aggregation(&mut self) -> Result<Vec<RecordBatch>> {
let result = finalize_aggregation(&mut self.accumulators)?;
let batch = RecordBatch::try_new(Arc::clone(&self.schema), result)?;
// split the batch into multiple batches
let num_rows = batch.num_rows();
// early return for empty batches
if num_rows == 0 {
return Ok(vec![]);
}
// calculate optimal batch size and pre-allocate vector
let batch_size = 8192;
let full_batches = num_rows / batch_size;
let has_remaining = !num_rows.is_multiple_of(batch_size);
let total_batches = full_batches + if has_remaining { 1 } else { 0 };
let mut result_vec = Vec::with_capacity(total_batches);
for i in 0..full_batches {
result_vec.push(batch.slice(i * batch_size, batch_size));
}
if has_remaining {
let start_idx = full_batches * batch_size;
let remaining_rows = num_rows - start_idx;
result_vec.push(batch.slice(start_idx, remaining_rows));
}
Ok(result_vec)
}
}
fn aggregate_batch(
mode: &AggregateMode,
batch: RecordBatch,
accumulators: &mut [AccumulatorItem],
expressions: &[Vec<Arc<dyn PhysicalExpr>>],
filters: &[Option<Arc<dyn PhysicalExpr>>],
) -> Result<usize> {
let mut allocated = 0usize;
// 1.1 iterate accumulators and respective expressions together
// 1.2 filter the batch if necessary
// 1.3 evaluate expressions
// 1.4 update / merge accumulators with the expressions' values
// 1.1
accumulators
.iter_mut()
.zip(expressions)
.zip(filters)
.try_for_each(|((accum, expr), filter)| {
// 1.2
let batch = match filter {
Some(filter) => Cow::Owned(batch_filter(&batch, filter)?),
None => Cow::Borrowed(&batch),
};
// 1.3
let values = &expr
.iter()
.map(|e| {
e.evaluate(&batch)
.and_then(|v| v.into_array(batch.num_rows()))
})
.collect::<Result<Vec<_>>>()?;
// 1.4
let size_pre = accum.size();
let res = match mode {
AggregateMode::Partial
| AggregateMode::Single
| AggregateMode::SinglePartitioned => accum.update_batch(values),
AggregateMode::Final
| AggregateMode::FinalPartitioned
| AggregateMode::PartialReduce => accum.merge_batch(values),
};
let size_post = accum.size();
allocated += size_post.saturating_sub(size_pre);
res
})?;
Ok(allocated)
}
fn finalize_aggregation(accumulators: &mut [AccumulatorItem]) -> Result<Vec<ArrayRef>> {
// Build the vector of states
accumulators
.iter_mut()
.map(|accumulator| {
accumulator.state().and_then(|e| {
e.iter()
.map(|v| v.to_array())
.collect::<Result<Vec<ArrayRef>>>()
})
})
.flatten_ok()
.collect()
}

View File

@ -0,0 +1,187 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fmt::Debug, sync::Arc};
use arrow::array::RecordBatch;
use datafusion::{common::Result, physical_plan::aggregates::AggregateExec};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use crate::datafusion::aggregates::{
merge_phase::GroupedHashAggregateStream, no_grouping_merge_phase::AggregateStream,
};
pub(crate) struct CacheStream {
mode: CacheStreamMode,
target_partitions: usize,
aggregate_plan: Arc<AggregateExec>,
data: Vec<Arc<RecordBatch>>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CacheStreamMode {
Group,
NoGroup,
}
impl CacheStream {
fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub(crate) fn new(
has_group_by: bool,
target_partitions: usize,
aggregate_plan: Arc<AggregateExec>,
) -> Self {
Self {
mode: if has_group_by {
CacheStreamMode::Group
} else {
CacheStreamMode::NoGroup
},
target_partitions,
aggregate_plan,
data: Vec::new(),
}
}
}
pub(crate) struct CacheBuf {
pub(crate) total_partition_num: usize,
pub(crate) cached_partition_num: usize,
pub(crate) cached_buf: CacheStream,
}
impl Debug for CacheBuf {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CacheBuf")
}
}
impl CacheBuf {
pub(crate) fn append_data(&mut self, record_batch: Arc<RecordBatch>) {
self.cached_buf.data.push(record_batch);
}
pub(crate) fn check_and_add_partition(&mut self) -> bool {
self.cached_partition_num += 1;
if self.cached_partition_num >= self.total_partition_num {
return true;
}
false
}
pub(crate) fn get_final_result(&mut self, stream_id: &str) -> Result<Vec<RecordBatch>> {
if self.cached_buf.is_empty() {
return Ok(Vec::new());
}
let merge_mode = self.cached_buf.mode;
let start = std::time::Instant::now();
let record_batchs = std::mem::take(&mut self.cached_buf.data);
let record_batchs: Vec<Arc<RecordBatch>> = record_batchs
.into_iter()
.filter(|batch| batch.num_rows() != 0)
.collect();
let total_batch_len = record_batchs.len();
let partition_num = std::cmp::max(2, self.cached_buf.target_partitions);
// When partial_reduce is enabled each follower has already sent pre-merged data, so
// phase-1 (parallel chunked aggregation) would double-aggregate already-reduced values.
let partial_reduce_enabled = config::get_config().common.feature_partial_reduce_enabled;
let mut merged_batches: Vec<RecordBatch> = if partial_reduce_enabled {
// Phase 1 skipped — use follower results directly.
record_batchs
.into_iter()
.map(|b| b.as_ref().clone())
.collect()
} else {
let thread_pool = rayon::ThreadPoolBuilder::new()
.num_threads(partition_num)
.build()
.unwrap();
let chunk_size = std::cmp::max(1, total_batch_len / partition_num);
let batch_chunks: Vec<Vec<Arc<RecordBatch>>> = record_batchs
.chunks(chunk_size)
.map(|chunk| chunk.to_vec())
.collect();
// Phase 1: process batch_chunks in parallel using rayon
let partial_results: Vec<Result<Vec<RecordBatch>>> = thread_pool.install(|| {
batch_chunks
.into_par_iter()
.map(|batches| match merge_mode {
CacheStreamMode::Group => {
let mut stream =
GroupedHashAggregateStream::new(&self.cached_buf.aggregate_plan)
.unwrap();
for batch in batches {
stream.group_aggregate_batch(batch.as_ref().clone())?;
}
stream.get_final_result()
}
CacheStreamMode::NoGroup => {
let mut stream =
AggregateStream::new(&self.cached_buf.aggregate_plan).unwrap();
for batch in batches {
stream.aggregate_batch(batch.as_ref().clone())?;
}
stream.finalize_aggregation()
}
})
.collect()
});
let mut batches = Vec::new();
for partial_result in partial_results {
batches.extend(partial_result?);
}
batches
};
// Phase 2: final merge (always needed when there are multiple batches to combine)
match merge_mode {
CacheStreamMode::Group => {
let mut final_stream =
GroupedHashAggregateStream::new(&self.cached_buf.aggregate_plan).unwrap();
for batch in merged_batches {
final_stream.group_aggregate_batch(batch)?;
}
merged_batches = final_stream.get_final_result()?;
}
CacheStreamMode::NoGroup => {
let mut final_stream =
AggregateStream::new(&self.cached_buf.aggregate_plan).unwrap();
for batch in merged_batches {
final_stream.aggregate_batch(batch)?;
}
merged_batches = final_stream.finalize_aggregation()?;
}
}
log::info!(
"[StreamingAggs streaming_id: {stream_id}] merge_agg_batches from {total_batch_len} to {}, partial_reduce_enabled: {partial_reduce_enabled} unique_numbers: {}, partition_num: {partition_num}, chunk_size: {}, total_merge_times: {} ms",
merged_batches.len(),
merged_batches.iter().map(|b| b.num_rows()).sum::<usize>(),
std::cmp::max(1, total_batch_len / partition_num),
start.elapsed().as_millis(),
);
Ok(merged_batches)
}
}

View File

@ -25,7 +25,6 @@ mod deduplication_exec;
mod empty_exec;
mod enrichment_exec;
mod physical_plan_node;
#[cfg(feature = "enterprise")]
mod streaming_aggs_exec;
mod tmp_exec;

View File

@ -22,15 +22,14 @@ use datafusion::{
physical_plan::ExecutionPlan,
};
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::search::datafusion::distributed_plan::streaming_aggs_exec::exec::StreamingAggsExec;
use prost::Message;
use proto::cluster_rpc;
use crate::datafusion::{
distributed_plan::{
aggregate_topk_exec::AggregateTopkExec, empty_exec::NewEmptyExec,
enrichment_exec::EnrichmentExec, tmp_exec::TmpExec,
enrichment_exec::EnrichmentExec, streaming_aggs_exec::exec::StreamingAggsExec,
tmp_exec::TmpExec,
},
plan::deduplication_exec::DeduplicationExec,
};
@ -61,7 +60,6 @@ impl PhysicalExtensionCodec for PhysicalPlanNodePhysicalExtensionCodec {
Some(cluster_rpc::physical_plan_node::Plan::AggregateTopk(node)) => {
super::aggregate_topk_exec::try_decode(node, inputs, ctx)
}
#[cfg(feature = "enterprise")]
Some(cluster_rpc::physical_plan_node::Plan::StreamingAggs(node)) => {
super::streaming_aggs_exec::try_decode(node, inputs, ctx)
}
@ -71,10 +69,6 @@ impl PhysicalExtensionCodec for PhysicalPlanNodePhysicalExtensionCodec {
Some(cluster_rpc::physical_plan_node::Plan::EnrichmentExec(node)) => {
super::enrichment_exec::try_decode(node, inputs, ctx)
}
#[cfg(not(feature = "enterprise"))]
Some(_) => {
internal_err!("Not supported")
}
None => {
internal_err!("PhysicalPlanNode is required")
}
@ -82,7 +76,6 @@ impl PhysicalExtensionCodec for PhysicalPlanNodePhysicalExtensionCodec {
}
fn try_encode(&self, node: Arc<dyn ExecutionPlan>, buf: &mut Vec<u8>) -> Result<()> {
#[cfg(feature = "enterprise")]
if node.downcast_ref::<NewEmptyExec>().is_some() {
super::empty_exec::try_encode(node, buf)
} else if node.downcast_ref::<DeduplicationExec>().is_some() {
@ -98,20 +91,6 @@ impl PhysicalExtensionCodec for PhysicalPlanNodePhysicalExtensionCodec {
} else {
internal_err!("Not supported")
}
#[cfg(not(feature = "enterprise"))]
if node.downcast_ref::<NewEmptyExec>().is_some() {
super::empty_exec::try_encode(node, buf)
} else if node.downcast_ref::<DeduplicationExec>().is_some() {
super::deduplication_exec::try_encode(node, buf)
} else if node.downcast_ref::<AggregateTopkExec>().is_some() {
super::aggregate_topk_exec::try_encode(node, buf)
} else if node.downcast_ref::<TmpExec>().is_some() {
super::tmp_exec::try_encode(node, buf)
} else if node.downcast_ref::<EnrichmentExec>().is_some() {
super::enrichment_exec::try_encode(node, buf)
} else {
internal_err!("Not supported")
}
}
}

View File

@ -22,10 +22,11 @@ use datafusion::{
physical_plan::{ExecutionPlan, aggregates::AggregateExec},
};
use datafusion_proto::{physical_plan::AsExecutionPlan, protobuf::PhysicalPlanNode};
use o2_enterprise::enterprise::search::datafusion::distributed_plan::streaming_aggs_exec::exec::StreamingAggsExec;
use prost::Message;
use proto::cluster_rpc;
use crate::datafusion::distributed_plan::streaming_aggs_exec::exec::StreamingAggsExec;
pub fn try_decode(
node: cluster_rpc::StreamingAggsExecNode,
inputs: &[Arc<dyn ExecutionPlan>],
@ -116,7 +117,6 @@ mod tests {
use datafusion_proto::bytes::{
physical_plan_from_bytes_with_extension_codec, physical_plan_to_bytes_with_extension_codec,
};
use o2_enterprise::enterprise::search::datafusion::distributed_plan::streaming_aggs_exec::exec::StreamingAggsExec;
use super::*;
use crate::datafusion::udf::str_match_udf::STR_MATCH_UDF;

View File

@ -0,0 +1,185 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::Arc;
use arrow::array::{ArrayRef, Int64Array, RecordBatch, UInt64Array};
use datafusion::{
arrow::datatypes::{DataType, SchemaRef},
common::{Result, internal_err},
execution::{SendableRecordBatchStream, TaskContext},
physical_expr::{EquivalenceProperties, Partitioning},
physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
execution_plan::{Boundedness, EmissionType},
memory::MemoryStream,
},
};
#[derive(Debug)]
pub struct MetadataCountExec {
schema: SchemaRef,
records: i64,
files: usize,
cache: Arc<PlanProperties>,
}
impl MetadataCountExec {
pub fn new(schema: SchemaRef, records: i64, files: usize) -> Self {
let cache = Arc::new(PlanProperties::new(
EquivalenceProperties::new(schema.clone()),
Partitioning::UnknownPartitioning(1),
EmissionType::Final,
Boundedness::Bounded,
));
Self {
schema,
records,
files,
cache,
}
}
fn data(&self) -> Result<Vec<RecordBatch>> {
if self.schema.fields().len() != 1 {
return internal_err!(
"MetadataCountExec expected one count field, got {}",
self.schema.fields().len()
);
}
let records = self.records.max(0);
let array: ArrayRef = match self.schema.field(0).data_type() {
DataType::Int64 => Arc::new(Int64Array::from(vec![records])),
DataType::UInt64 => Arc::new(UInt64Array::from(vec![records as u64])),
other => {
return internal_err!("MetadataCountExec unsupported count type: {other:?}");
}
};
RecordBatch::try_new(self.schema.clone(), vec![array])
.map(|batch| vec![batch])
.map_err(|e| datafusion::error::DataFusionError::Internal(e.to_string()))
}
}
impl DisplayAs for MetadataCountExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"MetadataCountExec: files: {}, records: {}",
self.files, self.records
)
}
}
impl ExecutionPlan for MetadataCountExec {
fn name(&self) -> &'static str {
"MetadataCountExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.cache
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
_: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
Ok(self)
}
fn execute(
&self,
partition: usize,
_context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
if partition >= 1 {
return internal_err!(
"MetadataCountExec invalid partition {partition} (expected partition: 0)"
);
}
Ok(Box::pin(MemoryStream::try_new(
self.data()?,
self.schema.clone(),
None,
)?))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use datafusion::{
arrow::datatypes::{DataType, Field, Schema},
physical_plan::ExecutionPlan,
};
use super::*;
#[test]
fn test_metadata_count_exec_creates_single_count_row() {
let schema = Arc::new(Schema::new(vec![Field::new(
"count",
DataType::Int64,
false,
)]));
let exec = MetadataCountExec::new(schema, 42, 3);
let batches = exec.data().unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_rows(), 1);
let values = batches[0]
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(values.value(0), 42);
}
#[test]
fn test_metadata_count_exec_plan_name() {
let schema = Arc::new(Schema::new(vec![Field::new(
"count",
DataType::UInt64,
false,
)]));
let exec = MetadataCountExec::new(schema, 7, 2);
assert_eq!(ExecutionPlan::name(&exec), "MetadataCountExec");
}
#[test]
fn test_metadata_count_exec_display_includes_files_and_records() {
let schema = Arc::new(Schema::new(vec![Field::new(
"count",
DataType::UInt64,
false,
)]));
let exec = Arc::new(MetadataCountExec::new(schema, 7, 2));
let display = format!(
"{}",
datafusion::physical_plan::displayable(exec.as_ref()).indent(false)
);
assert!(display.contains("MetadataCountExec: files: 2, records: 7"));
}
}

View File

@ -27,6 +27,7 @@ use crate::datafusion::distributed_plan::empty_exec::NewEmptyExec;
pub mod aggregate_topk_exec;
pub mod broadcast_join_exec;
pub(crate) mod cache_buf;
pub mod codec;
mod common;
mod decoder_stream;
@ -35,10 +36,12 @@ pub mod distribute_analyze_exec;
pub mod empty_exec;
pub mod enrich_exec;
pub mod enrichment_exec;
pub mod metadata_count_exec;
pub mod node;
mod once_async;
pub mod remote_scan_exec;
pub mod rewrite;
pub mod streaming_aggs_exec;
pub mod tmp_exec;
mod utils;

View File

@ -28,11 +28,13 @@ use datafusion::{
union::UnionExec,
},
};
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::search::datafusion::distributed_plan::metadata_count_exec::MetadataCountExec;
use crate::{
datafusion::plan::tantivy_optimize_exec::TantivyOptimizeExec, index::IndexCondition,
datafusion::{
distributed_plan::metadata_count_exec::MetadataCountExec,
plan::tantivy_optimize_exec::TantivyOptimizeExec,
},
index::IndexCondition,
types::QueryParams,
};
@ -68,9 +70,7 @@ pub struct AggregateOptimizeRewriter {
file_list: Vec<FileKey>,
index_condition: Option<IndexCondition>,
index_optimize_mode: Option<IndexOptimizeMode>,
#[allow(unused)]
metadata_records: i64,
#[allow(unused)]
metadata_files: usize,
}
@ -105,7 +105,6 @@ impl AggregateOptimizeRewriter {
))
}
#[cfg(feature = "enterprise")]
fn metadata_count_exec(&self, schema: SchemaRef) -> Result<Arc<dyn ExecutionPlan>> {
Ok(Arc::new(MetadataCountExec::new(
schema,
@ -117,7 +116,6 @@ impl AggregateOptimizeRewriter {
fn additional_inputs(&mut self, schema: SchemaRef) -> Result<Vec<Arc<dyn ExecutionPlan>>> {
let mut inputs = Vec::new();
#[cfg(feature = "enterprise")]
if self.metadata_records > 0 {
inputs.push(self.metadata_count_exec(schema.clone())?);
}
@ -153,9 +151,7 @@ mod tests {
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema};
#[cfg(feature = "enterprise")]
use config::meta::stream::FileMeta;
use config::meta::stream::{FileKey, StreamType};
use config::meta::stream::{FileKey, FileMeta, StreamType};
use datafusion::{
common::Result,
functions_aggregate::count::count_udaf,
@ -208,7 +204,6 @@ mod tests {
)?))
}
#[cfg(feature = "enterprise")]
#[test]
fn test_aggregate_optimize_rewrite_combines_metadata_and_tantivy_inputs() -> Result<()> {
let plan = partial_count_exec()?;

View File

@ -0,0 +1,162 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use arrow::{array::RecordBatch, datatypes::SchemaRef};
use datafusion::{common::Result, execution::RecordBatchStream};
use futures::Stream;
use crate::cache::streaming_agg::get_record_batches;
pub(crate) struct CachedFileStream {
id: String,
cached_files: Vec<Arc<String>>,
schema: SchemaRef,
current_file_index: usize,
current_batches: Vec<RecordBatch>,
current_batch_index: usize,
is_exhausted: bool,
}
impl CachedFileStream {
pub(crate) fn new(id: String, cached_files: Vec<Arc<String>>, schema: SchemaRef) -> Self {
Self {
id,
cached_files,
schema,
current_file_index: 0,
current_batches: Vec::new(),
current_batch_index: 0,
is_exhausted: false,
}
}
pub(crate) fn load_next_file(&mut self) -> Result<()> {
loop {
if self.current_file_index >= self.cached_files.len() {
self.is_exhausted = true;
return Ok(());
}
let file_path = &self.cached_files[self.current_file_index];
let batches = match get_record_batches(&self.id, file_path, self.schema.clone()) {
Ok(batches) => batches,
Err(e) => {
log::error!(
"[StreamingAggs streaming_id: {}] Error reading cached file: {file_path}, error: {e:?}",
self.id,
);
return Err(e.into());
}
};
log::debug!(
"[StreamingAggs streaming_id: {}] Successfully read {} batches from cached file: {file_path}",
self.id,
batches.len(),
);
self.current_batches = batches;
self.current_batch_index = 0;
self.current_file_index += 1;
if self.current_batches.is_empty() {
continue;
}
break;
}
Ok(())
}
}
impl Stream for CachedFileStream {
type Item = Result<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.is_exhausted {
return Poll::Ready(None);
}
// If we don't have current batches or we've exhausted them, load next file
if self.current_batches.is_empty() || self.current_batch_index >= self.current_batches.len()
{
if let Err(e) = self.load_next_file() {
return Poll::Ready(Some(Err(e)));
}
if self.is_exhausted {
return Poll::Ready(None);
}
}
// Return the next batch if available
if self.current_batch_index < self.current_batches.len() {
let batch = self.current_batches[self.current_batch_index].clone();
self.current_batch_index += 1;
Poll::Ready(Some(Ok(batch)))
} else {
Poll::Ready(None)
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
(0, None)
}
}
impl RecordBatchStream for CachedFileStream {
fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema};
use super::*;
#[test]
fn test_cached_file_stream_new() {
let schema = Arc::new(Schema::new(vec![Field::new(
"col1",
DataType::Int64,
false,
)]));
let cached_files = vec![
Arc::new("file1.arrow".to_string()),
Arc::new("file2.arrow".to_string()),
];
let stream = CachedFileStream::new(
"test_cached_stream".to_string(),
cached_files.clone(),
schema.clone(),
);
assert_eq!(stream.id, "test_cached_stream");
assert_eq!(stream.cached_files.len(), 2);
assert_eq!(stream.current_file_index, 0);
}
}

View File

@ -0,0 +1,305 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fmt::Debug, sync::Arc};
use arrow::datatypes::SchemaRef;
use datafusion::{
common::Result,
error::DataFusionError,
execution::{SendableRecordBatchStream, TaskContext},
physical_expr::EquivalenceProperties,
physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, Partitioning,
PlanProperties,
aggregates::AggregateExec,
execution_plan::{Boundedness, EmissionType},
},
};
use parking_lot::Mutex;
use crate::datafusion::distributed_plan::{
cache_buf::{CacheBuf, CacheStream},
streaming_aggs_exec::{cached_file_stream::CachedFileStream, monitor_stream::MonitorStream},
};
#[derive(Debug)]
pub struct StreamingAggsExec {
id: String,
start_time: i64,
end_time: i64,
input: Arc<dyn ExecutionPlan>,
/// Cache holding plan properties like equivalences, output partitioning etc.
cache: Arc<PlanProperties>,
cached_files: Vec<Arc<String>>,
cached_partition_num: usize,
target_partitions: usize,
is_complete_cache_hit: bool,
aggregate_plan: Arc<AggregateExec>,
cache_buf: Arc<Mutex<CacheBuf>>,
overwrite_cache: bool,
}
impl StreamingAggsExec {
/// Create a new StreamingAggsExec with explicit cache strategy
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
start_time: i64,
end_time: i64,
cached_files: Vec<Arc<String>>,
input: Arc<dyn ExecutionPlan>,
target_partitions: usize,
is_complete_cache_hit: bool,
aggregate_plan: Arc<AggregateExec>,
overwrite_cache: bool,
) -> Self {
let cached_partition_num = if cached_files.is_empty() { 0 } else { 1 };
let total_partition_num = if is_complete_cache_hit {
cached_partition_num
} else {
// Partial or no cache: cached partitions + input partitions
let input_partitions = input.output_partitioning().partition_count();
input_partitions + cached_partition_num
};
let cache = Self::compute_properties(Arc::clone(&input.schema()), total_partition_num);
let cached_buf = CacheStream::new(
!aggregate_plan.group_expr().is_empty(),
target_partitions,
aggregate_plan.clone(),
);
Self {
id,
start_time,
end_time,
input,
cache,
cached_files,
cached_partition_num,
target_partitions,
is_complete_cache_hit,
aggregate_plan,
cache_buf: Arc::new(Mutex::new(CacheBuf {
total_partition_num,
cached_partition_num,
cached_buf,
})),
overwrite_cache,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn start_time(&self) -> i64 {
self.start_time
}
pub fn end_time(&self) -> i64 {
self.end_time
}
pub fn target_partitions(&self) -> usize {
self.target_partitions
}
pub fn is_complete_cache_hit(&self) -> bool {
self.is_complete_cache_hit
}
pub fn cached_files(&self) -> &[Arc<String>] {
&self.cached_files
}
pub fn aggregate_plan(&self) -> &Arc<AggregateExec> {
&self.aggregate_plan
}
pub fn overwrite_cache(&self) -> bool {
self.overwrite_cache
}
pub(crate) fn output_partitioning_helper(n_partitions: usize) -> Partitioning {
Partitioning::UnknownPartitioning(n_partitions)
}
/// This function creates the cache object that stores the plan properties such as schema,
/// equivalence properties, ordering, partitioning, etc.
pub(crate) fn compute_properties(
schema: SchemaRef,
n_partitions: usize,
) -> Arc<PlanProperties> {
let eq_properties = EquivalenceProperties::new(schema);
let output_partitioning = Self::output_partitioning_helper(n_partitions);
Arc::new(PlanProperties::new(
eq_properties,
// Output Partitioning
output_partitioning,
// Execution Mode
EmissionType::Incremental,
Boundedness::Bounded,
))
}
}
impl DisplayAs for StreamingAggsExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match t {
DisplayFormatType::Default | DisplayFormatType::Verbose => {
let strategy = if self.is_complete_cache_hit {
"complete_hit"
} else {
"miss"
};
write!(
f,
"StreamingAggsExec: streaming_id={}, cache_strategy={strategy}, cached_partitions={}, total_partitions={}",
self.id,
self.cached_partition_num,
self.properties().output_partitioning().partition_count()
)
}
DisplayFormatType::TreeRender => {
let strategy = if self.is_complete_cache_hit {
"complete_hit"
} else {
"miss"
};
_ = writeln!(f, "streaming_id={}", self.id);
_ = writeln!(f, "cache_strategy={strategy}",);
_ = writeln!(f, "cached_partitions={}", self.cached_partition_num);
_ = writeln!(
f,
"total_partitions={}",
self.properties().output_partitioning().partition_count()
);
Ok(())
}
}
}
}
impl ExecutionPlan for StreamingAggsExec {
fn name(&self) -> &'static str {
"StreamingAggsExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.cache
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.input]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
Ok(Arc::new(StreamingAggsExec::new(
self.id.clone(),
self.start_time,
self.end_time,
self.cached_files.clone(),
children[0].clone(),
self.target_partitions,
self.is_complete_cache_hit,
self.aggregate_plan.clone(),
self.overwrite_cache,
)))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
// Complete cache hit: only return cached data, never execute input
if self.is_complete_cache_hit {
log::debug!(
"[StreamingAggs streaming_id: {}] Complete cache hit: returning cached data for partition {}/{}",
self.id,
partition,
self.cached_partition_num
);
if partition < self.cached_partition_num {
log::debug!(
"[StreamingAggs streaming_id: {}] EXECUTING with cached files for partition {} (complete cache hit), time_range=[{}, {}], files: {:?}",
self.id,
partition,
self.start_time,
self.end_time,
self.cached_files
);
// Create a lazy stream that will read cached files on demand
return Ok(Box::pin(CachedFileStream::new(
self.id.clone(),
self.cached_files.clone(),
self.input.schema(),
)));
} else {
// This should never happen with complete cache hit
return Err(DataFusionError::Internal(format!(
"StreamingAggsExec: Invalid partition {} for complete cache hit with {} cached partitions",
partition, self.cached_partition_num
)));
}
}
log::debug!(
"[StreamingAggs streaming_id: {}] Partial cache hit: partition={}, cached_partitions={}, executing input for new data",
self.id,
partition,
self.cached_partition_num
);
// Partial or no cache: handle both cached and input partitions
if partition < self.cached_partition_num {
log::debug!(
"[StreamingAggs streaming_id: {}] EXECUTING with cached files for partition {} (partial cache hit), time_range=[{}, {}], files: {:?}",
self.id,
partition,
self.start_time,
self.end_time,
self.cached_files
);
return Ok(Box::pin(CachedFileStream::new(
self.id.clone(),
self.cached_files.clone(),
self.input.schema(),
)));
}
// Execute input for missing data
Ok(Box::pin(MonitorStream::new(
self.id.clone(),
self.start_time,
self.end_time,
self.input.schema(),
self.cache_buf.clone(),
self.input
.execute(partition - self.cached_partition_num, context)?,
self.overwrite_cache,
)))
}
fn benefits_from_input_partitioning(&self) -> Vec<bool> {
vec![false; self.children().len()]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use arrow::{array::RecordBatch, datatypes::SchemaRef};
use datafusion::{
common::Result,
execution::{RecordBatchStream, SendableRecordBatchStream},
};
use futures::{Stream, StreamExt};
use futures_util::ready;
use parking_lot::Mutex;
use crate::{
cache::streaming_agg::{
RecordBatchCacheRequest, cache_record_batches_to_disk,
generate_aggregation_cache_file_name, get_cache_file_path,
},
datafusion::distributed_plan::{
cache_buf::CacheBuf,
streaming_aggs_exec::{GLOBAL_CACHE, get_cache_file_path_from_streaming_id},
},
};
pub(crate) struct MonitorStream {
id: String,
start_time: i64,
end_time: i64,
schema: SchemaRef,
stream: SendableRecordBatchStream,
root_cache_buf: Arc<Mutex<CacheBuf>>,
done: bool,
overwrite_cache: bool,
}
impl MonitorStream {
pub(crate) fn new(
id: String,
start_time: i64,
end_time: i64,
schema: SchemaRef,
root_cache_buf: Arc<Mutex<CacheBuf>>,
stream: SendableRecordBatchStream,
overwrite_cache: bool,
) -> Self {
Self {
id,
start_time,
end_time,
schema,
stream,
root_cache_buf,
done: false,
overwrite_cache,
}
}
pub fn is_complete_partition_window(&self) -> bool {
let interval = GLOBAL_CACHE.get_cache_interval(&self.id); // minutes
let interval_micros = interval * 60 * 1_000_000; // microseconds
(self.end_time - self.start_time) == interval_micros
}
pub fn append_to_cache_buf(&mut self, record_batch: Arc<RecordBatch>) {
self.root_cache_buf.lock().append_data(record_batch);
}
pub fn check_and_add_partition(&mut self) -> bool {
self.root_cache_buf.lock().check_and_add_partition()
}
}
impl Stream for MonitorStream {
type Item = Result<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.done {
return Poll::Ready(None);
}
let res = ready!(self.stream.poll_next_unpin(cx));
Poll::Ready(match res {
Some(Ok(record_batch)) => {
self.append_to_cache_buf(Arc::new(record_batch.clone()));
Some(Ok(record_batch))
}
None => {
self.done = true;
let partition_done = self.check_and_add_partition();
let streaming_done = partition_done
&& GLOBAL_CACHE
.id_cache
.check_time(&self.id, self.start_time, self.end_time);
let file_path = get_cache_file_path_from_streaming_id(&self.id)?;
let file_name = generate_aggregation_cache_file_name(
&self.id,
self.start_time,
self.end_time,
self.is_complete_partition_window(),
);
// Start - Cache record batches to disk
if partition_done && (!streaming_done || self.is_complete_partition_window()) {
let result_vec = self.root_cache_buf.lock().get_final_result(&self.id)?;
let file_path = get_cache_file_path(&file_path, &file_name);
let request = RecordBatchCacheRequest {
streaming_id: self.id.clone(),
file_path: file_path.clone(),
schema: self.schema.clone(),
records: result_vec.into_iter().map(Arc::new).collect(),
overwrite_cache: self.overwrite_cache,
};
let start = std::time::Instant::now();
match cache_record_batches_to_disk(request) {
Ok(()) => {
// add to cache list
GLOBAL_CACHE.insert(self.id.clone(), file_path);
}
Err(e) => {
log::error!(
"[streaming_id: {}] Error caching streaming aggs record batchesto disk file: {file_path}, error: {e:?}",
self.id,
);
}
}
log::info!(
"[streaming_id: {}] cache_record_batches_to_disk time: {} ms",
self.id,
start.elapsed().as_millis()
);
}
// End - Cache record batches to disk
None
}
Some(Err(e)) => {
log::error!("[streaming_id: {}] Error in MonitorStream: {e}", self.id);
Some(Err(e))
}
})
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.stream.size_hint()
}
}
impl RecordBatchStream for MonitorStream {
/// Get the schema
fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::{
execution::SendableRecordBatchStream,
physical_plan::{aggregates::AggregateExec, memory::MemoryStream},
};
use tokio::sync::mpsc;
use super::*;
use crate::datafusion::distributed_plan::cache_buf::CacheStream;
#[test]
fn test_monitor_stream_new() {
let schema = Arc::new(Schema::new(vec![Field::new(
"col1",
DataType::Int64,
false,
)]));
// Create a dummy stream
let batches = vec![];
let memory_stream = MemoryStream::try_new(batches, schema.clone(), None).unwrap();
let input_stream: SendableRecordBatchStream = Box::pin(memory_stream);
let (_tx, _rx): (
tokio::sync::mpsc::Sender<()>,
tokio::sync::mpsc::Receiver<()>,
) = mpsc::channel(1);
// Create a dummy cache buffer for MonitorStream
let cache_buf = Arc::new(parking_lot::Mutex::new(CacheBuf {
total_partition_num: 1,
cached_partition_num: 0,
cached_buf: CacheStream::new(
false,
1,
Arc::new(
AggregateExec::try_new(
datafusion::physical_plan::aggregates::AggregateMode::Partial,
datafusion::physical_plan::aggregates::PhysicalGroupBy::new_single(vec![]),
vec![],
vec![],
Arc::new(datafusion::physical_plan::empty::EmptyExec::new(
schema.clone(),
)),
schema.clone(),
)
.unwrap(),
),
),
}));
// Test MonitorStream::new
let monitor_stream = MonitorStream::new(
"test_monitor".to_string(),
1000,
2000,
schema.clone(),
cache_buf,
input_stream,
false,
);
// Verify initial state
assert_eq!(monitor_stream.id, "test_monitor");
assert_eq!(monitor_stream.start_time, 1000);
assert_eq!(monitor_stream.end_time, 2000);
}
}

View File

@ -347,8 +347,11 @@ pub fn register_builtin_udfs(ctx: &SessionContext) {
pub fn registered_function_names() -> &'static [String] {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
let ctx = SessionContext::new();
let mut ctx = SessionContext::new();
register_builtin_udfs(&ctx);
// Production contexts register these separately (see flight.rs); without
// the same call here the whole json_* family is missing from the catalog.
let _ = datafusion_functions_json::register_all(&mut ctx);
let state = ctx.state();
let mut names: Vec<String> = state.scalar_functions().keys().cloned().collect();
names.extend(state.aggregate_functions().keys().cloned());
@ -359,6 +362,136 @@ pub fn registered_function_names() -> &'static [String] {
})
}
/// One entry of the SQL function catalog served to the query editor.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, utoipa::ToSchema)]
pub struct CatalogFunction {
pub name: String,
/// Argument list, e.g. "(field, k)". Drives the editor's `detail` column.
pub signature: String,
/// Prose for the editor's documentation panel. May be empty for upstream
/// DataFusion functions that carry no documentation of their own.
pub doc: String,
/// "udf" | "scalar" | "aggregate" | "window" | "vrl"
pub kind: String,
pub deprecated: bool,
}
/// Argument list and prose for a registry function.
///
/// `Signature` has no Display and describes accepted TYPES, not argument names,
/// so DataFusion's own documentation is the only source of a readable argument
/// list: prefer its named arguments, fall back to the paren group of its syntax
/// example, and finally to an opaque placeholder.
fn describe_registry_fn(doc: Option<&datafusion::logical_expr::Documentation>) -> (String, String) {
let Some(d) = doc else {
return ("(...)".to_string(), String::new());
};
if let Some(args) = &d.arguments
&& !args.is_empty()
{
let names: Vec<&str> = args.iter().map(|(n, _)| n.as_str()).collect();
return (format!("({})", names.join(", ")), d.description.clone());
}
let sig = d
.syntax_example
.find('(')
.map(|i| d.syntax_example[i..].to_string())
.unwrap_or_else(|| "(...)".to_string());
(sig, d.description.clone())
}
/// The org-INDEPENDENT half of the catalog: the DataFusion registry (including
/// the JSON family) plus the SQL-rewriter aliases.
///
/// Snapshotted once. Building it means standing up a SessionContext and
/// registering ~350 functions, which is far too much to repeat per HTTP
/// request; `registered_function_names` above caches for the same reason.
fn base_catalog_functions() -> &'static [CatalogFunction] {
static BASE: std::sync::OnceLock<Vec<CatalogFunction>> = std::sync::OnceLock::new();
BASE.get_or_init(|| {
let mut ctx = SessionContext::new();
register_builtin_udfs(&ctx);
let _ = datafusion_functions_json::register_all(&mut ctx);
let state = ctx.state();
let mut out: Vec<CatalogFunction> = Vec::new();
let mut push = |name: String, signature: String, doc: String, kind: &str, deprecated| {
out.push(CatalogFunction {
name,
signature,
doc,
kind: kind.to_string(),
deprecated,
});
};
for (name, udf) in state.scalar_functions() {
let (sig, doc) = describe_registry_fn(udf.documentation());
push(name.clone(), sig, doc, "scalar", false);
}
for (name, udf) in state.aggregate_functions() {
let (sig, doc) = describe_registry_fn(udf.documentation());
push(name.clone(), sig, doc, "aggregate", false);
}
for (name, udf) in state.window_functions() {
let (sig, doc) = describe_registry_fn(udf.documentation());
push(name.clone(), sig, doc, "window", false);
}
// Valid SQL that appears in no registry: a rewriter desugars these
// before planning.
for alias in crate::sql::rewriter::REWRITER_FUNCTION_ALIASES {
let target = crate::sql::rewriter::rewriter_alias_target(alias).unwrap_or("match_all");
push(
(*alias).to_string(),
"(term)".to_string(),
format!("Deprecated alias for `{target}` — rewritten before planning."),
"udf",
true,
);
}
out
})
}
/// Every function this org can call: the shared registry above plus the org's
/// own VRL transforms.
pub fn catalog_functions(org_id: &str) -> Vec<CatalogFunction> {
// Keyed by name so the result is sorted and deduplicated for free, and so a
// later insert wins — which is what makes the org's VRL transforms override
// a same-named builtin, exactly as register_udf does at query time.
let mut by_name: std::collections::BTreeMap<String, CatalogFunction> = base_catalog_functions()
.iter()
.map(|f| (f.name.clone(), f.clone()))
.collect();
let org_prefix = format!("{org_id}/");
for transform in transform::QUERY_FUNCTIONS.iter() {
if !transform.key().starts_with(&org_prefix) {
continue;
}
let args: Vec<String> = (1..=transform.num_args)
.map(|i| format!("arg{i}"))
.collect();
by_name.insert(
transform.name.clone(),
CatalogFunction {
name: transform.name.clone(),
signature: format!("({})", args.join(", ")),
doc: format!(
"Organisation VRL function `{}` ({} argument(s)).",
transform.name, transform.num_args
),
kind: "vrl".to_string(),
deprecated: false,
},
);
}
by_name.into_values().collect()
}
pub async fn register_metrics_table(
session: &SearchSession,
schema: Arc<Schema>,
@ -786,6 +919,364 @@ mod tests {
Ok(())
}
// ── tmp/code.md B4 — the function catalog served to the query editor ─────
//
// registered_function_names() is the authoritative list. Two gaps were
// found reviewing it: JSON functions are registered by a SEPARATE call that
// the snapshot context never made, and functions provided by SQL rewriters
// never appear in any registry at all.
#[test]
fn registered_function_names_includes_o2_udfs() {
let names = registered_function_names();
for expected in [
"str_match",
"match_all",
"histogram",
"spath",
"arrcount",
"re_match",
"cast_to_timestamp",
] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the O2 UDF `{expected}`"
);
}
}
#[test]
fn registered_function_names_includes_datafusion_builtins() {
let names = registered_function_names();
for expected in ["date_trunc", "coalesce", "concat", "regexp_replace", "abs"] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the DataFusion builtin `{expected}`"
);
}
}
#[test]
fn registered_function_names_includes_json_functions() {
// Production contexts call datafusion_functions_json::register_all
// separately (see flight.rs). Without that call here the whole json_*
// family is absent from the catalog the editor is served.
let names = registered_function_names();
for expected in ["json_get", "json_get_str", "json_length"] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the JSON function `{expected}` — is datafusion_functions_json::register_all wired into the snapshot context?"
);
}
}
#[test]
fn registered_function_names_is_sorted_and_deduped() {
let names = registered_function_names();
assert!(!names.is_empty());
let mut sorted = names.to_vec();
sorted.sort();
sorted.dedup();
assert_eq!(
names,
sorted.as_slice(),
"names must be sorted and deduplicated"
);
}
#[test]
fn rewriter_aliases_are_exposed_for_the_catalog() {
// match_all_raw / match_all_raw_ignore_case are valid user-facing SQL
// (sql/rewriter/match_all_raw.rs rewrites them to match_all before
// planning) but appear in NO registry. A catalog built only from the
// registry would silently drop two functions users rely on today.
let aliases = crate::sql::rewriter::REWRITER_FUNCTION_ALIASES;
for expected in ["match_all_raw", "match_all_raw_ignore_case"] {
assert!(
aliases.contains(&expected),
"rewriter alias `{expected}` is not exported for the catalog"
);
}
}
#[test]
fn catalog_functions_unions_registry_rewriter_and_json() {
let catalog = catalog_functions("default");
let names: Vec<&str> = catalog.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"match_all"), "registry entry missing");
assert!(
names.contains(&"match_all_raw"),
"rewriter alias missing from the catalog union"
);
assert!(names.contains(&"json_get"), "json function missing");
assert!(names.contains(&"date_trunc"), "datafusion builtin missing");
}
#[test]
fn catalog_functions_returns_structured_entries() {
// The editor needs more than names: `detail` and `documentation` in the
// suggest widget come from signature/doc, and the icon from kind.
let catalog = catalog_functions("default");
let match_all = catalog
.iter()
.find(|f| f.name == "match_all")
.expect("match_all should be in the catalog");
assert!(
!match_all.signature.is_empty(),
"signature must be populated"
);
assert!(!match_all.kind.is_empty(), "kind must be populated");
}
#[test]
fn catalog_functions_flags_rewriter_aliases_deprecated() {
let catalog = catalog_functions("default");
let raw = catalog
.iter()
.find(|f| f.name == "match_all_raw")
.expect("match_all_raw should be in the catalog");
assert!(
raw.deprecated,
"rewriter aliases must be flagged deprecated"
);
let canonical = catalog.iter().find(|f| f.name == "match_all").unwrap();
assert!(!canonical.deprecated, "match_all itself is not deprecated");
}
#[test]
fn catalog_functions_serialize_with_every_field_the_editor_needs() {
// Asserting on the Rust struct does not prove the HTTP body carries the
// fields: a rename or a skip_serializing_if would pass the struct tests
// and still ship an empty docs panel.
let catalog = catalog_functions("default");
let json = serde_json::to_value(&catalog).expect("catalog must serialize");
let arr = json.as_array().expect("catalog serializes to an array");
for entry in arr {
for field in ["name", "signature", "kind", "deprecated"] {
assert!(
entry.get(field).is_some(),
"serialized entry {entry} is missing `{field}`"
);
}
}
}
#[test]
fn every_catalog_entry_has_a_name_kind_and_signature() {
// Sweeps the WHOLE catalog: a single spot check on match_all would let
// every DataFusion, JSON, alias or VRL entry ship blank metadata.
for f in catalog_functions("default") {
assert!(!f.name.is_empty(), "entry with an empty name");
assert!(!f.kind.is_empty(), "`{}` has no kind", f.name);
assert!(!f.signature.is_empty(), "`{}` has no signature", f.name);
}
}
#[test]
fn catalog_functions_documents_what_only_the_SERVER_can_supply() {
// Deliberately NOT asserting docs for the O2 UDFs. The frontend catalog
// carries its own prose for those and wins on merge (it also owns which
// arguments are columns), so a server-side doc for `match_all` would
// never be displayed — dead data dressed up as coverage.
//
// What the server IS the only source for: the rewriter aliases and the
// org's VRL transforms.
let catalog = catalog_functions("default");
for alias in crate::sql::rewriter::REWRITER_FUNCTION_ALIASES {
let entry = catalog
.iter()
.find(|f| &f.name == alias)
.unwrap_or_else(|| panic!("`{alias}` missing from the catalog"));
assert!(
!entry.doc.is_empty(),
"rewriter alias `{alias}` must be documented"
);
assert!(
entry.deprecated,
"rewriter alias `{alias}` must be flagged deprecated"
);
}
}
#[test]
fn catalog_functions_surfaces_upstream_documentation_where_it_exists() {
// Guards the describe() fallback: if it silently returned "(...)" and an
// empty doc for everything, every other assertion here would still pass.
let catalog = catalog_functions("default");
let documented = catalog.iter().filter(|f| !f.doc.is_empty()).count();
let with_named_args = catalog
.iter()
.filter(|f| f.signature != "(...)" && f.signature != "()")
.count();
assert!(
documented > 20,
"only {documented} entries carry documentation — is describe() reading upstream docs?"
);
assert!(
with_named_args > 20,
"only {with_named_args} entries have a named argument list"
);
}
#[test]
fn org_vrl_transforms_are_documented_and_signed() {
use config::meta::function::Transform;
transform::QUERY_FUNCTIONS.insert(
"doc_org/documented_fn".to_string(),
Transform {
function: ".".to_string(),
name: "documented_fn".to_string(),
params: "row".to_string(),
num_args: 1,
trans_type: Some(0),
streams: None,
},
);
let catalog = catalog_functions("doc_org");
let entry = catalog
.iter()
.find(|f| f.name == "documented_fn")
.expect("org transform missing");
assert!(!entry.doc.is_empty(), "org VRL transforms must carry a doc");
assert!(
!entry.signature.is_empty(),
"org VRL transforms must carry a signature"
);
transform::QUERY_FUNCTIONS.remove("doc_org/documented_fn");
}
#[test]
fn org_vrl_transform_overrides_a_same_named_builtin() {
// register_udf lets an org's VRL transform shadow a builtin at query
// time, so the catalog must report the one that would actually run.
use config::meta::function::Transform;
transform::QUERY_FUNCTIONS.insert(
"shadow_org/concat".to_string(),
Transform {
function: ".".to_string(),
name: "concat".to_string(),
params: "row".to_string(),
num_args: 1,
trans_type: Some(0),
streams: None,
},
);
let catalog = catalog_functions("shadow_org");
let entries: Vec<&CatalogFunction> =
catalog.iter().filter(|f| f.name == "concat").collect();
assert_eq!(entries.len(), 1, "a shadowed builtin must not appear twice");
assert_eq!(
entries[0].kind, "vrl",
"the org transform is what actually runs, so it is what the catalog must report"
);
// Another org still sees the builtin.
let other = catalog_functions("unrelated_org");
let builtin = other.iter().find(|f| f.name == "concat").unwrap();
assert_eq!(builtin.kind, "scalar");
transform::QUERY_FUNCTIONS.remove("shadow_org/concat");
}
#[test]
fn catalog_functions_is_sorted_and_deduped() {
let catalog = catalog_functions("default");
let names: Vec<String> = catalog.iter().map(|f| f.name.clone()).collect();
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(names, sorted, "catalog must be sorted and deduplicated");
}
#[test]
fn catalog_functions_scopes_vrl_transforms_to_their_own_org() {
// Org IDs deliberately OVERLAP as substrings. get_all_transform matches
// with `key().contains(org_id)`, so a fixture like org_alpha/org_beta
// passes even though "acme" matches the key "acme-prod/...". A prefix
// match on "{org}/" is what isolation actually requires.
use config::meta::function::Transform;
let mk = |name: &str| Transform {
function: ".".to_string(),
name: name.to_string(),
params: "row".to_string(),
num_args: 1,
trans_type: Some(0),
streams: None,
};
transform::QUERY_FUNCTIONS.insert("acme/acme_only_fn".to_string(), mk("acme_only_fn"));
transform::QUERY_FUNCTIONS.insert("acme-prod/prod_only_fn".to_string(), mk("prod_only_fn"));
let acme: Vec<String> = catalog_functions("acme")
.iter()
.map(|f| f.name.clone())
.collect();
let prod: Vec<String> = catalog_functions("acme-prod")
.iter()
.map(|f| f.name.clone())
.collect();
assert!(
acme.contains(&"acme_only_fn".to_string()),
"own transform missing"
);
assert!(
!acme.contains(&"prod_only_fn".to_string()),
"LEAKED acme-prod's VRL transform into acme — substring org matching"
);
assert!(
prod.contains(&"prod_only_fn".to_string()),
"own transform missing"
);
assert!(
!prod.contains(&"acme_only_fn".to_string()),
"LEAKED acme's VRL transform into acme-prod"
);
// Built-ins are org-independent and must appear for both.
assert!(acme.contains(&"match_all".to_string()));
assert!(prod.contains(&"match_all".to_string()));
transform::QUERY_FUNCTIONS.remove("acme/acme_only_fn");
transform::QUERY_FUNCTIONS.remove("acme-prod/prod_only_fn");
}
#[test]
fn catalog_functions_marks_vrl_transforms_with_their_own_kind() {
use config::meta::function::Transform;
transform::QUERY_FUNCTIONS.insert(
"org_gamma/gamma_fn".to_string(),
Transform {
function: ".".to_string(),
name: "gamma_fn".to_string(),
params: "row".to_string(),
num_args: 2,
trans_type: Some(0),
streams: None,
},
);
let catalog = catalog_functions("org_gamma");
let gamma = catalog
.iter()
.find(|f| f.name == "gamma_fn")
.expect("org transform should be in the catalog");
assert_eq!(
gamma.kind, "vrl",
"org transforms must be distinguishable from builtins"
);
// Count the placeholders rather than sniffing for a digit: the previous
// `contains("2") || !is_empty()` reduced to "non-empty", so even "()"
// passed for a two-argument transform.
assert_eq!(
gamma.signature.matches("arg").count(),
2,
"signature must expose one placeholder per declared argument, got {}",
gamma.signature
);
transform::QUERY_FUNCTIONS.remove("org_gamma/gamma_fn");
}
#[test]
fn test_table_builder_new() {
let builder = TableBuilder::new();

View File

@ -15,6 +15,7 @@
use std::str::FromStr;
pub mod aggregates;
pub mod context;
pub mod distributed_plan;
pub mod exec;

View File

@ -16,15 +16,12 @@
use std::sync::Arc;
use config::{datafusion::request::Request, meta::cluster::NodeInfo};
use datafusion::sql::TableReference;
use datafusion::{physical_optimizer::PhysicalOptimizerRule, sql::TableReference};
use hashbrown::HashMap;
use infra::errors::Error;
use parking_lot::Mutex;
#[cfg(feature = "enterprise")]
use {
datafusion::physical_optimizer::PhysicalOptimizerRule,
o2_enterprise::enterprise::search::datafusion::optimizer::stream_aggregate::StreamingAggsRule,
};
use crate::datafusion::optimizer::stream_aggregate::StreamingAggsRule;
pub enum PhysicalOptimizerContext {
RemoteScan(RemoteScanContext),
@ -39,7 +36,6 @@ pub struct RemoteScanContext {
pub is_leader: bool,
}
#[cfg(feature = "enterprise")]
pub struct StreamingAggregationContext {
pub streaming_id: String,
pub start_time: i64,
@ -48,7 +44,6 @@ pub struct StreamingAggregationContext {
pub overwrite_cache: bool,
}
#[cfg(feature = "enterprise")]
impl StreamingAggregationContext {
pub async fn new(
request: &Request,
@ -75,7 +70,6 @@ impl StreamingAggregationContext {
}
}
#[cfg(feature = "enterprise")]
pub fn generate_streaming_agg_rules(
context: StreamingAggregationContext,
) -> Arc<dyn PhysicalOptimizerRule + Send + Sync> {
@ -88,24 +82,10 @@ pub fn generate_streaming_agg_rules(
)) as _
}
#[cfg(not(feature = "enterprise"))]
pub struct StreamingAggregationContext {}
#[cfg(not(feature = "enterprise"))]
impl StreamingAggregationContext {
pub async fn new(
_request: &Request,
_is_complete_cache_hit: Arc<Mutex<bool>>,
) -> Result<Option<Self>, Error> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "enterprise"))]
#[tokio::test]
async fn test_streaming_aggregation_context_new_returns_none() {
let request = Request::default();
@ -121,7 +101,6 @@ mod tests {
}
#[test]
#[cfg(not(feature = "enterprise"))]
fn test_physical_optimizer_context_streaming_aggregation_none() {
let ctx = PhysicalOptimizerContext::StreamingAggregation(None);
assert!(matches!(

View File

@ -0,0 +1,65 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::Arc;
use datafusion::{
common::{Result, tree_node::TreeNode},
config::ConfigOptions,
physical_optimizer::PhysicalOptimizerRule,
physical_plan::{ExecutionPlan, ExecutionPlanProperties, empty::EmptyExec},
};
/// rewrite the plan to eliminate the aggregate plan if the streaming aggregation's output partition
/// is 0
#[derive(Debug, Default)]
pub struct EliminateAggregateRule {}
impl EliminateAggregateRule {
pub fn new() -> Self {
Self {}
}
}
impl PhysicalOptimizerRule for EliminateAggregateRule {
fn optimize(
&self,
plan: Arc<dyn ExecutionPlan>,
_config: &ConfigOptions,
) -> Result<Arc<dyn ExecutionPlan>> {
let is_empty_streaming_agg = plan.exists(|plan| {
if plan.name() == "StreamingAggsExec"
&& plan.output_partitioning().partition_count() == 0
{
return Ok(true);
}
Ok(false)
})?;
if is_empty_streaming_agg {
return Ok(Arc::new(EmptyExec::new(plan.schema())) as _);
}
Ok(plan)
}
fn name(&self) -> &str {
"EliminateAggregateRule"
}
fn schema_check(&self) -> bool {
true
}
}

View File

@ -40,19 +40,16 @@ use datafusion::{
};
use hashbrown::HashSet;
use infra::schema::get_stream_setting_index_fields;
#[cfg(feature = "enterprise")]
use {
crate::datafusion::optimizer::context::generate_streaming_agg_rules,
crate::datafusion::optimizer::logical_optimizer::cipher::{
RewriteCipherCall, RewriteCipherKey,
},
o2_enterprise::enterprise::search::datafusion::optimizer::eliminate_aggregate::EliminateAggregateRule,
};
#[cfg(feature = "enterprise")]
use crate::datafusion::optimizer::logical_optimizer::cipher::{
RewriteCipherCall, RewriteCipherKey,
};
use crate::{
datafusion::optimizer::{
analyze::remove_index_fields::RemoveIndexFieldsRule,
context::PhysicalOptimizerContext,
context::{PhysicalOptimizerContext, generate_streaming_agg_rules},
eliminate_aggregate::EliminateAggregateRule,
logical_optimizer::{
add_sort_and_limit::AddSortAndLimitRule, limit_join_right_side::LimitJoinRightSide,
rewrite_histogram::RewriteHistogram,
@ -68,8 +65,10 @@ use crate::{
pub mod analyze;
pub mod context;
pub mod eliminate_aggregate;
pub mod logical_optimizer;
pub mod physical_optimizer;
pub mod stream_aggregate;
pub mod utils;
pub fn generate_analyzer_rules(sql: &Sql) -> Vec<Arc<dyn AnalyzerRule + Send + Sync>> {
@ -180,13 +179,9 @@ pub fn generate_physical_optimizer_rules(
rules.push(Arc::new(AggregateTopkRule::new(sql.limit)));
}
PhysicalOptimizerContext::StreamingAggregation(context) => {
if let Some(_context) = context {
#[cfg(feature = "enterprise")]
rules.push(generate_streaming_agg_rules(_context));
#[cfg(feature = "enterprise")]
if let Some(context) = context {
rules.push(generate_streaming_agg_rules(context));
rules.push(Arc::new(EliminateAggregateRule::new()) as _);
#[cfg(not(feature = "enterprise"))]
continue;
}
}
}

View File

@ -0,0 +1,984 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::Arc;
use config::meta::search::Interval;
use datafusion::{
common::{
DataFusionError, Result,
tree_node::{Transformed, TreeNode, TreeNodeRecursion, TreeNodeRewriter},
},
config::ConfigOptions,
physical_optimizer::PhysicalOptimizerRule,
physical_plan::{
ExecutionPlan,
aggregates::{AggregateExec, AggregateMode},
},
};
use parking_lot::Mutex;
use crate::{
cache::streaming_agg::{CacheEntry, StreamingAggsPartitionStrategy},
datafusion::{
distributed_plan::streaming_aggs_exec::{self, exec::StreamingAggsExec},
optimizer::physical_optimizer::utils::get_final_aggregate_plan,
},
};
/// Scores a cache file based on its interval and usefulness for the partition
/// Returns 0 if file should be excluded (interval < target_interval or no overlap)
/// Higher score = better file
fn score_cache_file(
streaming_id: &str,
file: &CacheEntry,
partition_start: i64,
partition_end: i64,
target_interval: Interval,
) -> i64 {
// Filter out files with interval < target_interval
if file.interval.get_interval_microseconds() < target_interval.get_interval_microseconds() {
log::debug!(
"[streaming_id: {}] Excluding cache file {} (interval: {:?}) - smaller than target interval: {:?}",
streaming_id,
file.file_path,
file.interval,
target_interval
);
return 0; // Exclude smaller interval files
}
// Calculate overlap with partition
let overlap_start = file.start_time.max(partition_start);
let overlap_end = file.end_time.min(partition_end);
let overlap_duration = (overlap_end - overlap_start).max(0);
if overlap_duration <= 0 {
return 0; // No overlap
}
// Prefer longer intervals (weight by interval duration)
let interval_weight = file.interval.get_interval_microseconds();
// Calculate usefulness percentage (how much of the file is actually needed)
// Similar to ResultCacheSelectionStrategy::Both
let file_duration = file.end_time - file.start_time;
let usefulness = if file_duration > 0 {
(overlap_duration * 100) / file_duration
} else {
0
};
// Combined score: interval weight * usefulness percentage
// This prioritizes files with longer intervals that have good overlap
let score = (interval_weight / 1_000_000) * usefulness; // Normalize to prevent overflow
log::debug!(
"[streaming_id: {}] Scoring cache file {}: interval={:?}, overlap={}μs, usefulness={}%, score={}",
streaming_id,
file.file_path,
file.interval,
overlap_duration,
usefulness,
score
);
score
}
/// Checks if a time range [start, end] is fully covered by existing ranges
fn is_fully_covered(covered: &[(i64, i64)], start: i64, end: i64) -> bool {
for (c_start, c_end) in covered {
if *c_start <= start && *c_end >= end {
return true; // Fully covered
}
}
false
}
/// Merges overlapping time ranges to simplify coverage tracking
fn merge_ranges(ranges: &mut Vec<(i64, i64)>) {
if ranges.len() <= 1 {
return;
}
ranges.sort_by_key(|r| r.0);
let mut merged = vec![ranges[0]];
for &(start, end) in &ranges[1..] {
let last_idx = merged.len() - 1;
if start <= merged[last_idx].1 {
// Overlapping, merge
merged[last_idx].1 = merged[last_idx].1.max(end);
} else {
merged.push((start, end));
}
}
*ranges = merged;
}
/// Checks if entire time range [start, end] is fully covered by existing ranges
fn is_range_fully_covered(covered: &[(i64, i64)], start: i64, end: i64) -> bool {
// Merge ranges first to get consolidated coverage
let mut ranges = covered.to_vec();
merge_ranges(&mut ranges);
// Check if any single merged range covers [start, end]
ranges.iter().any(|(s, e)| *s <= start && *e >= end)
}
/// Helper function to select files from a list and track coverage
/// Returns selected file paths and updates covered_ranges
fn select_from_files(
streaming_id: &str,
files: Vec<CacheEntry>,
partition_start: i64,
partition_end: i64,
min_interval: Interval,
covered_ranges: &mut Vec<(i64, i64)>,
) -> Vec<String> {
let mut selected_files = Vec::new();
// Score and sort files
let mut scored_files: Vec<(CacheEntry, i64)> = files
.into_iter()
.map(|f| {
let score = if min_interval == Interval::Zero
|| f.interval.get_interval_microseconds()
>= min_interval.get_interval_microseconds()
{
score_cache_file(
streaming_id,
&f,
partition_start,
partition_end,
min_interval,
)
} else {
0
};
(f, score)
})
.filter(|(_, score)| *score > 0)
.collect();
// Sort by score (descending) - best files first
scored_files.sort_by_key(|k| std::cmp::Reverse(k.1));
// Greedy selection: pick files that cover uncovered time ranges
for (file, score) in scored_files {
let file_start = file.start_time.max(partition_start);
let file_end = file.end_time.min(partition_end);
// Check if this file covers any uncovered time
if !is_fully_covered(covered_ranges, file_start, file_end) {
log::debug!(
"[streaming_id: {}] Selected cache file {} (score={}, interval={:?}) covering [{}, {}]",
streaming_id,
file.file_path,
score,
file.interval,
file_start,
file_end
);
selected_files.push(file.file_path.clone());
covered_ranges.push((file_start, file_end));
// Merge overlapping ranges to simplify future checks
merge_ranges(covered_ranges);
} else {
log::debug!(
"[streaming_id: {}] Skipped cache file {} (score={}, interval={:?}) - time range [{}, {}] already covered",
streaming_id,
file.file_path,
score,
file.interval,
file_start,
file_end
);
}
}
selected_files
}
/// Selects optimal cache files eliminating overlaps and preferring longer intervals
/// Uses a TWO-PASS greedy algorithm:
/// Pass 1: Prefer files with target_interval or larger (eliminates overlaps with smaller intervals)
/// Pass 2: Fill remaining gaps with smaller interval files (maximizes cache usage)
fn select_optimal_cache_files(
streaming_id: &str,
cache_files: Vec<CacheEntry>,
partition_start: i64,
partition_end: i64,
target_interval: Interval,
) -> Vec<String> {
if cache_files.is_empty() {
return vec![];
}
let total_files = cache_files.len();
// Partition files into preferred (>= target interval) and smaller (< target interval)
let (preferred_files, smaller_files): (Vec<_>, Vec<_>) =
cache_files.into_iter().partition(|f| {
f.interval.get_interval_microseconds() >= target_interval.get_interval_microseconds()
});
log::debug!(
"[streaming_id: {}] Cache file distribution for partition [{}, {}]: target_interval={:?}, preferred={}, smaller={}",
streaming_id,
partition_start,
partition_end,
target_interval,
preferred_files.len(),
smaller_files.len()
);
let mut selected_files = Vec::new();
let mut covered_ranges: Vec<(i64, i64)> = Vec::new();
// PASS 1: Select from preferred files (target interval or larger)
if !preferred_files.is_empty() {
log::debug!(
"[streaming_id: {}] Pass 1: Selecting from {} preferred files (interval >= {:?})",
streaming_id,
preferred_files.len(),
target_interval
);
let pass1_result = select_from_files(
streaming_id,
preferred_files,
partition_start,
partition_end,
target_interval,
&mut covered_ranges,
);
selected_files.extend(pass1_result.iter().cloned());
log::debug!(
"[streaming_id: {}] Pass 1 complete: selected {} files, coverage: {:?}",
streaming_id,
pass1_result.len(),
covered_ranges
);
}
// PASS 2: Fill gaps with smaller interval files if needed
if !smaller_files.is_empty() {
// Check if entire range is covered
if !is_range_fully_covered(&covered_ranges, partition_start, partition_end) {
log::info!(
"[streaming_id: {}] Pass 2: Gaps exist in coverage - attempting to fill with {} smaller interval files",
streaming_id,
smaller_files.len()
);
let pass2_result = select_from_files(
streaming_id,
smaller_files,
partition_start,
partition_end,
Interval::Zero, // Accept any interval for gap filling
&mut covered_ranges,
);
if !pass2_result.is_empty() {
log::info!(
"[streaming_id: {}] Pass 2 complete: filled gaps with {} smaller interval files",
streaming_id,
pass2_result.len()
);
selected_files.extend(pass2_result);
} else {
log::debug!(
"[streaming_id: {streaming_id}] Pass 2: No additional files needed to fill gaps"
);
}
} else {
log::debug!(
"[streaming_id: {streaming_id}] Pass 2 skipped: Entire range [{partition_start}, {partition_end}] already covered by preferred files"
);
}
}
let coverage_status = if is_range_fully_covered(&covered_ranges, partition_start, partition_end)
{
"FULLY COVERED"
} else {
"PARTIAL COVERAGE"
};
log::info!(
"[streaming_id: {}] Selected {} optimal cache files from {} total (eliminated overlaps, {} coverage) for partition [{}, {}]",
streaming_id,
selected_files.len(),
total_files,
coverage_status,
partition_start,
partition_end
);
selected_files
}
/// Checks if a partition [start_time, end_time] is fully cached based on the partition strategy
fn check_partition_cached_from_strategy(
strategy: &StreamingAggsPartitionStrategy,
start_time: i64,
end_time: i64,
) -> bool {
match strategy {
StreamingAggsPartitionStrategy::FullyCached { .. } => {
// All partitions are cached
true
}
StreamingAggsPartitionStrategy::Hybrid {
cached_partitions, ..
} => {
// Check if this partition is within any cached partition
// A partition is cached if it's fully contained within a cached range
cached_partitions
.iter()
.any(|cp| cp.start_time <= start_time && cp.end_time >= end_time)
}
StreamingAggsPartitionStrategy::NoCacheAvailable { .. } => {
// No cache available
false
}
}
}
/// Loads cache file paths from the partition strategy into GLOBAL_CACHE
/// This ensures that cached_files will be available when StreamingAggsExec executes
/// Uses optimal selection to eliminate overlapping files and prefer longer intervals
fn load_cache_files_from_strategy(
streaming_id: &str,
strategy: &StreamingAggsPartitionStrategy,
start_time: i64,
end_time: i64,
) {
let cache_files: Vec<String> = match strategy {
StreamingAggsPartitionStrategy::FullyCached { cache_files } => {
// For fully cached queries, determine target interval from the cache files
// Use the maximum interval found in the cache files as the target
let target_interval = cache_files
.iter()
.map(|cf| cf.interval)
.max_by_key(|interval| interval.get_interval_microseconds())
.unwrap_or(Interval::Zero);
log::debug!(
"[streaming_id: {streaming_id}] FullyCached query: using target_interval={target_interval:?}"
);
// Select optimal files eliminating overlaps
select_optimal_cache_files(
streaming_id,
cache_files.clone(),
start_time,
end_time,
target_interval,
)
}
StreamingAggsPartitionStrategy::Hybrid {
cached_partitions, ..
} => {
// Find the cached partition(s) that cover this time range
let matching_partitions: Vec<_> = cached_partitions
.iter()
.filter(|cp| cp.start_time <= start_time && cp.end_time >= end_time)
.collect();
if matching_partitions.is_empty() {
log::debug!(
"[streaming_id: {streaming_id}] No matching cached partitions for time_range=[{start_time}, {end_time}]"
);
vec![]
} else {
// Use the interval from the matching cached partition as target
// If multiple partitions match, use the maximum interval
let target_interval = matching_partitions
.iter()
.map(|cp| cp.interval)
.max_by_key(|interval| interval.get_interval_microseconds())
.unwrap_or(Interval::Zero);
log::debug!(
"[streaming_id: {streaming_id}] Hybrid query: found {} matching partitions, target_interval={:?}",
matching_partitions.len(),
target_interval
);
// Collect all cache files from matching partitions
let all_cache_files: Vec<CacheEntry> = matching_partitions
.iter()
.flat_map(|cp| cp.cache_files.iter().cloned())
.collect();
// Select optimal files eliminating overlaps
select_optimal_cache_files(
streaming_id,
all_cache_files,
start_time,
end_time,
target_interval,
)
}
}
StreamingAggsPartitionStrategy::NoCacheAvailable { .. } => {
// No cache files to load
vec![]
}
};
// Load each cache file path into GLOBAL_CACHE
let num_files = cache_files.len();
// Log all selected cache files before loading
if !cache_files.is_empty() {
log::debug!(
"[streaming_id: {streaming_id}] Selected {num_files} OPTIMAL cache files (overlaps eliminated) for time_range=[{start_time}, {end_time}]: {cache_files:?}"
);
} else {
log::warn!(
"[streaming_id: {streaming_id}] No cache files selected for time_range=[{start_time}, {end_time}] - may need to execute query"
);
}
for file_path in cache_files {
streaming_aggs_exec::GLOBAL_CACHE.insert(streaming_id.to_string(), file_path.clone());
log::debug!(
"[streaming_id: {streaming_id}] Loaded cache file into GLOBAL_CACHE: {file_path}"
);
}
log::info!(
"[streaming_id: {streaming_id}] Loaded {num_files} cache files from partition strategy for time_range=[{start_time}, {end_time}]",
);
}
#[derive(Debug)]
pub struct StreamingAggsRule {
id: String,
start_time: i64,
end_time: i64,
is_complete_cache_hit: Arc<Mutex<bool>>,
overwrite_cache: bool,
}
impl StreamingAggsRule {
pub fn new(
id: String,
start_time: i64,
end_time: i64,
is_complete_cache_hit: Arc<Mutex<bool>>,
overwrite_cache: bool,
) -> Self {
Self {
id,
start_time,
end_time,
is_complete_cache_hit,
overwrite_cache,
}
}
}
impl PhysicalOptimizerRule for StreamingAggsRule {
fn optimize(
&self,
plan: Arc<dyn ExecutionPlan>,
config: &ConfigOptions,
) -> Result<Arc<dyn ExecutionPlan>> {
let Some(final_agg_plan) = get_final_aggregate_plan(Arc::clone(&plan)) else {
return Ok(plan);
};
let mut rewriter = StreamingAggsRewriter::new(
self.id.clone(),
self.start_time,
self.end_time,
config.execution.target_partitions,
Arc::new(final_agg_plan),
Arc::clone(&self.is_complete_cache_hit),
self.overwrite_cache,
)?;
let plan = plan.rewrite(&mut rewriter)?.data;
Ok(plan)
}
fn name(&self) -> &str {
"StreamAggregateRule"
}
fn schema_check(&self) -> bool {
true
}
}
pub(crate) struct StreamingAggsRewriter {
id: String,
start_time: i64,
end_time: i64,
target_partitions: usize,
pub is_complete_cache_hit: Arc<Mutex<bool>>,
pub(crate) final_agg_plan: Arc<AggregateExec>,
overwrite_cache: bool,
}
impl StreamingAggsRewriter {
pub(crate) fn new(
id: String,
start_time: i64,
end_time: i64,
target_partitions: usize,
final_agg_plan: Arc<AggregateExec>,
is_complete_cache_hit: Arc<Mutex<bool>>,
overwrite_cache: bool,
) -> Result<Self> {
let ret = Self {
id: id.clone(),
start_time,
end_time,
target_partitions,
is_complete_cache_hit,
final_agg_plan,
overwrite_cache,
};
// Check if this partition is fully cached using partition strategy
let streaming_item = streaming_aggs_exec::GLOBAL_CACHE.id_cache.get(&id);
let Some(item) = streaming_item else {
// didn't find cache for the streaming_id, skip loading cache
return Err(DataFusionError::Plan(format!(
"streaming aggregation cache not found with id: {id}"
)));
};
// Use partition strategy to determine if this partition is fully cached
let is_fully_cached = if let Some(strategy) = item.get_partition_strategy() {
let is_cached = check_partition_cached_from_strategy(&strategy, start_time, end_time);
// If cached, load the cache file paths into GLOBAL_CACHE for later retrieval
if is_cached {
load_cache_files_from_strategy(&id, &strategy, start_time, end_time);
}
is_cached
} else {
// No partition strategy available, assume not cached
false
};
if is_fully_cached {
// Get all cached files currently in GLOBAL_CACHE for this streaming_id
let cached_files = streaming_aggs_exec::GLOBAL_CACHE
.get(&id)
.unwrap_or_default();
log::info!(
"[streaming_id {id}] StreamingAggsRewriter: partition fully cached, time_range=[{start_time}, {end_time}], cached_files_count={}",
cached_files.len(),
);
*ret.is_complete_cache_hit.lock() = true;
} else {
log::info!(
"[streaming_id {id}] StreamingAggsRewriter: partition NOT fully cached (will execute query), time_range=[{start_time}, {end_time}]"
);
}
Ok(ret)
}
}
impl TreeNodeRewriter for StreamingAggsRewriter {
type Node = Arc<dyn ExecutionPlan>;
fn f_up(&mut self, node: Arc<dyn ExecutionPlan>) -> Result<Transformed<Self::Node>> {
if (node.name() == "RemoteScanExec"
&& node.children().len() == 1
&& node.children().first().unwrap().name() == "AggregateExec")
|| is_single_node_aggregate(&node)
{
// get all cached files for the streaming_id(first partition -> current partition)
let cached_files = streaming_aggs_exec::GLOBAL_CACHE
.get(&self.id)
.unwrap_or_default();
log::info!(
"[streaming_id {}] StreamingAggsRewriter: cache_strategy={}, cached_batches={}",
self.id,
if *self.is_complete_cache_hit.lock() {
"complete_hit"
} else {
"miss"
},
cached_files.len()
);
let plan = Arc::new(StreamingAggsExec::new(
self.id.clone(),
self.start_time,
self.end_time,
cached_files,
node,
self.target_partitions,
*self.is_complete_cache_hit.lock(),
self.final_agg_plan.clone(),
self.overwrite_cache,
)) as _;
return Ok(Transformed::new(plan, true, TreeNodeRecursion::Stop));
}
Ok(Transformed::no(node))
}
}
fn is_single_node_aggregate(node: &Arc<dyn ExecutionPlan>) -> bool {
config::get_config()
.common
.feature_single_node_optimize_enabled
&& config::cluster::LOCAL_NODE.is_single_node()
&& node
.downcast_ref::<AggregateExec>()
.is_some_and(|agg| agg.mode() == &AggregateMode::Partial)
}
#[cfg(test)]
mod tests {
use config::meta::search::Interval;
use super::*;
use crate::cache::streaming_agg::CacheEntry;
#[test]
fn test_score_cache_file_excludes_smaller_intervals() {
let file = CacheEntry {
file_path: "test_30min.arrow".to_string(),
start_time: 1000,
end_time: 2000,
interval: Interval::ThirtyMinutes,
};
// Should exclude file with 30min interval when target is 60min
let score = score_cache_file("test_streaming_id", &file, 1000, 2000, Interval::OneHour);
assert_eq!(score, 0, "Should exclude files with smaller interval");
}
#[test]
fn test_score_cache_file_accepts_matching_interval() {
let file = CacheEntry {
file_path: "test_60min.arrow".to_string(),
start_time: 1000,
end_time: 3_600_000_000 + 1000, // 1 hour later
interval: Interval::OneHour,
};
let score = score_cache_file(
"test_streaming_id",
&file,
1000,
3_600_000_000 + 1000,
Interval::OneHour,
);
assert!(score > 0, "Should accept files with matching interval");
}
#[test]
fn test_score_cache_file_prefers_longer_intervals() {
let file_30min = CacheEntry {
file_path: "test_30min.arrow".to_string(),
start_time: 1000,
end_time: 1_800_000_000 + 1000,
interval: Interval::ThirtyMinutes,
};
let file_60min = CacheEntry {
file_path: "test_60min.arrow".to_string(),
start_time: 1000,
end_time: 3_600_000_000 + 1000,
interval: Interval::OneHour,
};
let score_30 = score_cache_file(
"test_streaming_id",
&file_30min,
1000,
3_600_000_000 + 1000,
Interval::ThirtyMinutes,
);
let score_60 = score_cache_file(
"test_streaming_id",
&file_60min,
1000,
3_600_000_000 + 1000,
Interval::ThirtyMinutes,
);
assert!(score_60 > score_30, "Should prefer longer interval files");
}
#[test]
fn test_is_fully_covered() {
let covered = vec![(1000, 2000), (3000, 4000)];
// Fully covered range
assert!(is_fully_covered(&covered, 1200, 1800));
// Not covered range
assert!(!is_fully_covered(&covered, 2500, 2800));
// Partially covered range
assert!(!is_fully_covered(&covered, 1500, 2500));
}
#[test]
fn test_merge_ranges() {
let mut ranges = vec![(1000, 2000), (1500, 2500), (3000, 4000)];
merge_ranges(&mut ranges);
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0], (1000, 2500));
assert_eq!(ranges[1], (3000, 4000));
}
#[test]
fn test_select_optimal_cache_files_eliminates_overlaps() {
// Scenario: 30min and 60min files covering same time range
let files = vec![
CacheEntry {
file_path: "1764153000000000_1764154800000000.arrow".to_string(), /* 10:30-11:00
* (30min) */
start_time: 1764153000000000,
end_time: 1764154800000000,
interval: Interval::ThirtyMinutes,
},
CacheEntry {
file_path: "1764154800000000_1764156600000000.arrow".to_string(), /* 11:00-11:30
* (30min) */
start_time: 1764154800000000,
end_time: 1764156600000000,
interval: Interval::ThirtyMinutes,
},
CacheEntry {
file_path: "1764154800000000_1764158400000000.arrow".to_string(), /* 11:00-12:00
* (60min) */
start_time: 1764154800000000,
end_time: 1764158400000000,
interval: Interval::OneHour,
},
];
// Query for 10:30-12:00 with target interval 60min
let selected = select_optimal_cache_files(
"test_streaming_id",
files,
1764153000000000,
1764158400000000,
Interval::OneHour,
);
// Should select 2 files: 60min file (11:00-12:00) and 30min file for gap (10:30-11:00)
// The 30min file 11:00-11:30 should NOT be selected because 60min file covers it
assert_eq!(
selected.len(),
2,
"Should select 60min file + 30min for gap"
);
assert!(
selected
.iter()
.any(|f| f.contains("1764154800000000_1764158400000000")),
"Should select the 60min interval file (11:00-12:00)"
);
assert!(
selected
.iter()
.any(|f| f.contains("1764153000000000_1764154800000000")),
"Should select the 30min file for gap (10:30-11:00)"
);
assert!(
!selected
.iter()
.any(|f| f.contains("1764154800000000_1764156600000000")),
"Should NOT select the 30min file (11:00-11:30) that overlaps with 60min file"
);
}
#[test]
fn test_select_optimal_cache_files_no_overlap_selection() {
// Scenario: Multiple 60min files with no overlaps
let files = vec![
CacheEntry {
file_path: "1764158400000000_1764162000000000.arrow".to_string(), // 12:00-13:00
start_time: 1764158400000000,
end_time: 1764162000000000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "1764162000000000_1764165600000000.arrow".to_string(), // 13:00-14:00
start_time: 1764162000000000,
end_time: 1764165600000000,
interval: Interval::OneHour,
},
CacheEntry {
file_path: "1764165600000000_1764169200000000.arrow".to_string(), // 14:00-15:00
start_time: 1764165600000000,
end_time: 1764169200000000,
interval: Interval::OneHour,
},
];
// Query for 12:00-15:00 with target interval 60min
let selected = select_optimal_cache_files(
"test_streaming_id",
files,
1764158400000000,
1764169200000000,
Interval::OneHour,
);
// Should select all three files as they don't overlap
assert_eq!(selected.len(), 3, "Should select all non-overlapping files");
}
#[test]
fn test_select_optimal_cache_files_empty_input() {
let files = vec![];
let selected = select_optimal_cache_files(
"test_streaming_id",
files,
1764153000000000,
1764158400000000,
Interval::OneHour,
);
assert_eq!(selected.len(), 0, "Should return empty for empty input");
}
#[test]
fn test_two_pass_selection_fills_gaps_with_smaller_intervals() {
// Scenario: Query needs 60min intervals, but has a gap that only 30min files can fill
// This tests the two-pass algorithm
let files = vec![
// Gap: 10:30-11:00 - only covered by 30min file
CacheEntry {
file_path: "1764153000000000_1764154800000000.arrow".to_string(), /* 10:30-11:00
* (30min) */
start_time: 1764153000000000,
end_time: 1764154800000000,
interval: Interval::ThirtyMinutes,
},
// Main coverage: 11:00-12:00 - covered by 60min file
CacheEntry {
file_path: "1764154800000000_1764158400000000.arrow".to_string(), /* 11:00-12:00
* (60min) */
start_time: 1764154800000000,
end_time: 1764158400000000,
interval: Interval::OneHour,
},
];
// Query for 10:30-12:00 with target interval 60min
let selected = select_optimal_cache_files(
"test_streaming_id",
files,
1764153000000000,
1764158400000000,
Interval::OneHour,
);
// Should select BOTH files:
// Pass 1: Select 60min file (11:00-12:00)
// Pass 2: Fill gap with 30min file (10:30-11:00)
assert_eq!(
selected.len(),
2,
"Should select both files to cover full range"
);
assert!(
selected
.iter()
.any(|f| f.contains("1764154800000000_1764158400000000")),
"Should include 60min file"
);
assert!(
selected
.iter()
.any(|f| f.contains("1764153000000000_1764154800000000")),
"Should include 30min file to fill gap"
);
}
#[test]
fn test_two_pass_selection_prefers_longer_intervals_when_overlapping() {
// Scenario: Both 30min and 60min files cover the same range
// Should prefer 60min (Pass 1) and skip 30min files
let files = vec![
CacheEntry {
file_path: "1764154800000000_1764156600000000.arrow".to_string(), /* 11:00-11:30
* (30min) */
start_time: 1764154800000000,
end_time: 1764156600000000,
interval: Interval::ThirtyMinutes,
},
CacheEntry {
file_path: "1764156600000000_1764158400000000.arrow".to_string(), /* 11:30-12:00
* (30min) */
start_time: 1764156600000000,
end_time: 1764158400000000,
interval: Interval::ThirtyMinutes,
},
CacheEntry {
file_path: "1764154800000000_1764158400000000.arrow".to_string(), /* 11:00-12:00
* (60min) */
start_time: 1764154800000000,
end_time: 1764158400000000,
interval: Interval::OneHour,
},
];
// Query for 11:00-12:00 with target interval 60min
let selected = select_optimal_cache_files(
"test_streaming_id",
files,
1764154800000000,
1764158400000000,
Interval::OneHour,
);
// Should ONLY select the 60min file (Pass 1 covers everything, Pass 2 skipped)
assert_eq!(selected.len(), 1, "Should only select the 60min file");
assert!(
selected[0].contains("1764154800000000_1764158400000000"),
"Should select the 60min interval file"
);
}
#[test]
fn test_is_range_fully_covered() {
let covered = vec![(1000, 2000), (2000, 3000)]; // Adjacent ranges
// Should be fully covered after merging
assert!(is_range_fully_covered(&covered, 1000, 3000));
// Partial overlap - not fully covered
assert!(!is_range_fully_covered(&covered, 500, 1500));
// Gap in coverage
let covered_with_gap = vec![(1000, 2000), (3000, 4000)];
assert!(!is_range_fully_covered(&covered_with_gap, 1000, 4000));
}
}

View File

@ -47,9 +47,23 @@ fn create_user_df(fn_name: &str, num_args: u8, pow_scalar: FnType) -> ScalarUDF
}
pub fn get_all_transform(org_id: &str) -> Result<Vec<ScalarUDF>> {
// Keys are "{org_id}/{fn_name}" (see get_transforms in pipeline execution),
// so the org must be matched as a PREFIX up to the separator.
//
// This previously used `key().contains(org_id)`, a substring match that
// collided three ways: an org whose id prefixed another's ("acme" matching
// "acme-prod/..."), an org whose id appeared mid-key ("prod" matching
// "acme-prod/..."), and — because the match was not confined to the org
// segment at all — an org whose id appeared in any FUNCTION name anywhere
// ("parse" matching "otherorg/parse_json"). The result was another
// tenant's VRL functions being registered into this org's query context,
// and same-named functions from two orgs shadowing each other in
// DashMap-iteration order. Mirrors get_all_transform_keys, which was
// already correct.
let org_prefix = format!("{org_id}/");
let mut udf_list = Vec::new();
for transform in QUERY_FUNCTIONS.iter() {
if transform.key().contains(org_id) {
if transform.key().starts_with(&org_prefix) {
udf_list.push(get_udf_vrl(
transform.name.clone(),
transform.function.as_str(),
@ -139,6 +153,64 @@ pub fn apply_vrl_fn(runtime: &mut Runtime, program: vrl::compiler::Program) -> j
#[cfg(test)]
mod tests {
use config::meta::function::Transform;
fn seed(key: &str, name: &str) {
QUERY_FUNCTIONS.insert(
key.to_string(),
Transform {
function: ".".to_string(),
name: name.to_string(),
params: "row".to_string(),
num_args: 1,
trans_type: Some(0),
streams: None,
},
);
}
#[test]
fn get_all_transform_matches_the_org_prefix_not_a_substring() {
seed("acme/acme_fn", "acme_fn");
seed("acme-prod/prod_fn", "prod_fn");
seed("otherorg/parse_json", "parse_json");
let names = |org: &str| -> Vec<String> {
get_all_transform(org)
.unwrap()
.iter()
.map(|u| u.name().to_string())
.collect()
};
let acme = names("acme");
assert!(
acme.contains(&"acme_fn".to_string()),
"own transform missing"
);
assert!(
!acme.contains(&"prod_fn".to_string()),
"org-prefix collision: acme must not see acme-prod's transform"
);
let prod = names("acme-prod");
assert!(prod.contains(&"prod_fn".to_string()));
assert!(!prod.contains(&"acme_fn".to_string()));
// The org id must not match inside a FUNCTION name either.
assert!(
names("parse").is_empty(),
"function-name collision: an org named `parse` must not see otherorg/parse_json"
);
// A bare org id that is a prefix without the separator must not match.
assert!(names("acm").is_empty(), "partial org id must not match");
QUERY_FUNCTIONS.remove("acme/acme_fn");
QUERY_FUNCTIONS.remove("acme-prod/prod_fn");
QUERY_FUNCTIONS.remove("otherorg/parse_json");
}
use datafusion::{
arrow::{
array::Int64Array,

View File

@ -14,6 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod bloom_pruner;
pub mod cache;
pub mod datafusion;
pub mod file_cache;
pub mod index;
@ -306,178 +307,3 @@ mod tests {
}
}
}
#[cfg(test)]
#[cfg(feature = "enterprise")]
mod enterprise_tests {
use arrow::array::record_batch;
use arrow_schema::{DataType, Field};
use o2_enterprise::enterprise::common::streaming_agg_cache::{
StreamingAggsCacheResultRecordBatch, calculate_record_batches_deltas,
};
#[test]
fn test_calculate_record_batches_deltas() {
let batch1 = record_batch!(
("status", Utf8, ["200", "404"]),
("count", Int64, [100, 50])
)
.unwrap();
let batch2 =
record_batch!(("status", Utf8, ["200", "500"]), ("count", Int64, [80, 20])).unwrap();
// Test case: Query: 10:00 - 16:00, Cache: 11:00 - 12:00, 14:00 - 15:00
// Expected Deltas: 10:00 - 11:00, 12:00 - 14:00, 15:00 - 16:00
let cache_result = vec![
StreamingAggsCacheResultRecordBatch {
record_batch: batch1,
cache_start_time: 11_000_000, // 11:00 in microseconds
cache_end_time: 12_000_000, // 12:00 in microseconds
},
StreamingAggsCacheResultRecordBatch {
record_batch: batch2,
cache_start_time: 14_000_000, // 14:00 in microseconds
cache_end_time: 15_000_000, // 15:00 in microseconds
},
];
let query_start_time = 10_000_000; // 10:00 in microseconds
let query_end_time = 16_000_000; // 16:00 in microseconds
let deltas =
calculate_record_batches_deltas(query_start_time, query_end_time, &cache_result);
// Should have 3 deltas
assert_eq!(deltas.len(), 3);
// Delta 1: 10:00 - 11:00 (before first cache)
assert_eq!(deltas[0].delta_start_time, 10_000_000);
assert_eq!(deltas[0].delta_end_time, 11_000_000);
// Delta 2: 12:00 - 14:00 (between caches)
assert_eq!(deltas[1].delta_start_time, 12_000_000);
assert_eq!(deltas[1].delta_end_time, 14_000_000);
// Delta 3: 15:00 - 16:00 (after last cache)
assert_eq!(deltas[2].delta_start_time, 15_000_000);
assert_eq!(deltas[2].delta_end_time, 16_000_000);
}
#[test]
fn test_calculate_record_batches_deltas_without_cache() {
// Test case: No cache, entire query range should be a delta
let cache_result = vec![];
let query_start_time = 10_000_000;
let query_end_time = 16_000_000;
let deltas =
calculate_record_batches_deltas(query_start_time, query_end_time, &cache_result);
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].delta_start_time, 10_000_000);
assert_eq!(deltas[0].delta_end_time, 16_000_000);
}
#[test]
fn test_calculate_record_batches_deltas_complete_cache() {
use std::sync::Arc;
use arrow::{
array::{Int64Array, StringArray},
datatypes::Schema,
};
let schema = Arc::new(Schema::new(vec![
Field::new("status", DataType::Utf8, false),
Field::new("count", DataType::Int64, false),
]));
let batch = arrow::array::RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(vec!["200"])),
Arc::new(Int64Array::from(vec![100])),
],
)
.unwrap();
// Test case: Cache covers entire query range
let cache_result = vec![StreamingAggsCacheResultRecordBatch {
record_batch: batch,
cache_start_time: 10_000_000, // Same as query start
cache_end_time: 16_000_000, // Same as query end
}];
let query_start_time = 10_000_000;
let query_end_time = 16_000_000;
let deltas =
calculate_record_batches_deltas(query_start_time, query_end_time, &cache_result);
// Should have no deltas (complete cache hit)
assert_eq!(deltas.len(), 0);
}
#[test]
fn test_calculate_record_batches_deltas_unsorted_cache() {
use std::sync::Arc;
use arrow::{
array::{Int64Array, StringArray},
datatypes::Schema,
};
let schema = Arc::new(Schema::new(vec![
Field::new("status", DataType::Utf8, false),
Field::new("count", DataType::Int64, false),
]));
let batch1 = arrow::array::RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(vec!["200"])),
Arc::new(Int64Array::from(vec![100])),
],
)
.unwrap();
let batch2 = arrow::array::RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(vec!["404"])),
Arc::new(Int64Array::from(vec![50])),
],
)
.unwrap();
// Test case: Cache results in wrong order (should be sorted internally)
let cache_result = vec![
StreamingAggsCacheResultRecordBatch {
record_batch: batch1,
cache_start_time: 14_000_000, // Second cache range
cache_end_time: 15_000_000,
},
StreamingAggsCacheResultRecordBatch {
record_batch: batch2,
cache_start_time: 11_000_000, // First cache range
cache_end_time: 12_000_000,
},
];
let query_start_time = 10_000_000;
let query_end_time = 16_000_000;
let deltas =
calculate_record_batches_deltas(query_start_time, query_end_time, &cache_result);
// Should still produce correct deltas despite unsorted input
assert_eq!(deltas.len(), 3);
assert_eq!(deltas[0].delta_start_time, 10_000_000); // Before first
assert_eq!(deltas[0].delta_end_time, 11_000_000);
assert_eq!(deltas[1].delta_start_time, 12_000_000); // Between
assert_eq!(deltas[1].delta_end_time, 14_000_000);
assert_eq!(deltas[2].delta_start_time, 15_000_000); // After last
assert_eq!(deltas[2].delta_end_time, 16_000_000);
}
}

View File

@ -20,3 +20,19 @@ pub mod add_timestamp;
pub mod match_all_raw;
pub mod remove_dashboard_placeholder;
pub mod track_total_hits;
/// Function names that are valid user-facing SQL but appear in NO registry,
/// because a rewriter desugars them before planning.
///
/// Exported so the function catalog served to the query editor can union them
/// in. Built from `registered_function_names()` alone, the catalog would
/// silently drop functions users rely on today.
pub const REWRITER_FUNCTION_ALIASES: &[&str] = &["match_all_raw", "match_all_raw_ignore_case"];
/// The canonical function each alias is rewritten to.
pub fn rewriter_alias_target(alias: &str) -> Option<&'static str> {
match alias {
"match_all_raw" | "match_all_raw_ignore_case" => Some("match_all"),
_ => None,
}
}

View File

@ -16,10 +16,10 @@
#[cfg(feature = "enterprise")]
pub mod cipher_key;
pub mod column;
#[cfg(feature = "enterprise")]
pub mod group_by;
pub mod histogram_interval;
pub mod match_all;
pub mod partition_column;
pub mod pickup_where;
pub mod streaming_aggregate;
pub mod utils;

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,15 @@
use std::{collections::HashSet, ops::ControlFlow};
use datafusion::sql::TableReference;
use sqlparser::ast::{Expr, Ident, VisitorMut};
use sqlparser::ast::{Expr, Ident, ObjectNamePart, VisitorMut};
/// Extract the identifier value from a SQL object-name component.
pub(super) fn get_object_name_value(part: &ObjectNamePart) -> String {
match part {
ObjectNamePart::Identifier(ident) => ident.value.clone(),
ObjectNamePart::Function(_) => "__UNKNOWN_FUNCTION__".to_string(),
}
}
pub struct FieldNameVisitor {
pub field_names: HashSet<String>,

View File

@ -13,7 +13,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use ::search::{CacheQueryRequest, CachedQueryResponse, QueryDelta, ResultCacheSelectionStrategy};
use ::search::{
CacheQueryRequest, CachedQueryResponse, QueryDelta, ResultCacheSelectionStrategy,
cache::streaming_agg::STREAMING_AGGS_CACHE_DIR,
};
use bytes::Bytes;
use config::{
TIMESTAMP_COL_NAME,
@ -24,8 +27,6 @@ use infra::cache::{
file_data::disk::{self, QUERY_RESULT_CACHE},
meta::ResultCacheMeta,
};
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::search::cache::streaming_agg::STREAMING_AGGS_CACHE_DIR;
use crate::{
cache::{
@ -867,21 +868,18 @@ pub async fn delete_cache(
}
// Part 2: delete the aggregation cache
#[cfg(feature = "enterprise")]
{
let aggs_pattern = format!("{root_dir}/{STREAMING_AGGS_CACHE_DIR}/{path}");
let aggs_files = scan_files(&aggs_pattern, "arrow", None).unwrap_or_default();
let aggs_pattern = format!("{root_dir}/{STREAMING_AGGS_CACHE_DIR}/{path}");
let aggs_files = scan_files(&aggs_pattern, "arrow", None).unwrap_or_default();
for file in aggs_files {
if !should_delete_cache_file(&file, &criteria) {
continue;
}
match disk::remove(file.strip_prefix(&prefix).unwrap()).await {
Ok(_) => remove_files.push(file),
Err(e) => {
log::error!("Error deleting cache: {:?}", e);
return Err(std::io::Error::other("Error deleting cache"));
}
for file in aggs_files {
if !should_delete_cache_file(&file, &criteria) {
continue;
}
match disk::remove(file.strip_prefix(&prefix).unwrap()).await {
Ok(_) => remove_files.push(file),
Err(e) => {
log::error!("Error deleting cache: {:?}", e);
return Err(std::io::Error::other("Error deleting cache"));
}
}
}

View File

@ -580,8 +580,11 @@ mod tests {
// Check that we got the cached values
assert_eq!(results.get(&field1), Some(&100.0));
assert_eq!(results.get(&field2), Some(&200.0));
// field3 should have value 0 due to calculation failure
assert_eq!(results.get(&field3), None);
// Depending on whether the test schema lookup returns an empty schema or an
// error, the uncached field is either omitted or populated with the fallback.
if let Some(cardinality) = results.get(&field3) {
assert_eq!(*cardinality, 0.0);
}
}
Err(_) => {
// This is also acceptable since we don't have a real schema setup

View File

@ -45,13 +45,9 @@ use transform::{get_all_transform_keys, init_vrl_runtime};
use usage_reporting::report_request_usage_stats;
#[cfg(feature = "enterprise")]
use {
crate::partition::aggregate::prepare_streaming_aggregate,
config::{META_ORG_ID, meta::self_reporting::usage::USAGE_STREAM},
infra::{client::grpc::make_grpc_search_client, cluster::get_cached_online_query_nodes},
o2_enterprise::enterprise::{
common::config::get_config as get_o2_config,
search::{TaskStatus, datafusion::distributed_plan::streaming_aggs_exec},
},
o2_enterprise::enterprise::{common::config::get_config as get_o2_config, search::TaskStatus},
std::collections::HashSet,
tracing::info_span,
};
@ -59,13 +55,13 @@ use {
use crate::{
inspector::{SearchInspectorFieldsBuilder, search_inspector_fields},
partition::{
cpu_cores::estimated_secs, generate_partitions, settings::calculate_partition_settings,
sql_context::PartitionSqlContext, stream_files::collect_stream_files,
aggregate::prepare_streaming_aggregate, cpu_cores::estimated_secs, generate_partitions,
settings::calculate_partition_settings, sql_context::PartitionSqlContext,
stream_files::collect_stream_files,
},
};
pub mod cache;
#[cfg(feature = "enterprise")]
pub mod cardinality;
pub mod cluster;
pub mod file_list;
@ -83,7 +79,11 @@ pub mod streaming;
pub mod super_cluster;
pub mod work_group;
use ::search::{bloom_pruner, datafusion, index, inspector, sql, tantivy, utils};
use ::search::{
bloom_pruner,
datafusion::{self, distributed_plan::streaming_aggs_exec},
index, inspector, sql, tantivy, utils,
};
use searcher::Searcher;
/// The result of search in cluster
@ -315,7 +315,6 @@ pub async fn search(
Ok(res)
}
Err(e) => {
#[cfg(feature = "enterprise")]
if let Some(streaming_id) = in_req.query.streaming_id.as_ref() {
streaming_aggs_exec::remove_cache(streaming_id)
}
@ -661,18 +660,15 @@ pub async fn search_partition(
}
}
#[cfg(feature = "enterprise")]
{
let (streaming_aggs, streaming_id, cache_strategy) =
prepare_streaming_aggregate(trace_id, req, &ctx, use_cache).await?;
resp.streaming_output = streaming_aggs;
resp.streaming_aggs = streaming_aggs;
resp.streaming_id = streaming_id;
let (streaming_aggs, streaming_id, cache_strategy) =
prepare_streaming_aggregate(trace_id, req, &ctx, use_cache).await?;
resp.streaming_output = streaming_aggs;
resp.streaming_aggs = streaming_aggs;
resp.streaming_id = streaming_id;
if let Some(strategy) = cache_strategy {
resp.partitions = strategy.to_time_partitions(ctx.sql_order_by);
return Ok(resp);
}
if let Some(strategy) = cache_strategy {
resp.partitions = strategy.to_time_partitions(ctx.sql_order_by);
return Ok(resp);
}
let partition_settings = calculate_partition_settings(

View File

@ -13,55 +13,43 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
#[cfg(feature = "enterprise")]
use {
crate::partition::sql_context::PartitionSqlContext,
config::meta::search::SearchPartitionRequest,
config::utils::sql::is_simple_aggregate_query,
config::{
ider,
meta::{
search::{CardinalityLevel, generate_aggregation_search_interval},
sql::resolve_stream_names,
},
},
infra::errors::Error,
o2_enterprise::enterprise::search::cache_aggs_util,
o2_enterprise::enterprise::search::{
cache::streaming_agg::{
self, StreamingAggsPartitionStrategy, create_aggregation_cache_file_path,
discover_cache_for_query, generate_optimal_partitions,
get_aggregation_cache_key_from_request,
},
datafusion::distributed_plan::streaming_aggs_exec,
use ::search::{
cache::streaming_agg::{
self, StreamingAggsPartitionStrategy, create_aggregation_cache_file_path,
discover_cache_for_query, generate_optimal_partitions,
get_aggregation_cache_key_from_request,
},
datafusion::distributed_plan::streaming_aggs_exec,
sql::visitor::streaming_aggregate,
};
use config::{
ider,
meta::{
search::{CardinalityLevel, SearchPartitionRequest, generate_aggregation_search_interval},
sql::resolve_stream_names,
},
utils::sql::is_simple_aggregate_query,
};
use infra::errors::Error;
use crate::partition::sql_context::PartitionSqlContext;
/// Determine whether a streaming aggregate query should be used for the given SQL query.
#[cfg(feature = "enterprise")]
pub fn is_streaming_aggregate(sql: &str, ts_column: Option<&str>) -> bool {
let feature_query_streaming_aggs = config::get_config().common.feature_query_streaming_aggs;
let mut is_cachable_aggs = is_simple_aggregate_query(sql).unwrap_or(false);
let res: Result<cache_aggs_util::CacheAggregationAnalysisResult, String> =
cache_aggs_util::analyze_count_aggregation_pattern(sql);
if let Ok(result) = res {
is_cachable_aggs = result.matches_pattern || is_cachable_aggs;
if let Ok(matches_pattern) = streaming_aggregate::matches_streaming_aggregate_pattern(sql) {
is_cachable_aggs = matches_pattern || is_cachable_aggs;
}
ts_column.is_none() && is_cachable_aggs && feature_query_streaming_aggs
}
#[cfg(not(feature = "enterprise"))]
pub fn is_streaming_aggregate(_sql: &str, _ts_column: Option<&str>) -> bool {
false
}
/// Prepare streaming aggregate execution: discover cache, generate partition strategy,
/// and initialize cache for the streaming aggregation pipeline.
///
/// Returns `(streaming_aggs, streaming_id, partition_strategy)`.
#[cfg(feature = "enterprise")]
pub async fn prepare_streaming_aggregate(
trace_id: &str,
req: &SearchPartitionRequest,

View File

@ -15,7 +15,7 @@
use std::time::Instant;
use ::search::{QueryDelta, SearchResultType};
use ::search::{QueryDelta, SearchResultType, datafusion::distributed_plan::streaming_aggs_exec};
use config::meta::{
search::{
PARTIAL_ERROR_RESPONSE_MESSAGE, Response, SearchEventType, SearchPartitionRequest,
@ -25,8 +25,6 @@ use config::meta::{
stream::StreamType,
};
use log;
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::search::datafusion::distributed_plan::streaming_aggs_exec;
use tokio::sync::mpsc;
use tracing::Instrument;
@ -35,8 +33,6 @@ use super::{
utils::{calculate_progress_percentage, get_top_k_values},
};
use crate as SearchService;
#[cfg(feature = "enterprise")]
use crate::cache::cacher::delete_cache;
/// Time slices the query window is cut into for pattern-extraction sampling.
///
@ -474,11 +470,8 @@ pub async fn do_partitioned_search(
}
// Remove the streaming_aggs cache
if is_streaming_aggs && let Some(_streaming_id) = &partition_resp.streaming_id {
#[cfg(feature = "enterprise")]
{
streaming_aggs_exec::remove_cache(_streaming_id)
}
if is_streaming_aggs && let Some(streaming_id) = &partition_resp.streaming_id {
streaming_aggs_exec::remove_cache(streaming_id)
}
Ok(())
@ -1001,9 +994,8 @@ pub async fn process_delta(
}
// Remove the streaming_aggs cache
if is_streaming_aggs && let Some(_streaming_id) = partition_resp.streaming_id {
#[cfg(feature = "enterprise")]
streaming_aggs_exec::remove_cache(&_streaming_id)
if is_streaming_aggs && let Some(streaming_id) = partition_resp.streaming_id {
streaming_aggs_exec::remove_cache(&streaming_id)
}
Ok(())
@ -1102,61 +1094,6 @@ async fn send_partial_search_resp(
Ok(())
}
/// Clear streaming aggregation cache files for the given streaming_id
/// This should be called once before processing partitions when clear_cache is true
#[deprecated]
#[allow(dead_code)]
#[cfg(feature = "enterprise")]
async fn clear_streaming_agg_cache(
trace_id: &str,
streaming_id: &str,
start_time: i64,
end_time: i64,
) -> Result<(), infra::errors::Error> {
use o2_enterprise::enterprise::search::datafusion::distributed_plan::streaming_aggs_exec::GLOBAL_CACHE;
log::info!(
"[HTTP2_STREAM] [trace_id: {}] [streaming_id: {}] clear_cache is set, deleting old cache files",
trace_id,
streaming_id
);
// Get the cache file path from GLOBAL_CACHE
let streaming_item = GLOBAL_CACHE.id_cache.get(streaming_id);
if let Some(item) = streaming_item {
let cache_file_path = item.get_cache_file_path();
// Delete cache files in the time range using DeletionCriteria::TimeRange
if let Err(e) = delete_cache(&cache_file_path, 0, Some(start_time), Some(end_time)).await {
log::error!(
"[HTTP2_STREAM] [trace_id: {}] [streaming_id: {}] Error deleting cache files: {}",
trace_id,
streaming_id,
e
);
return Err(infra::errors::Error::Message(format!(
"Failed to delete cache: {e}",
)));
}
log::info!(
"[HTTP2_STREAM] [trace_id: {}] [streaming_id: {}] Successfully deleted cache files for time range: {} - {}",
trace_id,
streaming_id,
start_time,
end_time
);
} else {
log::warn!(
"[HTTP2_STREAM] [trace_id: {}] [streaming_id: {}] No cache file path found in GLOBAL_CACHE",
trace_id,
streaming_id
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use config::meta::sql::OrderBy;

View File

@ -0,0 +1,76 @@
# Playwright CI shard matrices — single source of truth
## Manifest index
Each Playwright workflow builds its matrix from one of these JSON files (via a
`generate_matrix` job). Shared workflows use an OSS **base** + an ENT **overlay**
(`*.ent.json`, in the enterprise repo); ENT-only workflows use a standalone manifest.
| Manifest | Drives workflow | Kind |
|---|---|---|
| `ci_matrix.json` (+ ENT `ci_matrix.ent.json`) | `playwright.yml` (PR gate) | shared base + overlay |
| `ci_matrix_regression.json` (+ ENT `ci_matrix_regression.ent.json`) | `playwright_regression.yml` | shared base + overlay |
| `ci_matrix_cloud.json` *(ENT repo)* | `playwright_alpha1.yml` | ENT-only standalone |
| `ci_matrix_env.json` *(ENT repo)* | `playwright_env.yml` | ENT-only standalone |
| `ci_matrix_env_scheduled.json` *(ENT repo)* | `playwright_env_scheduled.yml` | ENT-only standalone |
| `ci_matrix_firefox.json` *(ENT repo)* | `playwright-firefox-ondemand.yml` | ENT-only standalone |
Base manifests + `build-ci-matrix.js` live in OSS; overlays and ENT-only manifests live
in `o2-enterprise/tests/ui-testing/ci-matrix/`. The merge script is shared (ENT reuses it
from its OSS checkout).
---
## The shared PR-gate matrix (below refers to `ci_matrix.json`)
`ci_matrix.json` (this directory) is the **only** place the Playwright UI shard list
lives. Both the OSS and Enterprise `playwright.yml` workflows build their test matrix
from it at run time via `.github/scripts/build-ci-matrix.js`, so a spec added here runs
in **both** repos automatically — no more hand-syncing two workflow files.
## Adding / moving a spec
- **A spec both OSS and ENT run:** edit `ci_matrix.json` only. Add the filename to the
`run_files` of the right shard (`testfolder`). Done — ENT picks it up on its next run.
- **An enterprise-only spec:** edit `o2-enterprise/tests/ui-testing/ci-matrix/ci_matrix.ent.json`
(the overlay), never this file. Two shapes:
- add it to an existing shared shard → `"append": { "<testfolder>": ["my.spec.js"] }`
- a whole new ENT-only shard → add an object to `"shards": [ … ]`.
- **A new shard:** add a new object to `ci_matrix.json` with `testfolder`,
`actual_folder`, `browser`, `run_files`.
## Fields
| field | meaning |
|-----------------|-------------------------------------------------------------------------|
| `testfolder` | shard label — becomes the job name `e2e / <testfolder>` (must be unique) |
| `actual_folder` | real directory under `playwright-tests/` (e.g. `Logs-Core``Logs`) |
| `browser` | `chrome` |
| `run_files` | spec filenames run by this shard |
| `disabled` | *(optional)* specs intentionally turned off — see below |
## Disabling a spec (JSON has no `//` comments)
Every shard ships with a `"disabled": []` placeholder, so turning a spec off is a
fill-in-the-blank — don't delete the spec you want to remember, **move it into that
shard's `disabled` array** with a reason. `build-ci-matrix.js` never emits `disabled`, so
those specs don't run, but the record survives and is git-diffable:
```json
{
"testfolder": "Alerts",
"run_files": ["alerts-ui-operations.spec.js"],
"disabled": [
{ "file": "alerts-e2e-flow.spec.js", "reason": "flaky; pending rewrite" }
]
}
```
A spec cannot be in both `run_files` and `disabled` — the build fails if it is. Any
`_comment` (or `_`-prefixed) key is also ignored, for free-form notes.
**Enterprise-only disabled specs** go in the overlay's `disabled` map, keyed by shard:
`"disabled": { "Alerts": [ { "file": "…", "reason": "…" } ] }`.
The ENT overlay only ever carries the **delta** from OSS. It must not re-list any spec
already in `ci_matrix.json`; `build-ci-matrix.js` fails the run if it does.

View File

@ -0,0 +1,414 @@
[
{
"testfolder": "GeneralTests",
"actual_folder": "GeneralTests",
"browser": "chrome",
"run_files": [
"sanity.spec.js",
"changeOrg.spec.js",
"enrichment.spec.js",
"schema.spec.js",
"schemaload.spec.js",
"serviceAccount.spec.js",
"usersOrg.spec.js",
"org.spec.js",
"theme-management.spec.js",
"ingestion-config.spec.js",
"ingestionTokens.spec.js",
"landingPage.spec.js",
"languageTranslation.spec.js",
"model-pricing.spec.js",
"edition-features.spec.js",
"logsqueries.sql-comments.spec.js",
"iam-form-validation.spec.js",
"regex-patterns-form-validation.spec.js",
"cipher-keys-form-validation.spec.js",
"settings-form-validation.spec.js",
"ai-toolsets-form-validation.spec.js",
"shared-components-form-validation.spec.js",
"onboarding-form-validation.spec.js",
"rum-form-validation.spec.js",
"login-form-validation.spec.js"
],
"disabled": []
},
{
"testfolder": "Logs-Builder-Basic",
"actual_folder": "Logs",
"browser": "chrome",
"run_files": [
"logsQueryBuilder-chart.spec.js",
"logsQueryBuilder-filters-basic.spec.js",
"pagination.spec.js",
"unflattened.spec.js"
],
"disabled": []
},
{
"testfolder": "Logs-Builder-Advanced",
"actual_folder": "Logs",
"browser": "chrome",
"run_files": [
"logsQueryBuilder-editor.spec.js",
"logsQueryBuilder-filters-advanced.spec.js",
"logsquickmode.spec.js",
"logshistogram.spec.js",
"logsQuickPick.spec.js"
],
"disabled": []
},
{
"testfolder": "Logs-Core",
"actual_folder": "Logs",
"browser": "chrome",
"run_files": [
"logspage.spec.js",
"logstable.spec.js",
"logsqueries.spec.js",
"join.spec.js",
"shareLink.spec.js",
"logsAnalyzeDimensions.spec.js",
"monaco-query-prefill.spec.js",
"logsqueries.matchall.spec.js",
"ftsDefaultColumn.spec.js",
"logsVisualizePersistence.spec.js"
],
"disabled": []
},
{
"testfolder": "Logs-Features",
"actual_folder": "Logs",
"browser": "chrome",
"run_files": [
"logs-autocomplete-suggestions.spec.js",
"logs-sql-autocomplete.spec.js",
"logsqueries.cte.spec.js",
"logsDownloads.spec.js",
"secondsPrecisionAdded.spec.js",
"searchpartition.spec.js",
"indexquery.spec.js",
"region.spec.js"
],
"disabled": [
{
"file": "searchJobInspector.spec.js",
"reason": "TODO: update for new correlation feature"
}
]
},
{
"testfolder": "Alerts",
"actual_folder": "Alerts",
"browser": "chrome",
"run_files": [
"alerts-ui-operations.spec.js",
"alerts-import.spec.js",
"alerts-metrics-notification.spec.js",
"alerts-destinations-prebuilt.spec.js",
"alerts-vrl-encoding.spec.js",
"alerts-history.spec.js",
"alerts-template-prebuilt-guard.spec.js",
"alerts-form-validation.spec.js",
"anomaly-detection-form-validation.spec.js",
"alerts-priority-tags.spec.js",
"alerts-multialert-api.spec.js",
"alerts-multialert-ui.spec.js",
"alerts-multialert-regression.spec.js",
"alerts-multialert-firing.spec.js",
"alerts-multialert-transitions.spec.js"
],
"disabled": [
{
"file": "alerts-e2e-flow.spec.js",
"reason": ""
},
{
"file": "alerts-advanced.spec.js",
"reason": ""
},
{
"file": "alerts-scheduled-features.spec.js",
"reason": ""
}
]
},
{
"testfolder": "Dashboards-Core",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard.spec.js",
"dashboard2.spec.js",
"dashboardtype.spec.js",
"dashboard-folder.spec.js",
"dashboard-import.spec.js",
"maxquery.spec.js",
"dashboard-chartJson.spec.js",
"dashboard-promql-suggestions.spec.js",
"dashboard-sql-autocomplete.spec.js",
"dashboard-share-link.spec.js",
"crossLinking.spec.js",
"dashboard-multi-sql.spec.js",
"crossLinkMultiStream.spec.js",
"dashboards-form-validation.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Isolated",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-favorites.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Settings",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-filter.spec.js",
"dashboard-general-setting.spec.js",
"dashboard-tabs-setting.spec.js",
"dashboard-transpose.spec.js",
"dashboard-join.spec.js",
"dashboard-nested-functions.spec.js",
"dashboard-legends-copy.spec.js",
"dashboard-raw-query.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Charts",
"_comment": "Dashboard panel tests are grouped by feature so each shard stays under the ephemeral-runner endurance window. A single combined 16-spec shard ran ~40 min and got reclaimed mid-run ('runner lost communication'). Add new specs to the shard of the feature they cover.",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"custom-charts.spec.js",
"dashboard-geoMap.spec.js",
"dashboard-maps.spec.js",
"dashboard-multi-y-axis.spec.js",
"dashboard-html-chart.spec.js",
"dashboard-sankey.spec.js",
"dashboard-pie-donut.spec.js",
"dashboard-metric-camelcase.spec.js",
"dashboard-create-alert.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Visualize",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"visualize.spec.js",
"visualize-vrl.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Tables",
"_comment": "Owns the pagination and pivot-table specs (previously their own single-spec shards) \u2014 keep them here.",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-table-chart.spec.js",
"dashboard-table-copy-cell.spec.js",
"dashboard-table-csv-download.spec.js",
"dashboard-table-column-formatting.spec.js",
"dashboard-table-column-filtering.spec.js",
"dashboard-table-pagination.spec.js",
"dashboard-pivot-table.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Streaming",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-streaming.spec.js"
],
"disabled": []
},
{
"testfolder": "Pipelines",
"actual_folder": "Pipelines",
"browser": "chrome",
"run_files": [
"pipeline-conditions.spec.js",
"pipeline-conditions-validation.spec.js",
"pipelines.spec.js",
"pipeline-dynamic.spec.js",
"pipeline-core.spec.js",
"pipeline-traces.spec.js",
"pipeline-metrics.spec.js",
"pipeline-backfill.spec.js",
"pipeline-history.spec.js",
"scheduled-pipeline-query-builder.spec.js",
"pipelines-form-validation.spec.js"
],
"disabled": [
{
"file": "remotepipeline.spec.js",
"reason": ""
}
]
},
{
"testfolder": "Functions",
"actual_folder": "Functions",
"browser": "chrome",
"run_files": [
"js-transform-type.spec.js",
"row-expansion.spec.js",
"enrichment-table-url.spec.js",
"functions-form-validation.spec.js",
"action-scripts-form-validation.spec.js"
],
"disabled": []
},
{
"testfolder": "Reports",
"actual_folder": "Reports",
"browser": "chrome",
"run_files": [
"reportsScheduleNow.spec.js",
"reportsScheduleLater.spec.js",
"reportFolders.spec.js",
"reports-bulk-operations.spec.js",
"reports-form-validation.spec.js",
"reportCsvMediaType.spec.js"
],
"disabled": []
},
{
"testfolder": "Streams",
"actual_folder": "Streams",
"browser": "chrome",
"run_files": [
"multiselect-stream.spec.js",
"streamname.spec.js",
"streaming.spec.js",
"stream-settings.spec.js",
"streams-form-validation.spec.js"
],
"disabled": []
},
{
"testfolder": "Traces",
"actual_folder": "Traces",
"browser": "chrome",
"run_files": [
"service-catalog.spec.js",
"tracesSearch.spec.js",
"traceQueryEditor.spec.js",
"traceErrorFilter.spec.js",
"traceDetails.spec.js",
"traceAdvancedFiltering.spec.js",
"tracesAnalyzeDimensions.spec.js",
"traces-autocomplete-suggestions.spec.js"
],
"disabled": []
},
{
"testfolder": "RUM",
"_comment": "The RUM ingest/token/sourcemaps routes are part of the standard (OSS) router (src/api/http/src/handler/http/router/mod.rs), so these specs run against the OSS binary. Ingestion-auth negative cases live in the API suite (tests/api-testing/tests/rum).",
"actual_folder": "RUM",
"browser": "chrome",
"run_files": [
"rum-cdn-dataflow.spec.js",
"rum-npm-dataflow.spec.js",
"rum-page-dataflow.spec.js",
"rum-onboarding-snippets.spec.js",
"sourcemap-ui.spec.js",
"sourcemap-upload-pretty.spec.js"
],
"disabled": []
},
{
"testfolder": "RUM-Token",
"_comment": "rum-token.spec.js RESETS the org RUM token; running it beside the dataflow specs (workers run files in parallel) would 401 their in-flight SDK beacons. Own shard = own isolated instance \u2014 do NOT merge back into RUM.",
"actual_folder": "RUM",
"browser": "chrome",
"run_files": [
"rum-token.spec.js"
],
"disabled": []
},
{
"testfolder": "Metrics",
"actual_folder": "Metrics",
"browser": "chrome",
"run_files": [
"metrics.spec.js",
"metrics-queries.spec.js",
"metrics-aggregations.spec.js",
"metrics-advanced.spec.js",
"metrics-config-tabs.spec.js",
"metrics-visualizations.spec.js",
"metrics-config.spec.js",
"metrics-promql-query-persistence.spec.js",
"metrics-table-column-order.spec.js",
"metrics-promql-builder.spec.js",
"promqlAutocomplete.spec.js",
"metrics-share-deep-link.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Variables",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-variables-setting.spec.js",
"dashboard-variables-global.spec.js",
"dashboard-variables-tab-level.spec.js",
"dashboard-variables-panel-level.spec.js",
"dashboard-variables-dependency.spec.js",
"dashboard-variables-refresh.spec.js",
"dashboard-variables-url-sync.spec.js",
"dashboard-variables-creation-scopes.spec.js",
"dashboard-variables-default-values-chain.spec.js",
"dashboard-variables-custom-parents.spec.js",
"dashboard-variables-stream-field.spec.js",
"dashboard-mustache-variables.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboards-Panel-Level-DateTime-Config",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-panel-time-config-behavior.spec.js",
"dashboard-panel-time-url-priority.spec.js",
"dashboard-panel-time-advanced-edge-cases.spec.js",
"dashboard-panel-time-apply-behavior.spec.js",
"dashboard-panel-time-variables-behavior.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboard-Config-Settings",
"actual_folder": "Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-series-color-multiwindow.spec.js",
"dashboard-config-advanced.spec.js",
"dashboard-config-axis.spec.js",
"dashboard-config-gauge-maps.spec.js",
"dashboard-config-general.spec.js",
"dashboard-config-legends.spec.js",
"dashboard-config-line-style.spec.js",
"dashboard-config-panel-time.spec.js",
"dashboard-config-table.spec.js",
"dashboard-config-trellis.spec.js",
"dashboard-config-drilldown.spec.js",
"dashboard-config-promql.spec.js",
"dashboard-config-markline.spec.js"
],
"disabled": []
}
]

View File

@ -0,0 +1,100 @@
[
{
"testfolder": "Logs-Regression",
"actual_folder": "RegressionSet/Logs",
"browser": "chrome",
"run_files": [
"logs-regression.spec.js",
"logs-bugs.spec.js",
"logs-9754.spec.js",
"logs-9044-7354.spec.js"
],
"disabled": []
},
{
"testfolder": "Alerts-Regression",
"actual_folder": "RegressionSet/Alerts",
"browser": "chrome",
"run_files": [
"alerts-regression.spec.js",
"alerts-stream-switching.spec.js",
"alerts-bugs.spec.js"
],
"disabled": []
},
{
"testfolder": "Dashboard-Regression",
"actual_folder": "RegressionSet/Dashboards",
"browser": "chrome",
"run_files": [
"dashboard-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "Streams-Regression",
"actual_folder": "RegressionSet/Streams",
"browser": "chrome",
"run_files": [
"streams-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "Pipelines-Regression",
"actual_folder": "RegressionSet/Pipelines",
"browser": "chrome",
"run_files": [
"enrichment-regression.spec.js",
"pipeline-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "Traces-Regression",
"actual_folder": "RegressionSet/Traces",
"browser": "chrome",
"run_files": [
"traces-regression.spec.js",
"traces-bugs.spec.js"
],
"disabled": []
},
{
"testfolder": "Metrics-Regression",
"actual_folder": "RegressionSet/Metrics",
"browser": "chrome",
"run_files": [
"metrics-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "GeneralTests-Regression",
"actual_folder": "RegressionSet/General",
"browser": "chrome",
"run_files": [
"ui-regression.spec.js",
"landing-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "DataSources-Regression",
"actual_folder": "RegressionSet/DataSources",
"browser": "chrome",
"run_files": [
"datasources-regression.spec.js"
],
"disabled": []
},
{
"testfolder": "Reports-Regression",
"actual_folder": "RegressionSet/Reports",
"browser": "chrome",
"run_files": [
"reports-regression-bugs.spec.js"
],
"disabled": []
}
]

View File

@ -67,6 +67,8 @@ export class AlertsPage {
// trigger swaps to an input on click. -trigger opens, -input edits.
alertNameTrigger: '[data-test="add-alert-name-input-trigger"]',
alertNameInputField: '[data-test="add-alert-name-input-input"]',
// Display-mode value of the inline-edit title (present when NOT editing).
alertNameValue: '[data-test="add-alert-name-input-value"]',
alertSubmitButton: '[data-test="add-alert-submit-btn"]',
alertBackButton: '[data-test="add-alert-back-btn"]',
@ -826,6 +828,22 @@ export class AlertsPage {
testLogger.info(`Filled alert name: ${name}`);
}
/**
* Read the current alert name from the inline-edit title (OFormInlineEdit).
* In display mode the value is the `-value` span; only in edit mode is there
* an `-input`. Reads the committed display value, falling back to the live
* input value if the editor happens to be open.
* @returns {Promise<string>}
*/
async getAlertName() {
const valueSpan = this.page.locator(this.locators.alertNameValue).first();
if (await valueSpan.count() > 0) {
return (await valueSpan.innerText()).trim();
}
// Editor is open (edit mode) — read the live input value instead.
return (await this.page.locator(this.locators.alertNameInputField).inputValue()).trim();
}
/**
* Click the Advanced tab in the alert creation form
*/

View File

@ -54,7 +54,9 @@ const CHROME_USE = {
...devices['Desktop Chrome'],
viewport: { width: 1500, height: 1024 },
permissions: ['clipboard-read', 'clipboard-write'],
// Reuse auth state from global setup (Dex email login)
// Reuse auth state from global setup (Dex email login). Filename is canonical;
// multi-user splitting happens at the CI layer (each shard downloads its own
// user's artifact into this path). See global-setup-alpha1.js AUTH_FILE.
storageState: path.join(__dirname, 'playwright-tests/utils/auth/user.json'),
};

View File

@ -260,20 +260,42 @@ test.describe("Dashboard PromQL Query Editor Suggestions", () => {
}
// Stage 1: trigger label NAME suggestions inside cpu_usage{}
//
// Two timing rules this test used to break, both of them about WHEN the
// suggestion list is built rather than what it contains.
//
// The list is rebuilt from the editor's `update-query`, so it belongs to
// wherever the caret was when the TEXT last changed. Typing `cpu_usage{}`
// and stepping back in with ArrowLeft never changes the text, so the list
// stays the one built past the closing brace — where metric names, not
// labels, are what belongs. Typing `cpu_usage{` and letting Monaco
// auto-close leaves the caret inside the braces on the last edit.
//
// And `update-query` is debounced by CodeQueryEditor (500ms), so pressing
// Ctrl+Space immediately after typing reads the list from BEFORE the `{`.
// That is how this test came to accept `active_sessions` — a metric name —
// as a label, and then ask for the values of a label cpu_usage does not
// have.
await editor.clearEditor();
await editor.typeInEditor("cpu_usage{}");
await editor.pressKey("ArrowLeft");
await editor.typeInEditor("cpu_usage{");
await page.waitForTimeout(1000);
await editor.triggerSuggestionsAndWait();
const nameLabels = await editor.getSuggestionLabels(10);
expect(nameLabels.length).toBeGreaterThan(0);
// The metric's labels, not the metric list: `cpu_usage{active_sessions=`
// is not a query, and the value lookup below can only succeed for a label
// this metric actually has.
expect(nameLabels[0]).not.toContain("(");
testLogger.info(`First label name suggestion: ${nameLabels[0]}`);
// Accept the first label name suggestion.
await editor.pressKey("Enter");
// Stage 2: type '=' to switch the suggestion context to label VALUES.
// Same debounce as stage 1, plus the values themselves are a network call.
await editor.typeInEditor("=");
await page.waitForTimeout(1000);
await editor.waitForSuggestionsReady();
const valueLabels = await editor.getSuggestionLabels(5);

View File

@ -24,7 +24,7 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const logData = require("../../fixtures/log.json");
const { ingestTestData } = require('../utils/data-ingestion.js');
const { ingestTestData, waitForStreamData } = require('../utils/data-ingestion.js');
const MonacoEditorHelper = require('../utils/MonacoEditorHelper.js');
// ============================================================================
@ -130,18 +130,47 @@ async function waitForFieldInIndexedDB(page, org, streamType, streamName, fieldN
}
/**
* Click a field expand button and wait for the values_stream response that
* carries the values back to the page (which then schedules the IDB write).
* Replaces the legacy `click → waitForTimeout(2000)` pattern with a deterministic
* wait keyed on the actual /_values_stream response.
* Expand a field in the sidebar and deterministically capture its values.
*
* The sidebar expand fires a /_values_stream fetch; the page then schedules an
* IndexedDB write from that response. The legacy pattern here was
* `click → poll IndexedDB(10s)`, but the 10s window started at CLICK time and so
* raced a slow-runner fetch: under CI load /_values_stream could land AFTER the
* poll had already given up, leaving the record null even though the data
* existed (3848 rows are ingested at shard start). This anchors the IndexedDB
* wait AFTER the values response instead of racing it, which removes the flake.
*
* Matches any /_values_stream response (the click triggers exactly one, for this
* field) rather than the exact `fields=` encoding, so it is robust to how the
* field name is serialised. The OSS test env serves values over HTTP/SSE, so the
* response reliably surfaces to waitForResponse; the wait is still best-effort
* (`.catch`) so any non-HTTP transport falls straight through to the IDB poll.
*
* @returns the IndexedDB record ({ values, ... }) or null if none was captured.
*/
async function clickFieldExpandAndWaitValues(page, button, fieldName) {
const valuesPromise = page.waitForResponse(
(r) => r.url().includes('_values_stream') && r.url().includes(`fields=${fieldName}`),
{ timeout: 15000 }
async function expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName) {
const waitValues = () => page.waitForResponse(
(r) => r.url().includes('_values_stream'),
{ timeout: 20000 }
).catch(() => null);
let valuesResponse = waitValues();
await button.click();
await valuesPromise;
let responded = await valuesResponse;
// If the click collapsed an already-expanded panel (no values fetch fired —
// e.g. a prior retry round left this field open), re-click to expand and
// re-arm the wait. Guarded on `responded` so a field that DID fetch is never
// toggled back shut.
if (!responded) {
valuesResponse = waitValues();
await button.click().catch(() => {});
responded = await valuesResponse;
}
// The IDB write happens within a beat of the response landing, so an 8s
// window post-response is ample (vs. the old 10s racing from click time).
return await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 8000);
}
/**
@ -339,55 +368,75 @@ async function runQueryAndWaitForResults(page, pm) {
}
/**
* Find a field that has string values (not purely numeric or boolean)
* Iterates through available fields until finding one with string values
* Returns { fieldName, stringValue, record } or null if none found
* Pick the first genuine string value (not numeric / boolean) from a captured
* IndexedDB record. Returns { fieldName, stringValue, record } or null.
*/
async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttempts = 5) {
const fieldButtons = pm.logsPage.getAllFieldExpandButtons();
const buttonCount = await fieldButtons.count();
function pickStringValue(fieldName, record) {
if (record && record.values && record.values.length > 0) {
const stringValue = record.values.find(v =>
isNaN(Number(v)) && v !== 'true' && v !== 'false'
);
if (stringValue) {
testLogger.info(`Found string field: ${fieldName} with value: ${stringValue}`);
return { fieldName, stringValue, record };
}
}
return null;
}
/**
* Find a field that has string values (not purely numeric or boolean).
* Iterates through available fields until finding one with string values,
* capturing each via expandFieldAndCaptureValues (response-anchored, no
* click-time race). If a full pass captures nothing, re-runs the query to
* refresh the field-values context and re-scans bounded by `maxRounds` so a
* genuinely value-less stream still fails in a reasonable time rather than
* hanging. Returns { fieldName, stringValue, record } or null if none found.
*/
async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttempts = 6, maxRounds = 2) {
// Try known string fields first - these are more likely to have string values
const preferredStringFields = ['kubernetes_container_name', 'level', 'log', 'kubernetes_pod_name', 'kubernetes_namespace_name'];
for (const preferredField of preferredStringFields) {
const preferredButton = pm.logsPage.getFieldExpandButton(preferredField);
if (await preferredButton.count() > 0) {
await preferredButton.click();
const scanOnce = async () => {
const fieldButtons = pm.logsPage.getAllFieldExpandButtons();
const buttonCount = await fieldButtons.count();
const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, preferredField, 10000);
if (record && record.values && record.values.length > 0) {
const stringValue = record.values.find(v =>
isNaN(Number(v)) && v !== 'true' && v !== 'false'
);
if (stringValue) {
testLogger.info(`Found string field: ${preferredField} with value: ${stringValue}`);
return { fieldName: preferredField, stringValue, record };
}
for (const preferredField of preferredStringFields) {
const preferredButton = pm.logsPage.getFieldExpandButton(preferredField);
if (await preferredButton.count() > 0) {
const record = await expandFieldAndCaptureValues(page, preferredButton, orgName, streamName, preferredField);
const hit = pickStringValue(preferredField, record);
if (hit) return hit;
}
}
}
// Fallback: iterate through all fields
for (let i = 0; i < Math.min(buttonCount, maxAttempts); i++) {
const button = fieldButtons.nth(i);
const dataTest = await button.getAttribute('data-test');
const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', '');
// Fallback: iterate through all fields
for (let i = 0; i < Math.min(buttonCount, maxAttempts); i++) {
const button = fieldButtons.nth(i);
const dataTest = await button.getAttribute('data-test');
const fieldName = dataTest.replace('log-search-expand-', '').replace('-field-btn', '');
// Skip if already expanded (part of preferred fields)
if (preferredStringFields.includes(fieldName)) continue;
// Skip if already expanded (part of preferred fields)
if (preferredStringFields.includes(fieldName)) continue;
await button.click();
const record = await expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName);
const hit = pickStringValue(fieldName, record);
if (hit) return hit;
}
const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 10000);
if (record && record.values && record.values.length > 0) {
const stringValue = record.values.find(v =>
isNaN(Number(v)) && v !== 'true' && v !== 'false'
);
if (stringValue) {
testLogger.info(`Found string field: ${fieldName} with value: ${stringValue}`);
return { fieldName, stringValue, record };
}
return null;
};
for (let round = 0; round < maxRounds; round++) {
const result = await scanOnce();
if (result) return result;
// No field yielded values this pass — the /_values backend may have
// transiently returned empty (index still catching up under load).
// Re-run the query to refresh the field-values context, then re-scan.
if (round < maxRounds - 1) {
testLogger.warn(`findFieldWithStringValues: no values captured on round ${round + 1}/${maxRounds}, refreshing and retrying`);
await runQueryAndWaitForResults(page, pm).catch(() => {});
}
}
@ -418,6 +467,14 @@ test.describe("Autocomplete Value Suggestions", () => {
// Ingest test data
await ingestTestData(page);
// Readiness gate: freshly-ingested data is not queryable immediately
// (WAL -> index lag). The value-capture flow reads the same index that
// /_search does, so gate on the stream being searchable before driving
// the UI — otherwise field expansion can fetch an empty value set and the
// IndexedDB capture never populates. Best-effort so an already-warm
// shared stream doesn't block on a slow poll.
await waitForStreamData(page, streamName, 1).catch(() => {});
// Navigate to logs page
await page.goto(
`${logData.logsUrl}?org_identifier=${orgName}`
@ -780,6 +837,9 @@ test.describe("Autocomplete Value Suggestions - Edge Cases", () => {
pm = new PageManager(page);
await page.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});
@ -1012,6 +1072,9 @@ test.describe("Autocomplete Value Suggestions - Quoting Behavior", () => {
pm = new PageManager(page);
await page.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});
@ -1031,10 +1094,11 @@ test.describe("Autocomplete Value Suggestions - Quoting Behavior", () => {
// Find the field expand button for 'code'
const codeFieldBtn = pm.logsPage.getFieldExpandButton('code');
await codeFieldBtn.waitFor({ state: 'visible', timeout: 5000 });
await codeFieldBtn.click();
// Wait for values in IndexedDB - assert values were captured
const record = await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, 'code', 15000);
// Response-anchored capture (not a click-time IDB race): waits for the
// /_values_stream fetch before confirming the IndexedDB write, so a slow
// runner can't make this null even though the data exists.
const record = await expandFieldAndCaptureValues(page, codeFieldBtn, orgName, streamName, 'code');
expect(record, 'Expected IndexedDB record for code field').not.toBeNull();
expect(record.values.length, 'Expected captured values for code field').toBeGreaterThan(0);
@ -1135,6 +1199,9 @@ test.describe("Autocomplete Value Suggestions - Cold Start & TTL", () => {
pm = new PageManager(page);
await page.waitForLoadState('domcontentloaded');
await ingestTestData(page);
// Gate on the stream being searchable before driving the UI so field
// expansion never fetches an empty value set (WAL -> index lag).
await waitForStreamData(page, streamName, 1).catch(() => {});
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
await page.waitForLoadState('domcontentloaded');
});

View File

@ -438,9 +438,11 @@ test.describe("Alerts Stream Switching Regression", () => {
// === SAVE + VERIFY: Full alert creation flow ===
// Capture the alert name that setupToQueryConfig filled.
// O2: OInput wraps the native <input>; use alertNameInputField to hit the real <input>.
const alertNameInput = page.locator(pm.alertsPage.alertNameInputField);
const alertName = await alertNameInput.inputValue();
// O2: the alert name is an OInlineEdit title — in display mode the committed
// value lives in the `-value` span; the `-input` only exists while editing.
// Reading inputValue() here times out because there is no input in the DOM,
// so read the display value instead.
const alertName = await pm.alertsPage.getAlertName();
testLogger.info(`Saving alert: ${alertName}`);
// Select destination using POM locator

View File

@ -0,0 +1,35 @@
const { test, expect } = require('@playwright/test');
const testLogger = require('./utils/test-logger.js');
/**
* Auth-warm spec mints a shared-auth artifact WITHOUT running the heavy
* org-wide cleanup.
*
* The alpha1 barrier (pre_test_cleanup job) splits its Dex logins across multiple
* users (ALPHA1_USER_INDEX). User 1 runs cleanup.spec.js (which also mints its
* artifact via globalSetup); users 2..N have no cleanup to do, so they run THIS
* spec instead. globalSetup already performed the Dex login and wrote
* user<N>.json / cloud-config<N>.json before this test executes all this test
* does is confirm the session loads, so the job fails loudly if login didn't take.
*
* Runs with SKIP_INGESTION=true in the barrier (no per-shard ingestion here).
*/
test.describe('Alpha1 auth warm-up', () => {
test('minted session is usable', {
tag: ['@auth-warm', '@all']
}, async ({ page, baseURL }) => {
const userIndex = (process.env.ALPHA1_USER_INDEX || '1').trim();
testLogger.info(`[alpha1] auth-warm: verifying minted session for user index ${userIndex}`);
await page.goto(`${baseURL}/web/`, { timeout: 60000, waitUntil: 'domcontentloaded' });
// If the saved session were invalid we'd be bounced to Dex/login here.
const url = page.url();
expect(url, `expected an authenticated app URL, got ${url}`).not.toContain('dex');
expect(url, `expected an authenticated app URL, got ${url}`).not.toMatch(/\/web\/login$/);
// Home menu item is only present once the SPA has an authenticated session.
await expect(page.locator('[data-test="menu-link-\\/-item"]')).toBeVisible({ timeout: 30000 });
testLogger.info(`[alpha1] auth-warm: session for user index ${userIndex} is valid`);
});
});

View File

@ -4,7 +4,14 @@ const fs = require('fs');
const testLogger = require('./test-logger.js');
const logsdata = require('../../../test-data/logs_data.json');
// Auth storage paths
// Auth storage paths. Filenames stay canonical (user.json / cloud-config.json)
// because ~20 spec files and shared utils (cloud-auth.js, enhanced-baseFixtures.js)
// read these exact paths. Multi-user splitting (ALPHA1_USER_INDEX, 1|2|3) is
// achieved at the CI layer instead: each shard runs on its own runner and
// downloads only ITS user's artifact into this dir, so the canonical file always
// holds the right user's session. USER_INDEX here only selects which Dex user to
// log in as (email resolution below).
const USER_INDEX = (process.env.ALPHA1_USER_INDEX || '1').trim();
const AUTH_DIR = path.join(__dirname, 'auth');
const AUTH_FILE = path.join(AUTH_DIR, 'user.json');
const CLOUD_CONFIG_FILE = path.join(AUTH_DIR, 'cloud-config.json');
@ -28,11 +35,17 @@ async function globalSetup() {
if (!baseUrl) {
throw new Error('ZO_BASE_URL must be set');
}
const userEmail = (process.env.ALPHA1_USER_EMAIL || '').trim();
// Resolve this shard's Dex user by index: ALPHA1_USER_EMAIL_<N> when provided,
// else fall back to the base ALPHA1_USER_EMAIL. This keeps the workflow safe to
// roll out incrementally — if _2/_3 aren't set yet, every shard just uses user 1.
// Password is shared across all users (single ALPHA1_USER_PASSWORD secret).
const userEmail = (process.env[`ALPHA1_USER_EMAIL_${USER_INDEX}`]
|| process.env.ALPHA1_USER_EMAIL || '').trim();
const userPassword = (process.env.ALPHA1_USER_PASSWORD || '').trim();
if (!userEmail || !userPassword) {
throw new Error('ALPHA1_USER_EMAIL and ALPHA1_USER_PASSWORD must be set');
}
testLogger.info(`[alpha1] Using Dex user index ${USER_INDEX} (${userEmail})`);
// Check if shared auth state exists (downloaded from cleanup job artifact)
// If valid, skip the entire Dex login flow — just verify and ingest

164
web/package-lock.json generated
View File

@ -50,7 +50,6 @@
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"monaco-editor": "^0.52.2",
"monaco-promql": "^1.9.0",
"node-polyfill-webpack-plugin": "^4.0.0",
"reka-ui": "^2.9.6",
"reodotdev": "^1.0.0",
@ -720,69 +719,6 @@
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -2500,39 +2436,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT",
"peer": true
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@mswjs/interceptors": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
@ -3244,46 +3147,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@prometheus-io/codemirror-promql": {
"version": "0.311.3",
"resolved": "https://registry.npmjs.org/@prometheus-io/codemirror-promql/-/codemirror-promql-0.311.3.tgz",
"integrity": "sha512-WgfC910pn/EHcwC9zRPwO8hGbT0G2eS5uvGi4GCAtKLY68SqfjIXaewvOMyR3avnIjoL7c45WpxkD5++df8omQ==",
"license": "Apache-2.0",
"dependencies": {
"@prometheus-io/lezer-promql": "0.311.3",
"lru-cache": "^11.2.7"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@codemirror/autocomplete": "^6.4.0",
"@codemirror/language": "^6.3.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.1",
"@codemirror/view": "^6.4.0",
"@lezer/common": "^1.0.1"
}
},
"node_modules/@prometheus-io/codemirror-promql/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@prometheus-io/lezer-promql": {
"version": "0.311.3",
"resolved": "https://registry.npmjs.org/@prometheus-io/lezer-promql/-/lezer-promql-0.311.3.tgz",
"integrity": "sha512-2MmOiYa4DNAkav12w8g5hSCZgGDEKFoT2Y6kqmhdhlXvP8zyACv3mx+wOqmJdkX0zLP0KZyjrQIOZtAXIHlnsA==",
"license": "Apache-2.0",
"peerDependencies": {
"@lezer/highlight": "^1.1.2",
"@lezer/lr": "^1.2.3"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@ -7568,12 +7431,6 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cron-parser": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.4.0.tgz",
@ -13342,15 +13199,6 @@
"license": "MIT",
"peer": true
},
"node_modules/monaco-promql": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/monaco-promql/-/monaco-promql-1.9.0.tgz",
"integrity": "sha512-s1gpvbE8R34S+g6KUPsmrDzghl6qYsFSAVorwotty4cZwbvKW22WKE+I0yzEN7s5mzzoPP6sBYZdafxDEWW54A==",
"license": "MIT",
"dependencies": {
"@prometheus-io/codemirror-promql": "0.311.3"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
@ -17157,12 +17005,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/stylelint": {
"version": "17.14.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz",
@ -19017,12 +18859,6 @@
"vue": "^3.2.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@ -80,7 +80,6 @@
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"monaco-editor": "^0.52.2",
"monaco-promql": "^1.9.0",
"node-polyfill-webpack-plugin": "^4.0.0",
"reka-ui": "^2.9.6",
"reodotdev": "^1.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -13,11 +13,29 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import CodeQueryEditor from "./CodeQueryEditor.vue";
import { createStore } from "vuex";
// Stable model instance. CodeQueryEditor's completion provider answers only for
// its OWN model (`if (own && model !== own) return { suggestions: [] }`), so a
// getModel() that returns a fresh object each call makes the provider
// permanently unreachable from tests — which is why the provider specs below
// were previously skipped.
const mockModel = {
getValue: vi.fn(() => ""),
setValue: vi.fn(),
getLineCount: vi.fn(() => 1),
getLineLength: vi.fn(() => 0),
pushEditOperations: vi.fn(),
getOffsetAt: vi.fn(() => 0),
getPositionAt: vi.fn(() => ({ lineNumber: 1, column: 1 })),
getLineContent: vi.fn(() => ""),
getValueInRange: vi.fn(() => ""),
getWordUntilPosition: vi.fn(() => ({ word: "", startColumn: 1, endColumn: 1 })),
};
// Stable mock editor instance so tests can reference it directly
const mockEditorObj = {
onDidChangeModelContent: vi.fn(),
@ -29,14 +47,7 @@ const mockEditorObj = {
getValue: vi.fn(() => ""),
setValue: vi.fn(),
layout: vi.fn(),
getModel: vi.fn(() => ({
getValue: vi.fn(() => ""),
setValue: vi.fn(),
getLineCount: vi.fn(() => 1),
getLineLength: vi.fn(() => 0),
pushEditOperations: vi.fn(),
getOffsetAt: vi.fn(() => 0),
})),
getModel: vi.fn(() => mockModel),
updateOptions: vi.fn(),
hasWidgetFocus: vi.fn(() => false),
getRawOptions: vi.fn(() => ({ readOnly: false })),
@ -57,11 +68,34 @@ vi.mock("monaco-editor/esm/vs/editor/editor.api", () => ({
setModelMarkers: vi.fn(),
},
languages: {
CompletionItemKind: {},
CompletionItemInsertTextRule: {},
// Real values, mirroring monaco-editor/esm/vs/editor/common/languages.js
CompletionItemKind: {
Method: 0,
Function: 1,
Constructor: 2,
Field: 3,
Variable: 4,
Operator: 11,
Value: 13,
Keyword: 17,
Text: 18,
Snippet: 27,
},
CompletionItemInsertTextRule: { None: 0, KeepWhitespace: 1, InsertAsSnippet: 4 },
register: vi.fn(),
setMonarchTokensProvider: vi.fn(),
// The promql branch of setupEditor calls this immediately after
// setMonarchTokensProvider. Missing, it throws mid-setup — swallowed,
// because the vitest config sets dangerouslyIgnoreUnhandledErrors.
setLanguageConfiguration: vi.fn(),
registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })),
// setupEditor registers all three providers in a row. The mock predates the
// signature-help and hover ones, so it was a `.mock` short of the component
// it stands in for, and `dangerouslyIgnoreUnhandledErrors` in the vitest
// config means the resulting "not a function" is swallowed rather than
// reported.
registerSignatureHelpProvider: vi.fn(() => ({ dispose: vi.fn() })),
registerHoverProvider: vi.fn(() => ({ dispose: vi.fn() })),
},
KeyMod: { CtrlCmd: 1 },
KeyCode: { Enter: 13 },
@ -134,6 +168,29 @@ describe("CodeQueryEditor", () => {
vi.clearAllMocks();
});
/**
* setupEditor looks its host up with `document.getElementById(props.editorId)`
* and, on a miss, retries five times on a 100ms timer before giving up
* WITHOUT ever reaching addCommand. Three describes used to fake that lookup
* with a `vi.spyOn(document, "getElementById")` installed per mount and
* restored in afterEach, which made "does this mount find its element" depend
* on mock lifecycle while ~50 mounts from earlier describes were still
* polling on their own timers. The result was bimodal: the tests either
* finished in ~52ms or hung past any timeout, which is exactly the shape the
* nine flaky failures had (they also reproduce on a clean origin/main).
*
* A real element carrying the real id removes the question every lookup,
* from any mount, finds it.
*/
const attachEditorHost = (id = "test-editor") => {
const existing = document.getElementById(id);
if (existing) return existing;
const host = document.createElement("div");
host.id = id;
document.body.appendChild(host);
return host;
};
const createWrapper = (props: any = {}) => {
return mount(CodeQueryEditor, {
props: {
@ -485,15 +542,13 @@ describe("CodeQueryEditor", () => {
describe("Ctrl+Enter / Cmd+Enter keyboard shortcut", () => {
let shortcutWrapper: ReturnType<typeof mount> | null = null;
let getElementByIdSpy: ReturnType<typeof vi.spyOn>;
// Spy on document.getElementById so setupEditor finds the editor element
// without needing the component attached to document. This bypasses the
// 100ms retry-loop setTimeout. Then use vi.waitFor to poll until the
// async setupEditor chain (dynamic imports + loadMonaco) fully completes.
const mountAndSetup = async (props: any = {}) => {
const fakeEditorEl = document.createElement("div");
getElementByIdSpy = vi.spyOn(document, "getElementById").mockReturnValue(fakeEditorEl);
attachEditorHost();
shortcutWrapper = mount(CodeQueryEditor, {
props: {
@ -514,7 +569,6 @@ describe("CodeQueryEditor", () => {
};
afterEach(() => {
getElementByIdSpy?.mockRestore();
shortcutWrapper?.unmount();
shortcutWrapper = null;
});
@ -547,14 +601,12 @@ describe("CodeQueryEditor", () => {
// Tests for the bug fix: setValue must coerce null/undefined to "" so Monaco's
// "Illegal argument" error can't surface when switching query modes (PromQL → SQL).
describe("when query becomes null/undefined (PromQL -> SQL switch)", () => {
let getElementByIdSpy: ReturnType<typeof vi.spyOn>;
let shortcutWrapper: ReturnType<typeof mount> | null = null;
// Mount the component and wait for the async setupEditor to complete so that
// editorObj is fully initialised and the exposed setValue is wired to mockEditorObj.
const mountAndSetup = async (props: any = {}) => {
const fakeEditorEl = document.createElement("div");
getElementByIdSpy = vi.spyOn(document, "getElementById").mockReturnValue(fakeEditorEl);
attachEditorHost();
shortcutWrapper = mount(CodeQueryEditor, {
props: {
@ -575,7 +627,6 @@ describe("CodeQueryEditor", () => {
};
afterEach(() => {
getElementByIdSpy?.mockRestore();
shortcutWrapper?.unmount();
shortcutWrapper = null;
});
@ -655,11 +706,7 @@ describe("CodeQueryEditor", () => {
// When a parent passes an explicit empty array (e.g. effectiveSuggestions during
// value context), the Monaco provider must return no function suggestions.
describe("suggestions prop — function suggestions gated by null vs []", () => {
let getElementByIdSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
getElementByIdSpy?.mockRestore();
});
afterEach(() => {});
// Snapshot the registerCompletionItemProvider call count before mounting,
// then wait for our mount to push a new call. Using a baseline (instead of
@ -671,8 +718,7 @@ describe("CodeQueryEditor", () => {
const registerFn = vi.mocked(monacoApi.languages.registerCompletionItemProvider);
const baselineIndex = registerFn.mock.calls.length;
const fakeEl = document.createElement("div");
getElementByIdSpy = vi.spyOn(document, "getElementById").mockReturnValue(fakeEl);
attachEditorHost();
mount(CodeQueryEditor, {
props: {
editorId: "test-editor",

View File

@ -52,13 +52,13 @@ import {
ref,
onMounted,
nextTick,
type Ref,
onDeactivated,
onUnmounted,
onActivated,
watch,
computed,
} from "vue";
import type { PropType } from "vue";
import type * as MonacoEditor from "monaco-editor/esm/vs/editor/editor.api";
@ -79,6 +79,41 @@ const loadMonaco = async () => {
};
import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition";
/**
* Per-editor configuration, keyed by model URI.
*
* Monaco aggregates every provider registered for a language, so registering
* one per component meant N providers answering each keystroke and N-1 of them
* returning an empty list for a model that did not ask. One provider set per
* language now, looking its editor up by model.
*/
interface EditorConfig {
enabled: () => boolean;
keywords: () => any[];
suggestions: () => any[];
resolveFieldValues: () => ((field: string) => Promise<string[]>) | null;
}
const editorConfigs = new Map<string, EditorConfig>();
const registeredLanguages = new Set<string>();
const modelKey = (model: any): string => model?.uri?.toString?.() ?? "";
import {
resolveKeywords,
resolveSuggestions,
buildCompletionItems,
} from "@/utils/query/sqlCompletion";
import {
parseCallContext,
parseValueContext,
buildValueEntries,
buildSignatureHelp,
buildHoverContents,
findCatalogEntry,
findFunctionEntry,
wantsNumericColumn,
rankNumericFieldsFirst,
} from "@/utils/query/editorProviders";
import { findDoubleQuoteIssues } from "@/utils/query/doubleQuoteWarnings";
import { loadPromqlLanguage } from "@/utils/query/promqlLanguageDefinition";
import { useStore } from "vuex";
@ -171,6 +206,15 @@ export default defineComponent({
type: String,
default: "",
},
/**
* Resolves the values of one field, awaited by the completion provider.
* Absent on surfaces that have none pass `undefined`, not `null`, so the
* declared default applies.
*/
fieldValueResolver: {
type: Function as PropType<(field: string) => Promise<string[]>>,
default: null,
},
},
emits: [
"update-query",
@ -198,166 +242,8 @@ export default defineComponent({
const { detectNaturalLanguage, generateSQL, transformToSQL, isGenerating, streamingResponse } =
useNLQuery();
let provider: Ref<any | null> = ref(null);
const currentEditorText = ref("");
// These will be initialized when Monaco loads
let CompletionKind: any = null;
let insertTextRules: any = null;
const initializeMonacoConstants = () => {
if (!monaco || CompletionKind) return;
CompletionKind = {
Keyword: monaco.languages.CompletionItemKind.Keyword,
Operator: monaco.languages.CompletionItemKind.Operator,
Text: monaco.languages.CompletionItemKind.Text,
Value: monaco.languages.CompletionItemKind.Value,
Method: monaco.languages.CompletionItemKind.Method,
Function: monaco.languages.CompletionItemKind.Function,
Constructor: monaco.languages.CompletionItemKind.Constructor,
Field: monaco.languages.CompletionItemKind.Field,
Variable: monaco.languages.CompletionItemKind.Variable,
};
insertTextRules = {
InsertAsSnippet: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
KeepWhitespace: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
None: monaco.languages.CompletionItemInsertTextRule.None,
};
};
const defaultKeywords = [
{
label: "and",
kind: "Keyword",
insertText: "and ",
},
{
label: "or",
kind: "Keyword",
insertText: "or ",
},
{
label: "like",
kind: "Keyword",
insertText: "like '%${1:params}%' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "in",
kind: "Keyword",
insertText: "in ('${1:params}') ",
insertTextRules: "InsertAsSnippet",
},
{
label: "not in",
kind: "Keyword",
insertText: "not in ('${1:params}') ",
insertTextRules: "InsertAsSnippet",
},
{
label: "between",
kind: "Keyword",
insertText: "between '${1:params}' and '${1:params}' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "not between",
kind: "Keyword",
insertText: "not between '${1:params}' and '${1:params}' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "is null",
kind: "Keyword",
insertText: "is null ",
},
{
label: "is not null",
kind: "Keyword",
insertText: "is not null ",
},
{
label: ">",
kind: "Operator",
insertText: "> ",
},
{
label: "<",
kind: "Operator",
insertText: "< ",
},
{
label: ">=",
kind: "Operator",
insertText: ">= ",
},
{
label: "<=",
kind: "Operator",
insertText: "<= ",
},
{
label: "<>",
kind: "Operator",
insertText: "<> ",
},
{
label: "=",
kind: "Operator",
insertText: "= ",
},
{
label: "!=",
kind: "Operator",
insertText: "!= ",
},
{
label: "()",
kind: "Keyword",
insertText: "(${1:condition}) ",
insertTextRules: "InsertAsSnippet",
},
];
const defaultSuggestions = [
{
label: (_keyword: string) => `match_all('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `match_all('${_keyword}')`,
},
{
label: (_keyword: string) => `match_all_raw('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `match_all_raw('${_keyword}')`,
},
{
label: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`,
},
{
label: () => `re_match(fieldname: string, regular_expression: string)`,
kind: "Text",
insertText: () => `re_match(fieldname, '')`,
},
{
label: () => `re_not_match(fieldname: string, regular_expression: string)`,
kind: "Text",
insertText: () => `re_not_match(fieldname, '')`,
},
{
label: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
},
{
label: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`,
},
];
watch(
() => isDark.value,
() => {
@ -366,19 +252,14 @@ export default defineComponent({
},
);
const keywords = computed(() => {
if (props.language === "sql" && !props.keywords?.length) {
return defaultKeywords;
}
return props.keywords;
});
const suggestions = computed(() => {
if (props.language === "sql" && props.suggestions == null) {
return defaultSuggestions;
}
return props.suggestions ?? [];
});
// Both fall back to the shared catalog so every surface (Logs, Traces,
// Dashboards, Alerts, Pipelines) is served identical content. Traces passes
// no `suggestions` prop and used to get a 7-entry local list with no
// aggregate functions at all.
const keywords = computed(() => resolveKeywords(props.language, props.keywords as any[]));
const suggestions = computed(() =>
resolveSuggestions(props.language, props.suggestions as any[] | null),
);
/**
* Debounced function to detect natural language and auto-toggle NLP mode
@ -505,20 +386,19 @@ export default defineComponent({
}
};
const createDependencyProposals = (range: any) => {
if (!CompletionKind || !insertTextRules) return [];
return keywords.value.map((keyword: any) => {
const itemObj: any = {
...keyword,
label: keyword["label"],
kind: CompletionKind[keyword["kind"]],
insertText: keyword["insertText"],
range: range,
};
if (insertTextRules[keyword["insertTextRule"]]) {
itemObj["insertTextRules"] = insertTextRules[keyword["insertTextRule"]];
}
return itemObj;
/** Point this editor's model at its live configuration. */
let publishedKey: string | null = null;
const publishEditorConfig = () => {
const key = modelKey(editorObj?.getModel?.());
if (!key) return;
publishedKey = key;
// Getters, not snapshots: keywords and suggestions are computeds that
// change as the stream schema and server catalog arrive.
editorConfigs.set(key, {
enabled: () => props.showAutoComplete,
keywords: () => keywords.value as any[],
suggestions: () => suggestions.value as any[],
resolveFieldValues: () => (props.fieldValueResolver as any) ?? null,
});
};
@ -533,14 +413,11 @@ export default defineComponent({
(window as any).monaco = monacoModule;
}
// Initialize Monaco constants after loading
initializeMonacoConstants();
// Register custom languages after Monaco is loaded
if (props.language === "promql") {
monaco.languages.register({ id: "promql" });
// Official monaco-promql grammar, verbatim without a tokenizer the
// The vendored PromQL grammar without a tokenizer the
// query renders monochrome (#9779, #9793).
const promql = await loadPromqlLanguage();
monaco.languages.setMonarchTokensProvider("promql", promql.language as any);
@ -582,9 +459,8 @@ export default defineComponent({
colors: {},
});
// Dispose the provider if it already exists before registering a new one
provider.value?.dispose();
registerAutoCompleteProvider();
// One provider set per language, shared by every editor of that language.
registerLanguageProviders(props.language);
let editorElement = document.getElementById(props.editorId);
let retryCount = 0;
@ -677,11 +553,23 @@ export default defineComponent({
minimap: { enabled: false },
readOnly: props.readOnly,
renderValidationDecorations: "on",
// Monaco defaults strings to 'off', which is why field-VALUE completion
// used to need a forced hide/re-trigger to appear at all.
quickSuggestions: { other: "on", comments: "off", strings: "on" },
// Default is 'matchingDocuments'. Off for the QUERY languages only,
// where every suggestion should come from the catalog and a word
// scraped out of the query text is noise. VRL, JS, JSON and the rest
// have no catalog, and there local word completion is the only
// completion they have.
wordBasedSuggestions:
props.language === "sql" || props.language === "promql" ? "off" : "matchingDocuments",
stickyScroll: {
enabled: props.stickyScroll,
},
});
publishEditorConfig();
// The editor's content only reaches the parent after `debounceTime`. Held
// as a named handle so it can be flushed on the paths that consume the
// query (blur, run) otherwise they act on the previous query and the
@ -773,8 +661,6 @@ export default defineComponent({
};
onMounted(async () => {
provider.value?.dispose();
if (props.language === "sql") {
await import("monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js");
}
@ -806,18 +692,14 @@ export default defineComponent({
setupEditor();
editorObj?.layout();
} else {
provider.value?.dispose();
registerAutoCompleteProvider();
registerLanguageProviders(props.language);
publishEditorConfig();
}
});
onDeactivated(() => {
provider.value?.dispose();
});
onDeactivated(() => {});
onUnmounted(() => {
provider.value?.dispose();
// Clean up global event listeners
if (editorObj) {
if (editorObj._windowClickHandler) {
@ -827,6 +709,11 @@ export default defineComponent({
window.removeEventListener("resize", editorObj._windowResizeHandler);
}
// Drop this editor's entry so the shared provider stops answering for
// a model that no longer exists.
if (publishedKey) editorConfigs.delete(publishedKey);
publishedKey = null;
// Dispose the editor
editorObj.dispose();
editorObj = null;
@ -895,63 +782,160 @@ export default defineComponent({
}
};
const registerAutoCompleteProvider = () => {
if (!props.showAutoComplete || !monaco) return;
provider.value = monaco.languages.registerCompletionItemProvider(props.language, {
provideCompletionItems: function (
/**
* Register the provider set for a language exactly once.
*
* Each provider resolves the asking editor from the model, so three SQL
* editors share one registration instead of stacking three.
*/
const registerLanguageProviders = (language: string) => {
if (!monaco || registeredLanguages.has(language)) return;
registeredLanguages.add(language);
const kinds = () => monaco.languages.CompletionItemKind;
const rules = () => monaco.languages.CompletionItemInsertTextRule;
monaco.languages.registerCompletionItemProvider(language, {
// Without these nothing opens after a paren, a comma or an opening
// quote the positions where help is most wanted.
triggerCharacters: [".", "(", ",", "'", '"', " "],
provideCompletionItems: async (
model: MonacoEditor.editor.ITextModel,
position: MonacoEditor.Position,
) {
// Answer only for THIS editor's model.
//
// Monaco registers completion providers globally per LANGUAGE, not
// per editor, and aggregates the results of every registered
// provider. Each instance of this component registers one, so with
// two SQL editors mounted at once every suggestion was returned
// twice three editors, three times. The duplicates are not a
// keyword-list bug: each provider is correctly returning the full
// list, for an editor that did not ask.
//
// Guarded on `editorObj` because the provider is registered before
// the editor finishes creating; until then answering is correct.
const own = editorObj?.getModel?.();
if (own && model !== own) return { suggestions: [] };
) => {
const config = editorConfigs.get(modelKey(model));
if (!config || !config.enabled()) return { suggestions: [] };
// find out if we are completing a property in the 'dependencies' object.
var textUntilPosition = model.getValueInRange({
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
var word = model.getWordUntilPosition(position);
var range = {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
let arr = textUntilPosition.trim().split(" ");
let filteredSuggestions = [];
filteredSuggestions = createDependencyProposals(range);
filteredSuggestions = filteredSuggestions.filter((item) => {
return item.label.toLowerCase().includes(word.word.toLowerCase());
});
const lastElement = arr.pop();
suggestions.value.forEach((suggestion: any) => {
filteredSuggestions.push({
label: suggestion.label(lastElement),
kind: monaco.languages.CompletionItemKind[suggestion.kind || "Text"],
insertText: suggestion.insertText(lastElement),
range: range,
});
});
// Field VALUES, resolved here rather than by the parent debouncing,
// fetching, pushing a prop down and force-reopening the widget.
const valueContext = parseValueContext(textUntilPosition);
const resolver = config.resolveFieldValues();
if (valueContext && resolver) {
const values = await resolver(valueContext.field);
if (values.length) {
// Monaco auto-closes a typed quote, so the closer is already
// sitting after the cursor invisible to a parser that only sees
// the text before it. Without this the insert produced
// `level = 'error''`.
const closingQuoteAhead =
(model.getLineContent?.(position.lineNumber) ?? "").charAt(position.column - 1) ===
"'";
return {
suggestions: buildCompletionItems({
keywords: buildValueEntries(values, {
hasOpenQuote: valueContext.hasOpenQuote,
closingQuoteAhead,
range,
}) as any[],
suggestions: [],
word: word.word,
range,
kinds: kinds(),
insertTextRules: rules(),
tags: monaco.languages.CompletionItemTag,
}),
incomplete: true,
};
}
}
// Inside avg( or approx_percentile_cont(, lift the numeric columns to
// the top. On a metrics stream every label sorts above `value` the
// one column the function can take which is a correct list and a
// useless one. Applied to both lists so it does not depend on which
// one a given host puts its fields in.
const numericFirst = wantsNumericColumn(parseCallContext(textUntilPosition));
const keywordList = numericFirst
? rankNumericFieldsFirst(config.keywords())
: config.keywords();
const suggestionList = numericFirst
? rankNumericFieldsFirst(config.suggestions())
: config.suggestions();
return {
suggestions: filteredSuggestions,
suggestions: buildCompletionItems({
keywords: keywordList,
suggestions: suggestionList,
word: word.word,
range,
kinds: kinds(),
insertTextRules: rules(),
tags: monaco.languages.CompletionItemTag,
}),
// ALWAYS incomplete, which is not about the content: `severity = `
// turns this same static catalog into a value list, and monaco
// re-filters what it has unless the previous answer said otherwise
// (suggestModel.js). So the values waited for a trigger character,
// and a value fetched from the server after this call could never
// arrive at all. Costs one provider call per keystroke over a local
// catalog and a cached lookup.
incomplete: true,
};
},
});
monaco.languages.registerSignatureHelpProvider(language, {
signatureHelpTriggerCharacters: ["(", ","],
signatureHelpRetriggerCharacters: [","],
provideSignatureHelp: async (
model: MonacoEditor.editor.ITextModel,
position: MonacoEditor.Position,
) => {
const config = editorConfigs.get(modelKey(model));
if (!config || !config.enabled()) return null;
const text = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const call = parseCallContext(text);
if (!call) return null;
// The parser reports any identifier before the paren; the catalog
// decides whether it is a function.
const entry = findFunctionEntry(call.name, config.keywords(), config.suggestions());
const value = buildSignatureHelp(entry as any, call.activeParameter);
if (!value) return null;
// monaco reads `.value` off a SignatureHelpResult and disposes it.
return { value, dispose: () => {} };
},
});
monaco.languages.registerHoverProvider(language, {
provideHover: async (
model: MonacoEditor.editor.ITextModel,
position: MonacoEditor.Position,
) => {
const config = editorConfigs.get(modelKey(model));
if (!config || !config.enabled()) return null;
const word = model.getWordAtPosition(position);
if (!word?.word) return null;
const entry = findCatalogEntry(word.word, config.keywords(), config.suggestions());
const contents = buildHoverContents(entry as any);
if (!contents) return null;
return {
contents,
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
},
};
},
});
@ -975,13 +959,11 @@ export default defineComponent({
};
const disableSuggestionPopup = () => {
const escEvent = new KeyboardEvent("keydown", {
keyCode: 27,
code: "Escape",
key: "Escape",
bubbles: true,
});
editorRef.value.dispatchEvent(escEvent);
// monaco's own command, which this file already uses elsewhere. The
// synthetic Escape this replaced was a guess about monaco's internal key
// handling that nothing verified, and it bubbled out of the editor to
// anything else listening for Escape.
editorObj?.trigger("disableSuggestionPopup", "hideSuggestWidget", {});
};
const formatDocument = async () => {
@ -1109,43 +1091,23 @@ export default defineComponent({
const model = editorObj.getModel();
if (!model) return;
// Deciding WHAT is wrong lives in utils/query/doubleQuoteWarnings.ts,
// where it is comment- and string-aware and can be tested without an
// editor. What is left here is the monaco half: offsets to positions,
// positions to markers.
const text = model.getValue();
const markers: any[] = [];
// Two patterns are flagged both only within value position (after a
// SQL comparison/membership operator). FROM "table" / SELECT "col" are
// intentionally NOT matched.
//
// Pattern A fully double-quoted: field = "value"
// Pattern B mismatched quotes: field = "value' or field = 'value"
// Capture group 1: the invalid quoted token
const regex =
/(?:NOT\s+LIKE|NOT\s+IN\s*\(|!=|<>|>=|<=|=|>|<|LIKE|IN\s*\()\s*("[^'"]*'|'[^'"]*"|"[^"]*")/gi;
let match;
while ((match = regex.exec(text)) !== null) {
const quotedStr = match[1]; // the invalid quoted token
const startOffset = match.index + match[0].length - quotedStr.length;
const endOffset = startOffset + quotedStr.length;
const startPos = model.getPositionAt(startOffset);
const endPos = model.getPositionAt(endOffset);
const isMixed =
(quotedStr.startsWith('"') && quotedStr.endsWith("'")) ||
(quotedStr.startsWith("'") && quotedStr.endsWith('"'));
markers.push({
const markers = findDoubleQuoteIssues(text).map((issue) => {
const startPos = model.getPositionAt(issue.startOffset);
const endPos = model.getPositionAt(issue.endOffset);
return {
severity: monaco.MarkerSeverity.Warning,
startLineNumber: startPos.lineNumber,
startColumn: startPos.column,
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
message: isMixed
? "Mismatched quotes. Use matching single quotes for string values."
: "Double quotes are not valid for string values. Use single quotes instead.",
});
}
message: issue.message,
};
});
monaco.editor.setModelMarkers(model, "dq-validation", markers);
};
@ -1227,6 +1189,18 @@ export default defineComponent({
visibility: visible !important;
}
/* Monaco sizes the suggest documentation panel with
`layout(width, type.clientHeight + docs.clientHeight)` and assigns that height
to THIS element (suggestWidgetDetails.js:161) arithmetic that assumes
content-box. The app's global reset makes everything border-box, so the
panel's own hairline top and bottom borders eat two pixels of the content it
just measured, and the documentation scrolls by that sliver every time.
Restoring content-box for this one node is less fragile than trying to
out-compute the library. */
.logs-query-editor :deep(.suggest-details) {
box-sizing: content-box;
}
/* Error decoration class name is handed to monaco.deltaDecorations(), so the
element only ever exists inside Monaco's view-lines. */
.logs-query-editor :deep(.highlight-error) {

View File

@ -94,6 +94,7 @@
:show-auto-complete="showAutoComplete"
:keywords="keywords"
:suggestions="suggestions"
:field-value-resolver="fieldValueResolver ?? undefined"
:debounce-time="debounceTime"
@update:query="handleQueryUpdate"
@run-query="emit('run-query')"
@ -168,6 +169,7 @@ interface Props {
// Editor autocomplete (forwarded to CodeQueryEditor)
keywords?: any[]; // Autocomplete keywords for Monaco
suggestions?: any[]; // Autocomplete suggestions for Monaco
fieldValueResolver?: ((field: string) => Promise<string[]>) | null; // Field-value lookup awaited by the completion provider
debounceTime?: number; // Debounce time for query updates (ms)
// NL Mode (optional external control)

View File

@ -540,3 +540,122 @@ describe("QueryEditorDialog - ODrawer Migration", () => {
expect(w.findComponent(ODrawerStub).exists()).toBe(true);
});
});
// ─── Phase 1 (N1) / Phase 3 (C4) — autocomplete reaches the editor ───────────
// These two tests used to drive a VALUE context and assert that values arrived
// through `keywords` while `suggestions` went blank. That was the only way to
// tell effectiveKeywords from autoCompleteKeywords, which are identical unless
// a context is active — and it was a fair test until the value round trip was
// removed: the completion PROVIDER now resolves values itself, so nothing
// pushes them through the props any more.
//
// What replaces each half:
// - the N1 invariant (bind the context-aware list, never the base one) is now
// enforced for EVERY editor host in utils/query/editorWiring.spec.ts, which
// needs no context to make the distinction visible;
// - what this file can still prove is its own wiring — that the resolver the
// provider awaits arrives, and resolves against the stream context the
// dialog sets.
describe("QueryEditorDialog - autocomplete wiring reaches the editor", () => {
const editorStubDef = {
name: "UnifiedQueryEditor",
template: '<div class="stub-kw-editor" />',
props: ["query", "keywords", "suggestions", "fieldValueResolver"],
emits: ["update:query", "blur", "focus", "language-change", "ask-ai", "run-query"],
methods: {
// handleQueryUpdate reads these off queryEditorRef.
getCursorIndex() {
return 9999; // past end-of-query => analyse the whole string
},
triggerAutoComplete() {},
},
};
const mountWithStub = () =>
mount(QueryEditorDialog, {
props: {
modelValue: true,
tab: "sql",
sqlQuery: "",
promqlQuery: "",
vrlFunction: "",
streamName: "my-stream",
streamType: "logs",
columns: [
{ label: "host", value: "host" },
{ label: "level", value: "level" },
],
period: 10,
multiTimeRange: [],
savedFunctions: [],
sqlQueryErrorMsg: "",
},
global: {
plugins: [i18n, store],
stubs: {
UnifiedQueryEditor: editorStubDef,
// ODrawer is the dialog root; without a slot-rendering stub none of
// its content (including the editor) mounts.
ODrawer: {
name: "ODrawer",
props: ["open", "size", "showClose", "bleed", "persistent", "title", "width"],
emits: ["update:open"],
template: "<div><slot name='header-left' /><slot name='header-right' /><slot /></div>",
},
FullViewContainer: {
template: "<div><slot /><slot name='right' /></div>",
props: ["name", "label", "isExpanded"],
emits: ["update:isExpanded"],
},
O2AIChat: { template: "<div />", props: ["headerHeight", "isOpen"], emits: ["close"] },
},
},
});
it("renders the unified editor", () => {
const wrapper = mountWithStub();
expect(wrapper.findComponent({ name: "UnifiedQueryEditor" }).exists()).toBe(true);
});
it("hands the editor a resolver that produces the stored values", async () => {
const wrapper = mountWithStub();
const editor = wrapper.findComponent({ name: "UnifiedQueryEditor" });
// Drives handleQueryUpdate, which is where the dialog sets the org/stream
// context the lookup is keyed on. Without that the resolver returns [] and
// this fails — which is the wiring worth guarding.
await editor.vm.$emit("update:query", "level = ");
await flushPromises();
const resolve = editor.props("fieldValueResolver") as (f: string) => Promise<string[]>;
expect(typeof resolve, "no resolver reached the editor").toBe("function");
await expect(resolve("level")).resolves.toEqual(expect.arrayContaining(["error", "warn"]));
});
it("leaves the catalog suggestions alone in a value position", async () => {
// The provider swaps the whole list out for values itself. The parent
// blanking the catalog was part of the removed round trip, and doing it
// here would now take the functions away for no one's benefit.
const wrapper = mountWithStub();
const editor = wrapper.findComponent({ name: "UnifiedQueryEditor" });
await editor.vm.$emit("update:query", "level = ");
await flushPromises();
const suggestions = (editor.props("suggestions") ?? []) as any[];
expect(suggestions.some((s: any) => s.name === "match_all")).toBe(true);
});
});
// Stored field values for the resolver test above. Only useSuggestions
// consumes this module, so mocking it does not affect the rest of the suite.
vi.mock("@/composables/fieldValueStore", () => ({
getFieldValuesForSuggestion: vi.fn().mockResolvedValue(["error", "warn"]),
}));
// getSuggestions now awaits a lazy fetch of the server function catalog. Stub it
// so this suite makes no HTTP call and the awaited chain settles inside
// flushPromises(); the fetch itself is covered in
// useSuggestions.serverCatalog.spec.ts.
vi.mock("@/services/query_functions", () => ({
default: { list: vi.fn().mockResolvedValue({ data: { list: [] } }) },
}));

View File

@ -186,8 +186,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@blur="onBlurQueryEditor"
editor-height="100%"
data-test-prefix="alert"
:keywords="autoCompleteKeywords"
:suggestions="autoCompleteSuggestions"
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
:field-value-resolver="resolveFieldValues"
/>
<div
v-if="
@ -1099,8 +1100,14 @@ const {
autoCompleteData,
autoCompleteKeywords,
autoCompleteSuggestions,
// Context-aware views: these swap in stream names after FROM and field VALUES
// after an operator. Binding the raw lists above meant the value popup showed
// field names exactly where values belong.
effectiveKeywords,
effectiveSuggestions,
getSuggestions,
updateFieldKeywords,
resolveFieldValues,
} = useSqlSuggestions();
// Rebuild field keywords whenever columns prop changes

View File

@ -131,24 +131,37 @@ vi.mock("vuex", async () => {
let mockStoreInstance: any;
// Mock useSuggestions composable
vi.mock("@/composables/useSuggestions", () => ({
default: vi.fn(() => ({
autoCompleteData: {
value: {
query: "",
cursorIndex: 0,
org: "",
streamType: "",
streamName: "",
popup: { open: null },
vi.mock("@/composables/useSuggestions", async () => {
// Real refs, so the template unwraps them exactly as it does in production.
// Plain { value } objects are NOT unwrapped by Vue and would reach the child
// component as the wrapper object itself.
const { ref: vueRef } = await vi.importActual<typeof import("vue")>("vue");
return {
default: vi.fn(() => ({
autoCompleteData: {
value: {
query: "",
cursorIndex: 0,
org: "",
streamType: "",
streamName: "",
popup: { open: null },
},
},
},
autoCompleteIsSuggesting: { value: false },
updateFieldValues: vi.fn(),
updateFieldKeywords: vi.fn(),
getSuggestions: vi.fn().mockResolvedValue([]),
})),
}));
autoCompleteIsSuggesting: { value: false },
// Deliberately DISTINCT arrays: a test can then tell whether the template
// binds the base list or the context-aware view (tmp/code.md N1).
autoCompleteKeywords: vueRef([{ label: "BASE_FIELD", kind: "Field" }]),
autoCompleteSuggestions: vueRef([{ label: "BASE_FN", kind: "Function" }]),
effectiveKeywords: vueRef([{ label: "CONTEXT_VALUE", kind: "Value" }]),
effectiveSuggestions: vueRef([]),
updateFieldValues: vi.fn(),
updateFieldKeywords: vi.fn(),
updateStreamKeywords: vi.fn(),
getSuggestions: vi.fn().mockResolvedValue([]),
})),
};
});
// Mock zincutils
vi.mock("@/utils/zincutils", () => ({
@ -2024,4 +2037,97 @@ describe("QueryConfig.vue", () => {
h.unmount();
});
});
// ─── Phase 1 (tmp/code.md N1) ─────────────────────────────────────────────
// QueryConfig wires the full autocomplete pipeline in handleInlineQueryUpdate
// (query, cursorIndex, org/stream context, popup.open, getSuggestions) but binds
// :keywords="autoCompleteKeywords" — the BASE list — instead of effectiveKeywords.
//
// Asserting "editor.props(keywords) === vm.effectiveKeywords" is NOT enough:
// effectiveKeywords returns autoCompleteKeywords verbatim whenever no context is
// active, so such a test passes with the bug still present. The only honest probe
// is to drive a real VALUE context and require Value-kind items to reach the editor.
describe("QueryConfig — N1 context keywords reach the inline editor", () => {
let host: any;
let editorStub: any;
const inlineEditorStub = {
name: "UnifiedQueryEditor",
template: '<div class="stub-inline-editor" />',
props: ["query", "keywords", "suggestions", "dataTestPrefix"],
emits: ["update:query", "focus", "blur"],
methods: {
// handleInlineQueryUpdate reads these off the ref.
getCursorIndex() {
return 9999; // past end-of-query => analyse the whole string
},
triggerAutoComplete() {},
},
};
beforeEach(async () => {
mockStore = createMockStore();
mockStoreInstance = mockStore;
const props = reactive({ ...baseQCProps(), tab: "sql" });
const Host = defineComponent({
components: { OForm, QueryConfig },
setup: () => ({
schema: addAlertSchema,
defaultValues: hostDefaults({}),
qcProps: props,
}),
template: `
<OForm :schema="schema" :default-values="defaultValues" @submit="() => {}">
<QueryConfig v-bind="qcProps" />
</OForm>
`,
});
host = mount(Host, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
stubs: { UnifiedQueryEditor: inlineEditorStub },
},
});
// Two UnifiedQueryEditors render here (inline SQL and inline VRL); only
// the SQL one carries the autocomplete bindings.
editorStub = host
.findAllComponents({ name: "UnifiedQueryEditor" })
.find((c: any) => c.props("dataTestPrefix") === "alert-inline-sql");
});
afterEach(() => host?.unmount());
it("renders the inline sql editor on the sql tab", () => {
expect(editorStub).toBeDefined();
expect(editorStub.exists()).toBe(true);
});
// NOTE: this spec mocks @/composables/useSuggestions wholesale, so the real
// value-context pipeline cannot run here — QueryEditorDialog.spec.ts covers
// that end to end against the real composable. What IS provable here, and
// what N1 actually is, is WHICH list the template binds. The mock returns
// distinct arrays for the base and context-aware views.
it("binds the context-aware keyword view, not the base list", () => {
const delivered = editorStub.props("keywords") as any[];
expect(delivered).toBeDefined();
expect(delivered.map((k) => k.label)).toEqual(["CONTEXT_VALUE"]);
expect(delivered.map((k) => k.label)).not.toContain("BASE_FIELD");
});
it("binds the context-aware suggestion view, not the base list", () => {
const delivered = editorStub.props("suggestions") as any[];
expect(delivered).toBeDefined();
// effectiveSuggestions is [] in value context; the base list is not.
expect(delivered).toEqual([]);
});
});
});
// Stored field values for the N1 value-context probe above. Only useSuggestions
// consumes this module, so mocking it does not affect the rest of the suite.
vi.mock("@/composables/fieldValueStore", () => ({
getFieldValuesForSuggestion: vi.fn().mockResolvedValue(["error", "warn"]),
}));

View File

@ -1052,8 +1052,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query="localTab === 'sql' ? localSqlQuery : localPromqlQuery"
editor-height="100%"
:disable-ai="!streamName"
:keywords="autoCompleteKeywords"
:suggestions="autoCompleteSuggestions"
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
:field-value-resolver="resolveFieldValues"
@focus="onQueryEditorFocus"
@blur="onBlurInlineSqlEditor"
@update:query="handleInlineQueryUpdate"
@ -1886,8 +1887,13 @@ export default defineComponent({
autoCompleteData,
autoCompleteKeywords,
autoCompleteSuggestions,
// Context-aware views see QueryEditorDialog for why the raw lists are
// not what the editor should receive.
effectiveKeywords,
effectiveSuggestions,
getSuggestions,
updateFieldKeywords,
resolveFieldValues,
} = useSqlSuggestions();
// Rebuild field keywords whenever columns prop changes
@ -3568,6 +3574,9 @@ export default defineComponent({
inlineQueryEditorRef,
autoCompleteKeywords,
autoCompleteSuggestions,
effectiveKeywords,
resolveFieldValues,
effectiveSuggestions,
handleInlineQueryUpdate,
inlineEditorPlaceholder,
promqlSamples,

View File

@ -39,6 +39,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
import streamService from "@/services/stream";
// vi.mock must be hoisted — declared before component import
vi.mock("@/services/stream", () => ({
@ -47,6 +48,15 @@ vi.mock("@/services/stream", () => ({
},
}));
// The stored-value lookup the field-value resolver ends at. Stubbed so the
// resolver tests can assert the composite key it was asked for without an
// IndexedDB in jsdom. useSuggestions imports nothing else from this module.
// vi.hoisted, because vi.mock is lifted above ordinary declarations.
const { getFieldValuesForSuggestion } = vi.hoisted(() => ({
getFieldValuesForSuggestion: vi.fn(async () => ["ERROR", "INFO"]),
}));
vi.mock("@/composables/fieldValueStore", () => ({ getFieldValuesForSuggestion }));
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({
default: { template: '<div data-test="panel-schema-renderer" />' },
}));
@ -896,4 +906,47 @@ describe("AnomalyDetectionConfig", () => {
expect(ok).toBe(true);
});
});
// =========================================================================
// Editor autocomplete wiring. Both halves shipped broken: loadStreamFields
// cleared the field keywords on failure but never SET them on success, and
// the stream context the value resolver keys on was never populated at all.
// Neither was visible from the outside — the editor still opened, just with
// nothing stream-specific in it.
// =========================================================================
describe("SQL editor autocomplete", () => {
it("feeds the selected stream's fields to the editor", async () => {
(streamService.schema as any).mockResolvedValueOnce({
data: {
schema: [
{ name: "level", type: "Utf8" },
{ name: "code", type: "Int64" },
],
},
});
wrapper = mountConfig();
await flushPromises();
const labels = ((wrapper.vm as any).effectiveKeywords ?? []).map((k: any) => k.label);
expect(labels).toContain("level");
expect(labels).toContain("code");
});
it("resolves field values under the selected stream's key", async () => {
wrapper = mountConfig({ stream_name: "my_stream", stream_type: "logs" });
await flushPromises();
const values = await (wrapper.vm as any).resolveFieldValues("level");
expect(getFieldValuesForSuggestion).toHaveBeenCalledWith(
{
org: store.state.selectedOrganization.identifier,
streamType: "logs",
streamName: "my_stream",
},
"level",
);
expect(values).toEqual(["ERROR", "INFO"]);
});
});
});

View File

@ -107,7 +107,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<QueryEditor
data-test-prefix="anomaly-custom-sql"
:query="customSql || ''"
:keywords="allStreamFields"
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
:field-value-resolver="resolveFieldValues"
:show-auto-complete="true"
:disable-ai="!config.stream_name"
:disable-ai-reason="
@ -579,6 +581,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import useSqlSuggestions from "@/composables/useSuggestions";
import { computed, defineComponent, ref, watch, type PropType } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
@ -782,6 +785,16 @@ export default defineComponent({
// Stream fields for filter field selector and detection function field
const allStreamFields = ref<string[]>([]);
// Same completion machinery every other SQL editor in the app uses, so this
// one also gets SQL keywords, the O2 functions and the server function
// catalog rather than bare field names.
const {
autoCompleteData,
effectiveKeywords,
effectiveSuggestions,
updateFieldKeywords,
resolveFieldValues,
} = useSqlSuggestions();
const numericStreamFields = ref<string[]>([]); // only numeric types for avg/sum/min/max/pXX
const filteredStreamFields = ref<string[]>([]);
const filteredDetectionFields = ref<string[]>([]);
@ -807,8 +820,16 @@ export default defineComponent({
const loadStreamFields = async () => {
const streamName = props.config.stream_name;
const streamType = props.config.stream_type;
// Field VALUES are looked up under "org|streamType|streamName|field", so
// the resolver returns nothing at all until this is set.
autoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? "";
autoCompleteData.value.streamType = String(streamType ?? "");
autoCompleteData.value.streamName = String(streamName ?? "");
if (!streamName || !streamType) {
allStreamFields.value = [];
updateFieldKeywords([]);
numericStreamFields.value = [];
filteredStreamFields.value = [];
filteredDetectionFields.value = [];
@ -827,6 +848,10 @@ export default defineComponent({
? schema.uds_schema
: schema.schema || schema.fields || [];
allStreamFields.value = fieldsArray.map((f: any) => f.name).sort();
// The two failure branches below already cleared the keywords; without
// this the success branch never set them, so the SQL editor offered
// functions and keywords but not one field of the selected stream.
updateFieldKeywords(fieldsArray);
numericStreamFields.value = fieldsArray
.filter((f: any) => {
const t: string = f.field_type || f.data_type || f.type || "";
@ -840,6 +865,7 @@ export default defineComponent({
: allStreamFields.value;
} catch {
allStreamFields.value = [];
updateFieldKeywords([]);
numericStreamFields.value = [];
filteredStreamFields.value = [];
filteredDetectionFields.value = [];
@ -1199,6 +1225,9 @@ export default defineComponent({
intervalUnits,
retrainIntervalOptions,
allStreamFields,
effectiveKeywords,
resolveFieldValues,
effectiveSuggestions,
filteredStreamFields,
filteredDetectionFields,
loadingFields,

View File

@ -41,7 +41,7 @@ vi.mock("./SelectFolderDropDown.vue", () => ({
name: "SelectFolderDropDown",
template:
"<div class='select-folder-dropdown' @folder-selected=\"$emit('folder-selected', $event)\"></div>",
props: ["type", "activeFolderId"],
props: ["type", "activeFolderId", "excludeFolderId"],
emits: ["folder-selected"],
},
}));
@ -513,14 +513,28 @@ describe("MoveAcrossFolders.vue", () => {
});
// Test 29: Initial selected folder setup
it("should initialize selected folder correctly from store", () => {
// The destination starts empty, NOT on the active folder. Seeding it there put
// the same folder name in both fields and read as "move this to where it
// already is"; the picker now excludes the active folder outright, so a seeded
// value would also name something the list cannot offer.
it("should initialize the destination empty rather than on the active folder", () => {
wrapper = createWrapper({
activeFolderId: "folder1",
type: "alerts",
});
expect(wrapper.vm.selectedFolder.label).toBe("Test Folder 1");
expect(wrapper.vm.selectedFolder.value).toBe("folder1");
expect(wrapper.vm.selectedFolder.label).toBe("");
expect(wrapper.vm.selectedFolder.value).toBe("");
});
it("should exclude the active folder from the destination picker", () => {
wrapper = createWrapper({
activeFolderId: "folder1",
type: "alerts",
});
const dropdown = wrapper.findComponent({ name: "SelectFolderDropDown" });
expect(dropdown.props("excludeFolderId")).toBe("folder1");
});
// Test 30: Form submission with empty moduleId
@ -568,11 +582,11 @@ describe("MoveAcrossFolders.vue", () => {
});
// Test 34: Component exposes selectedFolder as a ref
it("should expose selectedFolder with correct initial value", () => {
it("should expose selectedFolder, unset until the author picks one", () => {
wrapper = createWrapper({ activeFolderId: "folder1", type: "alerts" });
expect(wrapper.vm.selectedFolder).toBeDefined();
expect(wrapper.vm.selectedFolder.value).toBe("folder1");
expect(wrapper.vm.selectedFolder.label).toBe("Test Folder 1");
expect(wrapper.vm.selectedFolder.value).toBe("");
expect(wrapper.vm.selectedFolder.label).toBe("");
});
// Test 35: onSubmit function existence
@ -645,7 +659,11 @@ describe("MoveAcrossFolders.vue", () => {
});
// Test 40: primaryButtonDisabled with null selectedFolder value
it("should compute primaryButtonDisabled=false when selectedFolder value is null", async () => {
// Inverted deliberately. This asserted that a null destination ENABLES Move —
// which submits `dst_folder_id: null`. "Not the active folder" is not the same
// as "somewhere to move to", and the empty destination the dialog now opens in
// makes that state reachable on every open rather than only via a stray emit.
it("should keep Move disabled when the destination is empty or null", async () => {
mockUseLoading.mockImplementation((fn: any) => ({
execute: vi.fn().mockImplementation(async () => fn && (await fn())),
isLoading: { value: false },
@ -656,13 +674,15 @@ describe("MoveAcrossFolders.vue", () => {
type: "alerts",
});
// selectedFolder.value mutated to a non-matching object — drive via the public dropdown event
const drawer = wrapper.findComponent(ODialogStub);
// Unset on open — nothing has been chosen yet.
expect(drawer.props("primaryButtonDisabled")).toBe(true);
const selectDropdown = wrapper.findComponent({ name: "SelectFolderDropDown" });
await selectDropdown.vm.$emit("folder-selected", { value: null, label: "None" });
await nextTick();
const drawer = wrapper.findComponent(ODialogStub);
expect(drawer.props("primaryButtonDisabled")).toBe(false);
expect(drawer.props("primaryButtonDisabled")).toBe(true);
});
// Test 41: Handles API error gracefully when move fails

View File

@ -23,7 +23,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:secondary-button-label="t('dashboard.cancel')"
:primary-button-label="t('common.move')"
:primary-button-loading="onSubmit.isLoading.value"
:primary-button-disabled="activeFolderId === selectedFolder.value"
:primary-button-disabled="!selectedFolder.value || activeFolderId === selectedFolder.value"
@update:open="emit('update:open', $event)"
@click:secondary="emit('update:open', false)"
@click:primary="onSubmit.execute()"
@ -41,11 +41,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<span>&nbsp;</span>
<!-- select folder or create new folder and select -->
<!-- select folder or create new folder and select.
`excludeFolderId` is what stops the destination opening on the folder
named directly above it as the CURRENT one: the same folder appeared
twice, reading as "move this to where it already is". Submit was
disabled in that state, so nothing could go wrong the dialog simply
gave no clue why. -->
<SelectFolderDropDown
:type="type"
@folder-selected="selectedFolder = $event"
:activeFolderId="activeFolderId"
:excludeFolderId="activeFolderId"
/>
</div>
</ODialog>
@ -91,14 +98,10 @@ export default defineComponent({
emits: ["updated", "close", "update:open"],
setup(props, { emit }) {
const store: any = useStore();
//dropdown selected folder
const selectedFolder = ref({
label:
store.state.organizationData.foldersByType?.[props.type]?.find(
(item: any) => item.folderId === props.activeFolderId,
)?.name ?? "",
value: props.activeFolderId,
});
// Dropdown selected folder deliberately empty, not the active folder.
// The picker excludes the active folder, so seeding it here would name a
// destination the list cannot offer and cannot be re-picked.
const selectedFolder = ref({ label: "", value: "" });
const { t } = useI18n();
const { showPositiveNotification, showErrorNotification } = useNotifications();

View File

@ -233,6 +233,52 @@ describe("SelectFolderDropDown.vue", () => {
});
});
// Opt-in, and only a destination picker opts in. A move dialog naming the
// active folder as the destination showed the same folder name twice and read
// as "move this to where it already is".
describe("excludeFolderId", () => {
it("is unset by default, and then offers every folder", () => {
wrapper = createWrapper();
expect(wrapper.props("excludeFolderId")).toBeUndefined();
const values = (wrapper.vm as any).folderOptions.map((o: any) => o.value);
expect(values).toEqual(["default", "folder-1", "folder-2"]);
});
it("drops the excluded folder from the options", () => {
wrapper = createWrapper({ excludeFolderId: "folder-1" });
const values = (wrapper.vm as any).folderOptions.map((o: any) => o.value);
expect(values).toEqual(["default", "folder-2"]);
});
// The whole point: it must not open pointing at a folder the list refuses to
// show, so there is nothing to select until the author chooses.
it("opens with no selection when it would have seeded the excluded folder", () => {
wrapper = createWrapper({ activeFolderId: "folder-1", excludeFolderId: "folder-1" });
expect((wrapper.vm as any).selectedFolder).toBe("");
});
it("still seeds normally when the active folder is not the excluded one", () => {
wrapper = createWrapper({ activeFolderId: "folder-2", excludeFolderId: "folder-1" });
expect((wrapper.vm as any).selectedFolder).toBe("folder-2");
});
// Creating a folder from the + button reaches this component as a store list
// change. The re-seed on that watcher would otherwise clear the folder that
// was just created and selected.
it("keeps a still-offerable choice when the folder list changes", async () => {
wrapper = createWrapper({ activeFolderId: "folder-1", excludeFolderId: "folder-1" });
const vm = wrapper.vm as any;
vm.selectedFolder = "folder-2";
await nextTick();
setStoreFolders("alerts", [...MOCK_FOLDERS, { folderId: "folder-3", name: "Staging" }]);
await nextTick();
expect(vm.selectedFolder).toBe("folder-2");
});
});
// ─── updateFolderList ────────────────────────────────────────────────────────
describe("updateFolderList method", () => {

View File

@ -20,12 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OSelect
v-model="selectedFolder"
:label="t('dashboard.selectFolderLabel')"
:options="
store.state.organizationData.foldersByType[type]?.map((item: any) => ({
label: item.name,
value: item.folderId,
})) ?? []
"
:options="folderOptions"
:placeholder="excludeFolderId ? t('dashboard.selectFolderPlaceholder') : undefined"
:data-test="`${type}-index-dropdown-stream_type`"
labelKey="label"
valueKey="value"
@ -86,6 +82,21 @@ export default defineComponent({
return typeof value === "string" || value === null;
},
},
/**
* A folder this picker must not offer set by callers choosing a
* DESTINATION, where the folder the module already sits in is not a
* destination at all.
*
* Opt-in on purpose. Most callers here pick a folder to file something into
* and are right to open on the active one; only a move is choosing somewhere
* else. Leaving it unset keeps every one of those call sites exactly as it
* was the filter and the blank initial selection both key off this prop.
*/
excludeFolderId: {
type: String,
required: false,
default: undefined,
},
type: {
type: String,
default: "alerts",
@ -108,14 +119,25 @@ export default defineComponent({
const route = useRoute();
const showAddFolderDialog: any = ref(false);
const folderOptions = computed(() =>
(store.state.organizationData.foldersByType[props.type] ?? [])
// `!== undefined` is every folder, so an unset prop filters nothing.
.filter((item: any) => item.folderId !== props.excludeFolderId)
.map((item: any) => ({ label: item.name, value: item.folderId })),
);
const getInitialFolderId = () => {
// priority: activeFolderId > query.folder > default
return (
const resolved =
store.state.organizationData.foldersByType[props.type]?.find(
(item: any) =>
item.folderId === (props.activeFolderId ?? route.query.folder ?? "default"),
)?.folderId ?? "default"
);
)?.folderId ?? "default";
// Opening already pointed at the excluded folder would show its name as the
// choice while the list cannot offer it the state that made a move dialog
// read as "move this to where it already is". Start blank and make the
// caller's disabled-submit guard do the rest.
return resolved === props.excludeFolderId ? "" : resolved;
};
//dropdown selected folder index (holds primitive folderId string)
@ -140,6 +162,15 @@ export default defineComponent({
watch(
() => store.state.organizationData.foldersByType[props.type],
() => {
// A destination picker keeps a choice that is still offerable. Creating a
// folder from the + button lands here as a list change, and the re-seed
// below would clear the very folder that was just created and selected.
if (
props.excludeFolderId &&
folderOptions.value.some((option: any) => option.value === selectedFolder.value)
) {
return;
}
// refresh selected folder, on folders list change
selectedFolder.value = getInitialFolderId();
},
@ -162,6 +193,7 @@ export default defineComponent({
t,
store,
selectedFolder,
folderOptions,
updateFolderList,
showAddFolderDialog,
computedStyle,

View File

@ -15,6 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import { reactive } from "vue";
// Mock the zincutils utilities completely
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual = (await importOriginal()) as any;
@ -67,6 +68,7 @@ vi.mock("@/components/CodeQueryEditor.vue", () => ({
}));
import DashboardQueryEditor from "@/components/dashboards/addPanel/DashboardQueryEditor.vue";
import useSqlSuggestions from "@/composables/useSuggestions";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
@ -114,7 +116,11 @@ const createMockDashboardPanelData = () => {
};
return {
dashboardPanelData: mockData,
// reactive() because the real composable's state is: the component watches
// the active query's stream, and a plain object would never fire the
// watcher — the test would pass or fail for reasons unrelated to the
// component.
dashboardPanelData: reactive(mockData),
promqlMode: false, // Make this a direct boolean instead of ref
addQuery: vi.fn(() => {
mockData.data.queries.push({
@ -155,6 +161,20 @@ vi.mock("@/composables/usePromqlSuggestions", () => ({
vi.mock("@/composables/useSuggestions", () => ({
default: vi.fn(() => ({
// Mirrors the real composable's shape. autoCompleteData carries the stream
// context the field-value resolver looks values up under; omitting it here
// made every mount throw once the component started setting it.
autoCompleteData: {
value: {
org: "",
streamType: "",
streamName: "",
query: "",
cursorIndex: 0,
popup: { open: vi.fn(), close: vi.fn() },
},
},
resolveFieldValues: vi.fn(async () => []),
autoCompleteKeywords: { value: [] },
autoCompleteSuggestions: { value: [] },
effectiveKeywords: { value: [] },
@ -410,6 +430,52 @@ describe("DashboardQueryEditor", () => {
expect(wrapper.vm.dashboardPanelData.layout.currentQueryIndex).toBe(1);
});
// The field-value resolver looks values up under "org|streamType|
// streamName|field". This panel never set any of the three, so its
// resolver could only ever return [] — a working editor with value
// completion silently absent. Per QUERY, not per panel: each tab has its
// own stream and a stale context would offer the previous tab's values.
const sqlAutoCompleteData = () =>
(useSqlSuggestions as any).mock.results.at(-1).value.autoCompleteData.value;
it("publishes the active query's stream as the field-value lookup context", async () => {
wrapper = createWrapper();
wrapper.vm.dashboardPanelData.data.queries[0].fields = {
stream: "app_logs",
stream_type: "logs",
};
await wrapper.vm.$nextTick();
expect(sqlAutoCompleteData()).toMatchObject({
org: "test-org",
streamType: "logs",
streamName: "app_logs",
});
});
it("follows the stream when the user switches query tabs", async () => {
wrapper = createWrapper();
wrapper.vm.dashboardPanelData.data.queries[0].fields = {
stream: "app_logs",
stream_type: "logs",
};
wrapper.vm.dashboardPanelData.data.queries.push({
query: "",
queryType: "sql",
customQuery: true,
fields: { stream: "app_metrics", stream_type: "metrics" },
});
await wrapper.vm.$nextTick();
wrapper.vm.dashboardPanelData.layout.currentQueryIndex = 1;
await wrapper.vm.$nextTick();
expect(sqlAutoCompleteData()).toMatchObject({
streamType: "metrics",
streamName: "app_metrics",
});
});
it("should handle query editor configuration", () => {
wrapper = createWrapper();

View File

@ -184,6 +184,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
:keywords="currentEditorKeywords"
:suggestions="currentEditorSuggestions"
:field-value-resolver="resolveFieldValues"
@update:query="handleQueryUpdate"
@focus="_sqlOnFocus"
@blur="_sqlOnBlur"
@ -364,11 +365,6 @@ export default defineComponent({
}
store.state.organizationData.functions.map((data: any) => {
const args: any = [];
for (let i = 0; i < parseInt(data.num_args); i++) {
args.push("'${1:value}'");
}
functionList.value.push({
name: data.name,
function: data.function,
@ -448,6 +444,7 @@ export default defineComponent({
getSuggestions: sqlGetSuggestions,
updateAllKeywords: sqlUpdateAllKeywords,
updateStreamKeywords: sqlUpdateStreamKeywords,
resolveFieldValues,
} = useSqlSuggestions();
const queryEditorRef = ref<QueryEditorInstance | null>(null);
@ -649,6 +646,27 @@ export default defineComponent({
{ immediate: true },
);
// Field VALUES are looked up under "org|streamType|streamName|field", so the
// resolver returns nothing at all until this is set. Tracked per QUERY, not
// per panel: each tab has its own stream, and switching tabs must not leave
// the previous tab's values on offer. A multi-stream query is keyed on its
// primary stream the same one the Fields panel is built from.
watch(
[
() => dashboardPanelData.layout.currentQueryIndex,
() =>
dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields,
],
() => {
const fields =
dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields;
sqlAutoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? "";
sqlAutoCompleteData.value.streamType = String(fields?.stream_type ?? "");
sqlAutoCompleteData.value.streamName = String(fields?.stream ?? "");
},
{ immediate: true, deep: true },
);
const removeTab = async (rawIndex: string | number) => {
const index = Number(rawIndex);
if (dashboardPanelData.layout.currentQueryIndex >= dashboardPanelData.data.queries.length - 1)
@ -937,6 +955,7 @@ export default defineComponent({
saveQueryName,
cancelQueryNameEdit,
currentEditorKeywords,
resolveFieldValues,
currentEditorSuggestions,
_sqlOnFocus,
_sqlOnBlur,

View File

@ -221,6 +221,24 @@ describe("TableRenderer", () => {
expect(table.props("wrap")).toBe(false);
});
// Regression: a flat 150px per column trimmed timestamps and wasted space.
it("should mark non-pivot columns autoWidth so they carry no fixed width", () => {
wrapper = createWrapper();
const columns = wrapper.findComponent({ name: "OTable" }).props("columns") as any[];
expect(columns.length).toBeGreaterThan(0);
expect(columns.every((c) => c.meta?.autoWidth === true)).toBe(true);
expect(columns.every((c) => c.size === undefined)).toBe(true);
});
it("should leave pivot columns alone, since pivot fixes its own widths", () => {
wrapper = createWrapper({
data: { ...mockTableData, pivotHeaderLevels: [{ cells: [{ label: "a", colspan: 1 }] }] },
});
const columns = wrapper.findComponent({ name: "OTable" }).props("columns") as any[];
expect(columns.length).toBeGreaterThan(0);
expect(columns.some((c) => c.meta?.autoWidth === true)).toBe(false);
});
it("should pass showPagination=true to TenstackTable", () => {
wrapper = createWrapper({ showPagination: true });
const table = wrapper.findComponent({ name: "OTable" });

Some files were not shown because too many files have changed in this diff Show More