You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PPL can read lookups (lookup, source=<lookup_index>) but has no way to write a search result back into a lookup dataset. Users who want to derive a lookup (dimension) table from a query, such as a per-host rollup, a filtered subset, or a deduplicated key set, must build and refresh lookup indices out of band (frontend data importer, manual bulk indexing, external ETL). There is no in-query way to materialize a lookup from a pipeline result.
Current State
lookup enriches rows from an existing lookup index; no PPL command produces or refreshes one.
Lookups are created and maintained outside PPL.
The already-merged Dashboards data importer (OpenSearch-Dashboards#11303) ships a lookup substrate: a filtered alias ({term:{__lookup:<uuid>}}) over a user-chosen backing index, written as a new __lookup slice and made visible by pointing the alias at it. The importer addresses its target index by name, with the filtered alias as an optional versioning layer on top. Related RFC #5074 proposed read/write lookup commands. This RFC adopts the same alias-plus-uuid-slice mechanism so PPL writes and importer writes share one shape, using a dedicated per-lookup backing index so lookups stay isolated from one another.
Long-Term Goals
Give PPL a first-class way to persist a query result as a lookup, so downstream searches enrich against a small precomputed dataset instead of rescanning raw data.
Keep the operation simple and predictable:
Overwrite is content-atomic and gap-free: a fresh slice is written and the alias is atomically repointed, so a concurrent reader always resolves the complete old lookup or the complete new lookup, never a partial one, and a crash mid-write leaves the old lookup serving and self-heals on re-run.
Append is incremental and at-least-once; a keyed write is idempotent on re-run.
Run entirely under the caller's security context with no new privilege surface, so it is safe to expose on managed or multi-tenant clusters.
Serve the lookup use case (small, bounded dimension datasets) and keep a single call's resource footprint bounded via an operator ceiling, so a large or unbounded query cannot OOM the coordinator or become a noisy neighbor.
It materializes the current pipeline result into the lookup <name> and returns a single rows_written count. The command is single-name: <name> is the lookup, matching SPL outputlookup <name>; the physical backing index is derived and hidden from the user.
Overwrite (append=false, default): replace the lookup with the result (atomic, gap-free).
append=true: append to the existing lookup.
override_if_empty (default true): an empty result clears the lookup; =false leaves it intact.
key_field=<f...>: upsert by key (idempotent re-run, no duplicates); implies append=true. Every key field must be a field of the result; multivalue key values are rejected.
max=<n>: per-query cap; truncates the result to at most n rows (user-opted, like SQL LIMIT).
Terminal sink: returns a rows_written count rather than forwarding input rows. Multivalue fields are preserved as native arrays. The destination is created on demand.
A cluster setting plugins.ppl.outputlookup.max_rows (NodeScope, Dynamic, default 1_000_000, min 1) is an operator ceiling on how many rows a single call may write (see Operator ceiling below).
Approach
Substrate = one dedicated backing index per lookup. A lookup <name> is a __lookup=<uuid>slice inside an ordinary, non-hidden index <name>__lookup, behind a filtered alias<name> ({term:{__lookup:<uuid>}}), the same alias-plus-slice artifact #11303 produces. The backing index is created on demand and owns its own mapping via a uniform dynamic template (string maps to text plus keyword, date and numeric detection off), so the write honors that mapping rather than imposing a keyword-only one. Reads (lookup <name>, source=<name>) resolve the alias and see only that lookup's slice. Because each lookup has its own index, there is no shared mapping, no cross-lookup type conflict, and write authorization is per-index.
Overwrite (append=false): write a fresh __lookup=<new-uuid> slice, then atomically repoint the alias onto it in one _aliases request (remove the old filter, add the new). Reads are content-atomic and gap-free. The previous slice is left orphaned (a __lookup uuid referenced by no alias) for a follow-up reaper.
Append (append=true): resolve the current slice uuid from the alias filter and bulk into it. Incrementally visible, at-least-once.
Append to an absent lookup uses a deterministic per-lookup discriminant, so two concurrent first-appends converge into the same slice and install the same alias filter (idempotent), with no lost write and no orphan. This defines the same-name concurrency contract; overwrite stays last-writer-wins on the atomic repoint.
key_field upsert uses a deterministic _id salted with the slice uuid, so keyed rows never collide across slices; length-prefixed per-field canonical encoding distinguishes empty string from null, encodes BigDecimal and BigInteger without truncation, and rejects multivalue keys. A keyed append is idempotent on re-run; a plain append may duplicate.
The command is a Calcite TableModify (INSERT) node, so the optimizer treats it as a mandatory side effect (never dropped or reordered), with the rowcount row type named rows_written. It does not require a source scan: the in-cluster client is resolved from the schema, so a sourceless pipeline such as makeresults | outputlookup works. The source pages unbounded via PIT, so the whole result is written in full, subject only to the operator ceiling below (fail-loud, never a silent truncation).
max_rows caps how many rows a singleoutputlookup call may write.
It is fail-loud: if the pipeline produces more than the ceiling, the command throws (HTTP 400) and writes nothing (no slice, no alias change). It never writes a truncated slice.
This preserves read/write consistency: a silent truncation would let the lookup diverge from its source with no error. Fail-loud makes it all-or-nothing. It is orthogonal to max=<int>, which is a user-opted truncation and therefore not a divergence. The command also validates 1 <= max <= max_rows before any write.
It makes outputlookup a bounded dimension-table sink, not a bulk loader. Bounding the per-call row count also bounds the source PIT lifetime and the coordinator buffer, the most direct mitigation for noisy-neighbor impact; PIT count and lifetime are otherwise governed by core OpenSearch (search.max_open_pit_context, plugins.sql.cursor.keep_alive).
A lookup larger than the ceiling is built by paged append, each call bounded and backpressured by indexing_pressure.
Safety / permissions: writes execute under the caller's security context (no privilege escalation). The caller needs indices:data/write/bulk, indices:admin/aliases, and indices:admin/get (to probe the target) on the relevant names, plus indices:admin/create the first time the backing index is created. No cluster-level permission is required. Because each lookup is an ordinary non-hidden index, ordinary index patterns cover it and write authorization is per-lookup: a grant on lookup A does not grant write on lookup B. Verified by a permissions integration test in which a read-privileged user reads a lookup through its alias.
Full resolution of <name> (every case). The target is in one of these states, and the backing index is <name>__lookup:
<name> current state
overwrite (append=false)
append (append=true)
absent
write new slice, add filtered alias
write new slice (deterministic discriminant so concurrent first-appends converge), add filtered alias
our lookup (filtered alias)
write new slice, atomically repoint the alias; old slice orphaned for the reaper
bulk into the current slice
concrete index of the same name
refuse (a concrete index cannot share a name with the alias)
refuse
#11303 filtered alias over another index
migrate: write a <name>__lookup slice and repoint the alias onto it; the legacy index is not deleted; the old slice is orphaned
refuse (overwrite once to migrate)
other (non-filtered) alias
refuse
refuse
Alternative
Single shared .lookups index (co-locate all lookups as __lookup slices in one hidden index) is rejected. It re-inherits RFC [RFC] Discriminator-based lookups #5074's problems: mapping field explosion across all lookups (total_fields), first-writer-wins type conflicts on shared field names, a single-index blast radius, and a hidden dot-prefixed index that a role must be granted .* (or the exact name) to read. It also cannot scope write authorization per lookup, because a bulk grant on the shared index writes every slice and a filtered alias does not constrain writes. A dedicated per-lookup index avoids all of these while using the same alias-plus-uuid-slice mechanism as #11303.
Plain index per lookup with delete-plus-recreate overwrite is rejected. It gives only a weak, non-atomic overwrite (an absent or rebuilding window, and a crash can lose the lookup). The alias-plus-uuid-slice with atomic repoint gives content-atomic gap-free overwrite at the same per-lookup granularity.
Streaming-batch overwrite (page the read and bulk each page under one PIT, repoint once at the end) is rejected for v1. It lowers coordinator heap but holds the source PIT and indexing pressure on the data nodes for the whole write, trading a coordinator spike for a longer data-node noisy-neighbor window. Bounded writes (max_rows) plus paged append are preferred.
Asynchronous or task-based write is rejected for v1: outputlookup is naturally synchronous and terminal.
Performance (measured)
3-node m5.xlarge, node-side, single run per cell (directional). The write is materialized like a build-time index: the pre-publish slice is invisible until the atomic alias repoint, so it loads with 0 replicas, async translog, and no auto refresh, all restored to serving settings before publish, and a single refresh replaces per-batch refreshes. With this, outputlookup write throughput is on par with or faster than a plain _bulk load of the same rows (overwrite, rows/s):
write
outputlookup
plain _bulk
ratio
100K / 2 cols
25,802
27,533
0.94x
100K / 5 cols
30,980
19,984
1.55x
100K / 20 cols
11,145
11,329
0.98x
1M / 2 cols
44,358
31,275
1.42x
1M / 5 cols
32,982
24,043
1.37x
1M / 20 cols
13,774
10,976
1.25x
The _bulk baseline used default index settings (1 replica, request durability), so these ratios reflect each path at its realistic write configuration, not identical durability; the point is that the per-batch refresh tax an earlier build carried (about 2.5x slower than bulk) is eliminated and the write is now bulk-class. The write is bounded by max_rows; 1M rows wrote un-truncated with no OOM even at 20 columns (20M cells) on an 8GB heap. Read tax is negligible (about 11 to 15 ms for a 100-row lookup) and flat across 0 / 9 / 99 orphan slices.
Scale
Each lookup is its own bounded index, so the shared-index ceilings (mapping explosion, cross-lookup type conflict, single-index blast radius) do not arise. max_rows bounds a single lookup and a single write. The tradeoff is the opposite one: a user who materializes very many lookups creates many small indices (shard and cluster-state overhead). outputlookup targets a bounded number of query-materialized dimension tables; bulk ingestion of many tiny files remains the data importer's job. The only accumulation within a lookup's own index is orphaned slices from overwrite, reclaimed by a follow-up reaper (enumerate the __lookup uuids, subtract those referenced by the alias, delete the remainder).
Implementation Discussion
Implemented in PR #5621: grammar, AST, and parse (single-name syntax); the TableModify node (row type named rows_written) plus physical write operator, bulk writer, and deterministic uuid-salted _id encoder (BigDecimal and BigInteger safe); the per-lookup backing-index lifecycle (ensureExists non-hidden with a uniform dynamic template; overwrite = new slice + atomic alias repoint; append = bulk into the current slice; append-to-absent = deterministic discriminant so concurrent first-appends converge); a client resolved from the schema so sourceless pipelines work; a structured XContentParser read of the alias __lookup filter; an indices-level target probe (no cluster:monitor/state); migrate-on-touch for a legacy #11303 filtered alias (repoint onto <name>__lookup, never delete the legacy index); key_field schema validation at plan time; 1 <= max <= max_rows validation; a bounded bulk-retry loop with an absolute timeout; and the plugins.ppl.outputlookup.max_rows operator ceiling (fail-loud, no partial write). Tests: CalcitePPLOutputLookupIT (20, including per-lookup isolation, sourceless makeresults|outputlookup, concurrent append-to-absent, large-source no-truncation, multi-field upsert, concrete-index-refused, importer-alias migration, and the ceiling), OutputLookupPermissionsIT (2), LookupIdEncoderTest (including a BigDecimal regression), AstBuilderTest, plus user docs.
Consistency contract. Overwrite is content-atomic and gap-free (write new slice, atomic alias repoint); a crash before the repoint leaves the old lookup serving and self-heals on re-run (the orphan is invisible through the filter and enumerable for the reaper). Append is at-least-once; a keyed append is idempotent. Concurrent appends to an absent lookup converge into one slice (deterministic discriminant) with no lost write; concurrent overwrites are last-writer-wins. Verified: a same-name concurrent-overwrite chaos run observed last-writer-wins with 13,532 reads and zero torn, partial, or mixed states across 30 atomic repoints; a concurrent append-to-absent integration test confirms both appends persist.
Coexistence with the importer (no deprecation planned).outputlookup and the #11303 importer both use the alias-plus-uuid-slice shape, so they coexist by construction; reads (lookup and source=<name>) resolve either transparently. The only conversion is migrate-on-collision: overwriting a name that is currently a legacy filtered alias repoints it onto a <name>__lookup slice, and only that one lookup.
Resolved design decisions:
A lookup is a dedicated per-lookup, non-hidden backing index (<name>__lookup), not a shared hidden .lookups. This removes the shared-mapping, type-conflict, hidden-index, and per-lookup-write-authz concerns by construction.
The command is single-name (<name>), matching SPL; there is no user-facing backing-index option, and the backing index is derived and hidden.
Same-name concurrency: append-to-absent uses a deterministic discriminant (concurrent convergence, no lost write); overwrite is last-writer-wins on the atomic repoint.
The target-state probe is indices-level (get-index and get-aliases) and needs no cluster:monitor/state.
No dedicated inputlookup command; source=<name> suffices.
max_rows is a fail-loud operator ceiling (no partial write), orthogonal to the per-query max=<int> truncation.
Orphaned __lookup slices (left by every overwrite, and by crash or concurrent-overwrite losers) are reclaimed by a periodic reaper, delivered as a separate PR; this PR leaves the slice in place with an observability log and documents the discriminant contract the reaper keys on.
Remaining for discussion:
8. Co-design with #11303: is a shared discovery and management contract for "what is a lookup" worth standardizing across the two writers, and does the migrate-on-collision plus permanent-coexistence model match the data-importer owners' expectations?
Problem Statement
PPL can read lookups (
lookup,source=<lookup_index>) but has no way to write a search result back into a lookup dataset. Users who want to derive a lookup (dimension) table from a query, such as a per-host rollup, a filtered subset, or a deduplicated key set, must build and refresh lookup indices out of band (frontend data importer, manual bulk indexing, external ETL). There is no in-query way to materialize a lookup from a pipeline result.Current State
lookupenriches rows from an existing lookup index; no PPL command produces or refreshes one.{term:{__lookup:<uuid>}}) over a user-chosen backing index, written as a new__lookupslice and made visible by pointing the alias at it. The importer addresses its target index by name, with the filtered alias as an optional versioning layer on top. Related RFC #5074 proposed read/write lookup commands. This RFC adopts the same alias-plus-uuid-slice mechanism so PPL writes and importer writes share one shape, using a dedicated per-lookup backing index so lookups stay isolated from one another.Long-Term Goals
Proposal
Add a terminal, synchronous PPL command:
It materializes the current pipeline result into the lookup
<name>and returns a singlerows_writtencount. The command is single-name:<name>is the lookup, matching SPLoutputlookup <name>; the physical backing index is derived and hidden from the user.append=false, default): replace the lookup with the result (atomic, gap-free).append=true: append to the existing lookup.override_if_empty(defaulttrue): an empty result clears the lookup;=falseleaves it intact.key_field=<f...>: upsert by key (idempotent re-run, no duplicates); impliesappend=true. Every key field must be a field of the result; multivalue key values are rejected.max=<n>: per-query cap; truncates the result to at mostnrows (user-opted, like SQLLIMIT).rows_writtencount rather than forwarding input rows. Multivalue fields are preserved as native arrays. The destination is created on demand.A cluster setting
plugins.ppl.outputlookup.max_rows(NodeScope,Dynamic, default1_000_000, min1) is an operator ceiling on how many rows a single call may write (see Operator ceiling below).Approach
Substrate = one dedicated backing index per lookup. A lookup
<name>is a__lookup=<uuid>slice inside an ordinary, non-hidden index<name>__lookup, behind a filtered alias<name>({term:{__lookup:<uuid>}}), the same alias-plus-slice artifact #11303 produces. The backing index is created on demand and owns its own mapping via a uniform dynamic template (string maps totextpluskeyword, date and numeric detection off), so the write honors that mapping rather than imposing a keyword-only one. Reads (lookup <name>,source=<name>) resolve the alias and see only that lookup's slice. Because each lookup has its own index, there is no shared mapping, no cross-lookup type conflict, and write authorization is per-index.append=false): write a fresh__lookup=<new-uuid>slice, then atomically repoint the alias onto it in one_aliasesrequest (remove the old filter, add the new). Reads are content-atomic and gap-free. The previous slice is left orphaned (a__lookupuuid referenced by no alias) for a follow-up reaper.append=true): resolve the current slice uuid from the alias filter and bulk into it. Incrementally visible, at-least-once.key_fieldupsert uses a deterministic_idsalted with the slice uuid, so keyed rows never collide across slices; length-prefixed per-field canonical encoding distinguishes empty string from null, encodesBigDecimalandBigIntegerwithout truncation, and rejects multivalue keys. A keyed append is idempotent on re-run; a plain append may duplicate.TableModify(INSERT) node, so the optimizer treats it as a mandatory side effect (never dropped or reordered), with the rowcount row type namedrows_written. It does not require a source scan: the in-cluster client is resolved from the schema, so a sourceless pipeline such asmakeresults | outputlookupworks. The source pages unbounded via PIT, so the whole result is written in full, subject only to the operator ceiling below (fail-loud, never a silent truncation).Operator ceiling:
plugins.ppl.outputlookup.max_rowsmax_rowscaps how many rows a singleoutputlookupcall may write.max=<int>, which is a user-opted truncation and therefore not a divergence. The command also validates1 <= max <= max_rowsbefore any write.outputlookupa bounded dimension-table sink, not a bulk loader. Bounding the per-call row count also bounds the source PIT lifetime and the coordinator buffer, the most direct mitigation for noisy-neighbor impact; PIT count and lifetime are otherwise governed by core OpenSearch (search.max_open_pit_context,plugins.sql.cursor.keep_alive).append, each call bounded and backpressured byindexing_pressure.Safety / permissions: writes execute under the caller's security context (no privilege escalation). The caller needs
indices:data/write/bulk,indices:admin/aliases, andindices:admin/get(to probe the target) on the relevant names, plusindices:admin/createthe first time the backing index is created. No cluster-level permission is required. Because each lookup is an ordinary non-hidden index, ordinary index patterns cover it and write authorization is per-lookup: a grant on lookup A does not grant write on lookup B. Verified by a permissions integration test in which a read-privileged user reads a lookup through its alias.Full resolution of
<name>(every case). The target is in one of these states, and the backing index is<name>__lookup:<name>current stateappend=false)append=true)<name>__lookupslice and repoint the alias onto it; the legacy index is not deleted; the old slice is orphanedAlternative
.lookupsindex (co-locate all lookups as__lookupslices in one hidden index) is rejected. It re-inherits RFC [RFC] Discriminator-based lookups #5074's problems: mapping field explosion across all lookups (total_fields), first-writer-wins type conflicts on shared field names, a single-index blast radius, and a hidden dot-prefixed index that a role must be granted.*(or the exact name) to read. It also cannot scope write authorization per lookup, because a bulk grant on the shared index writes every slice and a filtered alias does not constrain writes. A dedicated per-lookup index avoids all of these while using the same alias-plus-uuid-slice mechanism as #11303.max_rows) plus pagedappendare preferred.outputlookupis naturally synchronous and terminal.Performance (measured)
3-node
m5.xlarge, node-side, single run per cell (directional). The write is materialized like a build-time index: the pre-publish slice is invisible until the atomic alias repoint, so it loads with 0 replicas, async translog, and no auto refresh, all restored to serving settings before publish, and a single refresh replaces per-batch refreshes. With this,outputlookupwrite throughput is on par with or faster than a plain_bulkload of the same rows (overwrite, rows/s):outputlookup_bulkThe
_bulkbaseline used default index settings (1 replica, request durability), so these ratios reflect each path at its realistic write configuration, not identical durability; the point is that the per-batch refresh tax an earlier build carried (about 2.5x slower than bulk) is eliminated and the write is now bulk-class. The write is bounded bymax_rows; 1M rows wrote un-truncated with no OOM even at 20 columns (20M cells) on an 8GB heap. Read tax is negligible (about 11 to 15 ms for a 100-row lookup) and flat across 0 / 9 / 99 orphan slices.Scale
Each lookup is its own bounded index, so the shared-index ceilings (mapping explosion, cross-lookup type conflict, single-index blast radius) do not arise.
max_rowsbounds a single lookup and a single write. The tradeoff is the opposite one: a user who materializes very many lookups creates many small indices (shard and cluster-state overhead).outputlookuptargets a bounded number of query-materialized dimension tables; bulk ingestion of many tiny files remains the data importer's job. The only accumulation within a lookup's own index is orphaned slices from overwrite, reclaimed by a follow-up reaper (enumerate the__lookupuuids, subtract those referenced by the alias, delete the remainder).Implementation Discussion
Implemented in PR #5621: grammar, AST, and parse (single-name syntax); the
TableModifynode (row type namedrows_written) plus physical write operator, bulk writer, and deterministic uuid-salted_idencoder (BigDecimalandBigIntegersafe); the per-lookup backing-index lifecycle (ensureExistsnon-hidden with a uniform dynamic template; overwrite = new slice + atomic alias repoint; append = bulk into the current slice; append-to-absent = deterministic discriminant so concurrent first-appends converge); a client resolved from the schema so sourceless pipelines work; a structuredXContentParserread of the alias__lookupfilter; an indices-level target probe (nocluster:monitor/state); migrate-on-touch for a legacy #11303 filtered alias (repoint onto<name>__lookup, never delete the legacy index);key_fieldschema validation at plan time;1 <= max <= max_rowsvalidation; a bounded bulk-retry loop with an absolute timeout; and theplugins.ppl.outputlookup.max_rowsoperator ceiling (fail-loud, no partial write). Tests:CalcitePPLOutputLookupIT(20, including per-lookup isolation, sourcelessmakeresults|outputlookup, concurrent append-to-absent, large-source no-truncation, multi-field upsert, concrete-index-refused, importer-alias migration, and the ceiling),OutputLookupPermissionsIT(2),LookupIdEncoderTest(including aBigDecimalregression),AstBuilderTest, plus user docs.Consistency contract. Overwrite is content-atomic and gap-free (write new slice, atomic alias repoint); a crash before the repoint leaves the old lookup serving and self-heals on re-run (the orphan is invisible through the filter and enumerable for the reaper). Append is at-least-once; a keyed append is idempotent. Concurrent appends to an absent lookup converge into one slice (deterministic discriminant) with no lost write; concurrent overwrites are last-writer-wins. Verified: a same-name concurrent-overwrite chaos run observed last-writer-wins with 13,532 reads and zero torn, partial, or mixed states across 30 atomic repoints; a concurrent append-to-absent integration test confirms both appends persist.
Coexistence with the importer (no deprecation planned).
outputlookupand the #11303 importer both use the alias-plus-uuid-slice shape, so they coexist by construction; reads (lookupandsource=<name>) resolve either transparently. The only conversion is migrate-on-collision: overwriting a name that is currently a legacy filtered alias repoints it onto a<name>__lookupslice, and only that one lookup.Resolved design decisions:
<name>__lookup), not a shared hidden.lookups. This removes the shared-mapping, type-conflict, hidden-index, and per-lookup-write-authz concerns by construction.<name>), matching SPL; there is no user-facing backing-index option, and the backing index is derived and hidden.get-indexandget-aliases) and needs nocluster:monitor/state.inputlookupcommand;source=<name>suffices.max_rowsis a fail-loud operator ceiling (no partial write), orthogonal to the per-querymax=<int>truncation.__lookupslices (left by every overwrite, and by crash or concurrent-overwrite losers) are reclaimed by a periodic reaper, delivered as a separate PR; this PR leaves the slice in place with an observability log and documents the discriminant contract the reaper keys on.Remaining for discussion:
8. Co-design with #11303: is a shared discovery and management contract for "what is a lookup" worth standardizing across the two writers, and does the migrate-on-collision plus permanent-coexistence model match the data-importer owners' expectations?