[Feature] Add PPL outputlookup command (synchronous terminal write sink)#5621
[Feature] Add PPL outputlookup command (synchronous terminal write sink)#5621noCharger wants to merge 15 commits into
Conversation
PR Reviewer Guide 🔍(Review updated until commit 5dc4c9a)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 5dc4c9a Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit b84bad6
Suggestions up to commit dade0d5
Suggestions up to commit 4ebd6a0
Suggestions up to commit 3e25b5f
Suggestions up to commit 91bf200
|
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 b84bad6.
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 |
39f1c3c to
995ed79
Compare
|
Persistent review updated to latest commit 995ed79 |
995ed79 to
4ccbe20
Compare
|
Persistent review updated to latest commit 4ccbe20 |
Adds the PPL outputlookup command (RFC opensearch-project#5625): a synchronous, terminal write sink that materializes a pipeline result into a lookup and returns a rows_written count. Every lookup is a __lookup=<uuid> slice in one shared .lookups index behind a filtered alias <name>, the same artifact the Dashboards data importer produces. LookupsIndex owns .lookups as a normal hidden index (not a registered system index, so users can read a lookup through its alias) with a uniform dynamic template mapping string to text plus keyword. Overwrite writes a fresh slice and atomically repoints the alias in one aliases request; append bulks into the current slice; keyed _id is uuid-salted. A concrete same-name index is refused; a legacy alias over an index outside .lookups migrates onto a .lookups slice on overwrite. Terminal Calcite TableModify INSERT node, PIT-streamed unbounded read, runs under the caller security context. An operator ceiling plugins.ppl.outputlookup.max_rows (NodeScope, Dynamic, default 1000000) fails the write loud with a 400 and writes no slice when the input exceeds it, so an unbounded scan cannot silently drain the coordinator. It is orthogonal to the per-query max= argument, which still truncates to at most N by user opt-in. Reclaiming slices orphaned by overwrite (and by crash or concurrent overwrite) is left to a follow-up reaper PR; the overwrite path logs each orphan as the reaper's observability seam. Tests: CalcitePPLOutputLookupIT (18), OutputLookupPermissionsIT (2). C2 substrate preserved on feature/ppl-outputlookup-c2; alias-swap B on feature/ppl-outputlookup-alias-swap. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
4ccbe20 to
7963644
Compare
|
Persistent review updated to latest commit 7963644 |
Resolve conflicts from the upstream revert of the PPL rest command (opensearch-project#5635 reverting opensearch-project#5599): - Settings.java: keep OUTPUTLOOKUP_MAX_ROWS, drop the reverted PPL_REST_REDACTION_ENABLED / PPL_REST_ALLOWED_ENDPOINTS keys. - AstBuilderTest.java: keep the outputlookup parser tests, drop the reverted testRestCommand / testRestCommandWithArgs. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
Resolve conflicts from the upstream merge of PPL makeresults (opensearch-project#5622): - Analyzer.java: keep both visitOutputLookup and visitMakeResults. - AstBuilderTest.java: keep both imports (MakeResults, OutputLookup). Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
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.
|
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 |
|
|
||
| @Override | ||
| public LogicalPlan visitOutputLookup( | ||
| org.opensearch.sql.ast.tree.OutputLookup node, AnalysisContext context) { |
There was a problem hiding this comment.
nit: fix this import?
| /** | ||
| * Fields whose values form the document {@code _id} for upsert; empty means auto-generated id. | ||
| */ | ||
| private List<String> keyFields = java.util.List.of(); |
| return visitChildren(node, context); | ||
| } | ||
|
|
||
| public T visitOutputLookup(org.opensearch.sql.ast.tree.OutputLookup node, C context) { |
| public java.util.List<org.apache.calcite.plan.RelOptRule> getWriteConversionRules() { | ||
| return java.util.List.of(); |
|
|
||
| @Override | ||
| public RelNode visitOutputLookup( | ||
| org.opensearch.sql.ast.tree.OutputLookup node, CalcitePlanContext context) { |
| // the slice through the atomic alias repoint that follows, and append through this refresh, so | ||
| // per-batch refresh is redundant and dominates write cost at scale. | ||
| client.admin().indices().refresh(new RefreshRequest(index)).actionGet(); | ||
| restoreServeSettings(client, index); |
There was a problem hiding this comment.
It will never restore settings if writer or client throws exception
| if (value instanceof Float || value instanceof Double) { | ||
| return Double.toString(((Number) value).doubleValue()); | ||
| } | ||
| if (value instanceof java.math.BigDecimal bigDecimal) { |
| // longValue() would truncate the fraction and collide distinct decimals onto one id | ||
| return bigDecimal.stripTrailingZeros().toPlainString(); | ||
| } | ||
| if (value instanceof java.math.BigInteger bigInteger) { |
| return 0; | ||
| } | ||
|
|
||
| LookupsIndex.ensureExists(client, backingIndex); |
There was a problem hiding this comment.
It will create an empty index when there is an existing lookup index, right? Then the empty index is never used.
| 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.
…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 |
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.