[Feature] Add PPL outputlookup command (synchronous terminal write sink) - #5621
[Feature] Add PPL outputlookup command (synchronous terminal write sink)#5621noCharger wants to merge 19 commits into
Conversation
PR Reviewer Guide 🔍(Review updated until commit f9d9260)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to f9d9260 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 9d9a533
Suggestions up to commit 9c2adda
Suggestions up to commit 306bf6e
Suggestions up to commit 5dc4c9a
Suggestions up to commit b84bad6
|
Adds the PPL outputlookup command: a synchronous terminal sink that materializes pipeline rows into a lookup index and returns a single rows_written count. Owned write path, independent of collect. Parse layer - Grammar tokens OUTPUTLOOKUP, OVERRIDE_IF_EMPTY, KEY_FIELD plus the outputlookupCommand rule; key_field accepts a comma-separated field list; kept usable as an identifier. - OutputLookup AST node and AstBuilder (key_field defaults append to true). - Analyzer rejects it on the V2 path (Calcite only). Terminal sink - OutputLookupTableModify extends Calcite TableModify (INSERT): the optimizer treats it as a mandatory table-modifying side effect and it exposes the standard rowcount row type. A dedicated rule lowers it to the physical EnumerableOutputLookup, wiring in the in-cluster node client. - OutputLookupWriteExec: schema inference (reserved metadata fields excluded), overwrite via a fresh backing index plus atomic alias swap, append to the current backing, override_if_empty empty guard, and a max row cap. The destination is created on demand. - Full-result write: the input is eagerly drained and the source scan pages via PIT, so a source larger than the result window is written in full. Write core - OpenSearchBulkWriter: batched bulk with 429 backoff retry; non-429 and retry-exhausted failures throw rather than being swallowed. APPEND uses an auto id, UPSERT uses a deterministic id from key_field. - LookupIdEncoder: id is base64url(SHA-256(length-prefixed canonical key)), a bounded 43-char string; multi-field keys cannot collide across boundaries, empty differs from null, and multivalue keys are rejected. Tests - Unit: parse (6), writer (5), id encoder (5), schema inference (1). - Integration: CalcitePPLOutputLookupIT (9) covering rowcount return, alias-swap overwrite, append, override_if_empty both ways, single- and multi-field key_field upsert, max, multivalue-as-array, and large-source no-truncation. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
db5cd41 to
9476e41
Compare
PR Code Analyzer ❗AI-powered 'Code-Diff-Analyzer' found issues on commit 306bf6e.
The table above displays the top 10 most important findings. Pull Requests Author(s): Please update your Pull Request according to the report above. Repository Maintainer(s): You can Thanks. |
|
Persistent review updated to latest commit 9476e41 |
…orphan cleanup, authz) - Reject a key_field that is not a result field at plan time, so a misspelled or absent key can no longer collapse every row onto one _id. - Refuse when the destination name is already a concrete index (covers dest == source) instead of failing later on the alias swap. - On a failed overwrite, delete the freshly created backing so no orphan is left; document last-writer-wins concurrency and the crash/concurrent orphan reaper as a follow-up. - Document that writes run under the caller security context and the required destination permissions; add OutputLookupPermissionsIT proving a read-only user is denied. Tests: CalcitePPLOutputLookupIT grows to 12 (adds missing-key_field, concrete-index-dest, and failed-overwrite-no-orphan); OutputLookupPermissionsIT added under integTestWithSecurity. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit 92cfd5f |
…kup-clean Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com> # Conflicts: # ppl/src/main/antlr/OpenSearchPPLParser.g4 # ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java
Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit 4c9fb21 |
…kup-clean Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com> # Conflicts: # ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java # ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java
|
Persistent review updated to latest commit d39455c |
Benchmark: performance and resilience3-node Performance (current build)Measured on the current build: per-batch
Resilience (chaos)
|
Reword the plugins.ppl.outputlookup.max_rows ceiling error to say nothing was written and give the two next steps (add max=<n>, or raise the dynamic setting). Document the override path (raise the setting or use a bulk indexing path for large data) in the outputlookup Limitations. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
| case ABSENT: | ||
| { | ||
| String uuid = newUuid(); | ||
| writeSlice(client, LookupsIndex.INDEX_NAME, fields, mode, keyFields, rows, uuid); | ||
| addFilteredAlias(client, name, LookupsIndex.INDEX_NAME, uuid); | ||
| break; |
There was a problem hiding this comment.
The class has no shared mutable state, but the lookup lifecycle is not concurrency-safe. In particular, two concurrent appends to an absent lookup can both resolve ABSENT, write separate UUID slices, and race to install the alias. Both requests may return success, while only the last alias target remains visible. Concurrent append and overwrite has a similar lost-visibility problem. What concurrency contract do we want for same-name writes?
For example, similar concurrent queries like ... | outputlookup append=true my_lookup generate the following sequence, resulting orphan slice in the backing index:
| Timestamp | Request A | Request B | alias status |
|---|---|---|---|
| T1 | resolve → ABSENT | non-existent | |
| T2 | resolve → ABSENT | non-existent | |
| T3 | write uuid-A | non-existent | |
| T4 | write uuid-B | non-existent | |
| T5 | add alias → uuid-A | my_lookup → uuid-A | |
| T6 | return success | add alias → uuid-B | my_lookup → uuid-B |
| T7 | return success | my_lookup → uuid-B |
There was a problem hiding this comment.
The class has no shared mutable state, but the lookup lifecycle is not concurrency-safe. In particular, two concurrent appends to an absent lookup can both resolve ABSENT, write separate UUID slices, and race to install the alias. Both requests may return success, while only the last alias target remains visible. Concurrent append and overwrite has a similar lost-visibility problem. What concurrency contract do we want for same-name writes?
For example, similar concurrent queries like
... | outputlookup append=true my_lookupgenerate the following sequence, resulting orphan slice in the backing index:Timestamp Request A Request B alias status
T1 resolve → ABSENT non-existent
T2 resolve → ABSENT non-existent
T3 write uuid-A non-existent
T4 write uuid-B non-existent
T5 add alias → uuid-A my_lookup → uuid-A
T6 return success add alias → uuid-B my_lookup → uuid-B
T7 return success my_lookup → uuid-B
Contract defined as append-to-absent useing a deterministic per-lookup discriminant so concurrent first-appends converge into one slice (no lost write); overwrite is last-writer-wins on the atomic repoint. Added a concurrent IT.
There was a problem hiding this comment.
Contract for concurrent same-name writes:
- Concurrent first-time appends to an absent lookup converge on one slice via a deterministic per-lookup discriminant, so every appended row persists and the alias resolves to that slice. Covered by
testConcurrentAppendToAbsentDoesNotLoseWrites. - A concurrent overwrite is last-writer-wins on the atomic repoint. An append that races an overwrite may write into the slice the overwrite orphans, so those appended rows are acknowledged while staying outside the published lookup. Concrete interleaving on an existing lookup
hosts(alias -> U0):
| T | A: outputlookup append=true hosts | B: outputlookup hosts (overwrite) | alias |
|---|---|---|---|
| T1 | resolve -> ALIAS (U0, primary hosts__lookup) | hosts -> U0 | |
| T2 | resolve -> ALIAS (U0) | hosts -> U0 | |
| T3 | writeSlice(hosts__lookup, U1) | hosts -> U0 | |
| T4 | repoint -> U1 | hosts -> U1 (U0 orphaned) | |
| T5 | writeSlice(hosts__lookup, U0) | hosts -> U1 | |
| T6 | return success | hosts -> U1 |
A wrote into U0, which the overwrite orphaned, so A's rows stay outside the published lookup while A reports success.
- When both writers observe the lookup as absent (one append, one overwrite), the alias resolves to whichever installs it last, so the surviving data comes from that writer.
This matches the merged data importer (OpenSearch-Dashboards#11303), which uses add-only alias updates and leaves cross-request coordination to the caller. I documented this contract in docs/user/ppl/cmd/outputlookup.md (Concurrency section) and recommend serializing writes to a given lookup, for example a single scheduled refresh per lookup, for deterministic results under contention. A per-lookup lock (control-index doc with seqNo/primaryTerm CAS) would eliminate the append-vs-overwrite window; I have it as a follow-up rather than in this PR to stay aligned with the importer precedent.
|
Persistent review updated to latest commit 91bf200 |
3e25b5f to
4ebd6a0
Compare
|
Persistent review updated to latest commit 4ebd6a0 |
outputlookup materializes a pipeline result into a lookup and returns a single rows_written count. A lookup <name> is a __lookup=<uuid> slice in a dedicated per-lookup, plain, non-hidden backing index (<name>__lookup) behind a filtered alias, the same artifact the Dashboards data importer (#11303) produces: non-hidden backing (no dot-prefix read grant), per-lookup mapping (no cross-lookup type conflict), and per-lookup index-level write authz. The single user-facing name is the lookup alias; the backing index is derived and hidden, matching SPL's single-name outputlookup. Overwrite writes a fresh slice and atomically repoints the alias (content-atomic, gap-free); append bulks into the current slice. - Sourceless pipelines: resolve a client handle from the schema and register the write-lowering rule via a table-supplied extension point, so makeresults|outputlookup and join|outputlookup work. - max validation: reject 1 > max or max > max_rows before any write. - Output column named rows_written via deriveRowType. - Deterministic keyed _id encoding for BigDecimal/BigInteger key values. - Same-name concurrency: append to an absent lookup uses a deterministic per-lookup discriminant so concurrent first-appends converge into one slice with no lost write; overwrite keeps a fresh uuid with last-writer-wins on the atomic repoint. - Parse the __lookup discriminant from the alias filter with XContentParser instead of a non-anchored regex. - Bound the bulk 429 retry loop with an absolute timeout. Tests: CalcitePPLOutputLookupIT (per-lookup isolation, importer-alias repoint, sourceless makeresults, concurrent append-to-absent), OutputLookupPermissionsIT, LookupIdEncoderTest, AstBuilderTest. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
4ebd6a0 to
dade0d5
Compare
|
Persistent review updated to latest commit dade0d5 |
The pre-publish slice is invisible until the atomic alias repoint, so it can be loaded like a build-time index: 0 replicas, async translog, and no auto refresh during the write, restored to serving settings before publish. Bulk batch size raised to 5000 to match a plain bulk load. Same-cluster A/B on 3-node m5.xlarge (node-side): overwrite throughput at 1M rows rises to on-par-or-faster than a plain _bulk load of the same data (1.25-1.42x), eliminating the per-batch refresh tax measured earlier. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit b84bad6 |
| repointFilteredAlias(client, name, target.aliasIndices(), backingIndex, uuid); | ||
| // TODO(reaper, separate PR): the atomic repoint leaves the previous slice as an | ||
| // orphan (a __lookup uuid in the backing index referenced by no alias). Crash-before- | ||
| // repoint and concurrent same-name overwrite produce the same orphan shape. A reaper | ||
| // reclaims them per backing index: enumerate distinct __lookup uuids, subtract the set | ||
| // referenced by any filtered alias, delete_by_query the remainder. This info log is the | ||
| // reaper's observability seam until then. |
There was a problem hiding this comment.
I’m still concerned that maintaining multiple UUID slices in one backing index introduces unnecessary lifecycle complexity. Before the alias is repointed, a newly written slice is indistinguishable from an orphan to the proposed reaper, so safe cleanup would require additional coordination or a grace period. The generations also share mappings, index-level settings, and the reserved __lookup field, and cleanup requires delete_by_query.
Would one physical index per generation plus an atomic unfiltered-alias switch be simpler? Orphan generations would then be unreferenced managed indices and could be removed by deleting the entire index.
There was a problem hiding this comment.
Aligned this with the merged precedent.
OpenSearch-Dashboards#11303 (the data importer) uses the same model: one index holding multiple slices keyed by a __lookup=<uuid> field, with a filtered alias {term:{__lookup:<uuid>}} published on a clean load. Field name, type, and alias-filter convention match, so outputlookup and the importer stay wire-compatible on the read side.
On orphan handling: #11303's alias update is add-only (a single add action), so a re-import repoints the alias and leaves the previous slice in place; it keeps the slice model and defers reclamation. I matched that here by removing the reaper/delete_by_query design. Overwrite repoints the filtered alias to the new slice in one atomic aliases request; reclaiming the superseded slice is deferred to a follow-up, consistent with the importer.
One deliberate difference, in the safer direction: the backing index is per-lookup (<name>__lookup), giving each lookup its own mapping and write boundary while keeping the identical __lookup + filtered-alias convention.
There was a problem hiding this comment.
Ok, I'm not quite familiar with data importer background. If our goal is to align with previous behavior, then we‘d better clarify all of limitations. Please also get the signoff from data importer author or other developer whoever knows the previous design decisions.
…exception, import cleanup - close the input Enumerator via try/finally in OutputLookupWriteExec.execute - wrap the slice bulk write and refresh in try/finally so serve settings are always restored even if the writer or refresh throws - convert inline fully-qualified class references to proper imports across Analyzer, AbstractNodeVisitor, OutputLookup, AbstractOpenSearchTable, CalciteRelNodeVisitor, OutputLookupTableModify, OpenSearchIndex, LookupIdEncoder Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit 5dc4c9a |
outputlookup called ensureExists(<name>__lookup) unconditionally before resolving the write target. When appending into an existing lookup whose primary index is not <name>__lookup (for example one created by the data importer), that left behind an empty, unreferenced <name>__lookup index. Create the backing index only in the two branches that write to it (new lookup and overwrite). The append branch writes into the alias's existing primary index and no longer creates an unused index. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit 306bf6e |
…eaper design Strictly align outputlookup's write path with the merged data importer (OpenSearch-Dashboards#11303): - Remove applyLoadSettings/restoreServeSettings around the slice bulk write. The importer creates the index and ingests without touching index-level settings; restoreServeSettings also wrote back hardcoded values (replicas=1, durability=request, refresh=default) instead of the index's original settings, which could clobber a backing index with custom settings. - Remove the reaper / delete_by_query design. Overwrite still repoints the filtered alias atomically; the superseded slice is left unreferenced, matching the importer (add-only, also leaves superseded slices in place). Orphan reclamation is out of scope for this PR. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit 9c2adda |
…kup-clean # Conflicts: # common/src/main/java/org/opensearch/sql/common/setting/Settings.java # docs/user/ppl/index.md # ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java
|
Persistent review updated to latest commit 9d9a533 |
Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
|
Persistent review updated to latest commit f9d9260 |
| | [explain command](cmd/explain.md) | 3.1 | stable (since 3.1) | N/A | Explain the plan of query. | | ||
| | [show datasources command](cmd/showdatasources.md) | 2.4 | stable (since 2.4) | N/A | Query datasources configured in the PPL engine. | | ||
| | [makeresults command](cmd/makeresults.md) | 3.8 | experimental (since 3.8) | No | Generate in-memory rows for testing and seeding, optionally from inline CSV/JSON data. | | ||
| | [outputlookup command](cmd/outputlookup.md) | 3.8 | experimental (since 3.8) | No | Write pipeline results into a lookup, read back with source=<name> or the lookup command. | |
There was a problem hiding this comment.
nit: need to be changed to 3.9
| /** Suffix of the dedicated per-lookup backing index derived from the lookup name. */ | ||
| public static final String BACKING_SUFFIX = "__lookup"; |
There was a problem hiding this comment.
nit: A better namespace control could be metadata enrichment to avoid index naming conflicts with other business indices. Curious if we could add reserved metadata info to _meta to strengthen it?
| rows, | ||
| target.lookupUuid()); | ||
| } else { | ||
| String uuid = newUuid(); |
There was a problem hiding this comment.
Could we validate the target overwrite index to be valid lookup index?
| tagged[row.length] = uuid; | ||
| writer.add(tagged); | ||
| } | ||
| } |
There was a problem hiding this comment.
What would be partial write success/failure behavior? In case of append mode, could it generate partial duplicate rows? If we don't expect to handle it, add it to limitations.
| repointFilteredAlias(client, name, target.aliasIndices(), backingIndex, uuid); | ||
| // TODO(reaper, separate PR): the atomic repoint leaves the previous slice as an | ||
| // orphan (a __lookup uuid in the backing index referenced by no alias). Crash-before- | ||
| // repoint and concurrent same-name overwrite produce the same orphan shape. A reaper | ||
| // reclaims them per backing index: enumerate distinct __lookup uuids, subtract the set | ||
| // referenced by any filtered alias, delete_by_query the remainder. This info log is the | ||
| // reaper's observability seam until then. |
There was a problem hiding this comment.
Ok, I'm not quite familiar with data importer background. If our goal is to align with previous behavior, then we‘d better clarify all of limitations. Please also get the signoff from data importer author or other developer whoever knows the previous design decisions.
Description
Implements the PPL
outputlookupcommand from RFC #5625 — a synchronous, terminal write sink that materializes the current pipeline result into a lookup and returns a singlerows_writtencount.Semantics, substrate, consistency contract,
<name>resolution/migration, permissions, and alternatives are all in the RFC and are not restated here.This PR also adds the operator ceiling
plugins.ppl.outputlookup.max_rows(NodeScope,Dynamic, default1_000_000): a single call exceeding it fails with 400 and writes nothing (fail-loud, no truncated slice), orthogonal to the per-querymax=<int>truncation.Tests:
CalcitePPLOutputLookupIT(18, incl.testMaxRowsSettingRejectsExceeding),OutputLookupPermissionsIT(2, incl. a read-privileged user reading the lookup through its alias), and unit tests.Benchmark (3-node
m5.xlarge): write-bound; per-batch refresh is ~2.5x of plain bulk (the main follow-up lever); 1M rows written un-truncated with no OOM; same-name concurrent overwrite is last-writer-wins with 13,532 reads and 0 torn/partial across 30 atomic repoints; crash-window self-heals on re-run. Full perf + chaos results and charts are in the comment below.Related Issues
Addresses #5625
Check List
--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.