ES|QL|DS: A declaration for external-datasource behaviour, demonstrated on dataset registration #159749

Open
quackaplop opened 5:24pm on September 18, 2026 wants to merge 3040 Ξ” into elastic/elasticsearch main from
ds-crud-combinatorics
Reviewing
2902 fewer changed lines
(29% less) vs GitHub
This saves about 50.8 hours per year vs conventional diff tools
quackaplop authored
of work between September 17 and September 18
Diff Delta:
3040
About 321 Diff Delta/hour
Classified as:  Feature Add, General

quackaplop's Description of Work

Introduces a declaration-driven framework for testing external-datasource
behaviour, and demonstrates it on the dataset registration round trip.

Reference: [x-pack/plugin/esql/qa/fixture-common/README.md](x-pack/plugin/esql/qa/fixture-common/README.md).

Why

The round trip is: register a dataset with some settings, then query it. Three
things can happen, and a suite has to tell them apart.


  1. The registration is refused β€” the setting is wrong in a way the endpoint
    can see without opening anything.

  2. The registration is accepted and the query fails β€” registration performs
    no I/O, so a setting that disagrees with the bytes cannot be detected until
    something opens the object.

  3. The registration is accepted and the query succeeds β€” the setting took
    effect, or was silently ignored, which looks identical unless the rows are
    checked.

The existing suite expresses one of these. A 200 means pass, so a setting that
was accepted and ignored is indistinguishable from one that worked, and outcome
2 has nowhere to live at all β€” its own javadoc says to mute such a case.

And a status is not an assertion. The first run of the new suite had all seven
cases returning 400 while asserting nothing: the resource was written as a
filesystem path, so every registration failed on the URI scheme and on a
consequent unknown-setting error, and no case was exercising the bound it
named. Only an assertion on the message showed it. That is now the rule β€” every
case names the substring the failure must contain, and the symbol that emits it,
so the next reader can re-check the message against the code rather than trust
that someone did.

The framework

Cases are declared, not written. This is a complete one:

case.dataset.setting.skip_rows.negative.settings.skip_rows = -1

case.dataset.setting.skip_rows.negative.message = [skip_rows] must be between 0 and 1000, got [-1]
case.dataset.setting.skip_rows.negative.emitter = DataSourceValidationUtils.validateInt

Names are paths β€” dataset.setting.*, dataset.combination.*,
dataset.format.*, dataset.resource.* β€” so the corpus is read by subject and
grows without the parser changing. Datasource cases arrive as datasource.*.

A case declares which outcome it expects, and both halves are assertions. For
an accepted-then-failing case the PUT succeeding is not setup: if it ever starts
refusing, registration has acquired I/O and the guarantee is gone β€” silently,
because the query would never run.

The parser refuses anything that would look present and assert nothing: a case
with no settings, no message, an unknown attribute, a query on a refusal, a
blocked case with no filed issue, an empty declaration. Each refusal names why.
The full attribute table and that list are in the README.

What it found and what it pins

Combinations are where the interesting refusals are. Four rules are refused
only as combinations β€” each setting is accepted on its own:


  • an error budget with no error_mode


  • split_probe_window and max_split_probes whose product exceeds the probe
    budget, neither value being out of range


  • partition_detection=template with no partition_path

  • file ordering under a schema resolution where read order cannot change the
    answer

None is reachable by varying settings independently. All four were found by
reading for validators that take the whole settings map, which is the cheap way
to locate them β€” a combination can only be refused where something reads more
than one setting.

The deferred-validation outcome now has a case. An explicit format of
parquet over a file holding CSV bytes registers cleanly and fails at query
with the reader naming the file, the format it was told to expect, and the bytes
it found instead.

One case is blocked on a filed defect. A resource written as a filesystem
path is refused correctly, and a second error reports schema_sample_size β€” a
valid CSV setting β€” as unknown, because the failed resource left the format
unresolved and the accepted vocabulary collapsed. A user reading that deletes
the setting, fixes the path, and registers a dataset silently missing what they
asked for. Filed as elastic/esql-planning#1999; the case asserts the second
error is absent, is skipped until the fix, and names the issue in the
declaration rather than in a mute list somewhere else. The citation is enforced:
a blocked case with no elastic/<repo>#<n> fails the parse.

The shared declaration

fixture-common also carries the dimension declaration the read-path suites
will read, brought here so it stands alone and green ahead of them. Three
changes made it serve registration as well as reads: a data_source bind so
provider settings have somewhere to live, a rejected outcome on a value served
only by a new REGISTRATION seam, and the removal of five classes that write
fixture bytes β€” nothing in the engine called them and two duplicated classes
already in the esql-datasource-csv plugin.

Its key routing was an if/else that sent any unrecognised bind to the dataset
map, which is the misroute the comment beside it warns about; it is now a
per-bind map that refuses a bind carrying no key.

Coverage of the declaration engine went from 79.5% line / 74.9% branch to 94.9%
/ 90.8%, almost entirely by testing refusals β€” a parser that accepts a bad
declaration does not fail, it generates a crossing with a cell missing from it,
and nothing reports a case that was never generated.

Scope

This covers the create half of the round trip, and mostly its refusal path:
7 of the 18 decidable checks in validateDataset, one deferred-validation case,
and no case yet for a setting accepted and silently ignored β€” which needs
fixtures whose bytes parse differently per value. Read-back, update, delete and
datasource settings are not covered; the data_source bind exists but nothing
uses it yet.

Verification

184 unit tests needing no cluster; 30 registration cases against a single-node
cluster, 14 executing and the rest filtered by outcome. The blocked case was run
with its block removed to confirm it fails for the stated reason rather than
sitting green.

29 total changed files
(13 files ignored)
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
You've reached the end of this PR review
You have reached the final β€œwhy is this here?”