Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9476e41
Add PPL outputlookup command (synchronous terminal write sink)
noCharger Jul 14, 2026
92cfd5f
Harden outputlookup failure cases (key_field validation, dest guard, …
noCharger Jul 14, 2026
e1d19cd
Merge remote-tracking branch 'origin/main' into feature/ppl-outputloo…
noCharger Jul 15, 2026
4c9fb21
Surface outputlookup write params in explain + add user doc
noCharger Jul 15, 2026
d39455c
Merge remote-tracking branch 'origin/main' into feature/ppl-outputloo…
noCharger Jul 15, 2026
b1c4ccb
outputlookup: migration-aware overwrite for data-importer (#11303) lo…
noCharger Jul 15, 2026
7faebf1
outputlookup C2: plain-index substrate + lookup-marker guard + indice…
noCharger Jul 15, 2026
a986700
Apply spotless formatting
noCharger Jul 15, 2026
7963644
Add PPL outputlookup command on the shared .lookups substrate
noCharger Jul 20, 2026
5bf7c7c
Merge origin/main into feature/ppl-outputlookup-clean
noCharger Jul 20, 2026
29b7e8b
Merge origin/main into feature/ppl-outputlookup-clean
noCharger Jul 20, 2026
e682917
Make outputlookup max_rows exceed error actionable
noCharger Jul 20, 2026
dade0d5
Add PPL outputlookup command
noCharger Jul 21, 2026
b84bad6
Optimize outputlookup slice write with build-time index settings
noCharger Jul 21, 2026
5dc4c9a
Address review comments: close input enumerator, restore settings on …
noCharger Jul 23, 2026
306bf6e
Create the outputlookup backing index only on paths that write to it
noCharger Jul 30, 2026
9c2adda
Match the merged data importer: drop write-time settings tuning and r…
noCharger Aug 11, 2026
9d9a533
Merge remote-tracking branch 'origin/main' into feature/ppl-outputloo…
noCharger Aug 12, 2026
f9d9260
Document the outputlookup concurrency contract for same-name writes
noCharger Aug 12, 2026
81a06c2
Address review feedback: overwrite lookup validation, backing index _…
noCharger Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public enum Key {
PPL_SYNTAX_LEGACY_PREFERRED("plugins.ppl.syntax.legacy.preferred"),
PPL_SUBSEARCH_MAXOUT("plugins.ppl.subsearch.maxout"),
PPL_JOIN_SUBSEARCH_MAXOUT("plugins.ppl.join.subsearch_maxout"),
OUTPUTLOOKUP_MAX_ROWS("plugins.ppl.outputlookup.max_rows"),

/** Enable Calcite as execution engine */
CALCITE_ENGINE_ENABLED("plugins.calcite.enabled"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,12 @@ public LogicalPlan visitNoMv(NoMv node, AnalysisContext context) {
throw getOnlyForCalciteException("nomv");
}

@Override
public LogicalPlan visitOutputLookup(
org.opensearch.sql.ast.tree.OutputLookup node, AnalysisContext context) {
Comment thread
noCharger marked this conversation as resolved.
Outdated
throw getOnlyForCalciteException("outputlookup");
}

@Override
public LogicalPlan visitMakeResults(MakeResults node, AnalysisContext context) {
throw getOnlyForCalciteException("makeresults");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ public T visitReverse(Reverse node, C context) {
return visitChildren(node, context);
}

public T visitOutputLookup(org.opensearch.sql.ast.tree.OutputLookup node, C context) {
Comment thread
noCharger marked this conversation as resolved.
Outdated
return visitChildren(node, context);
}

public T visitTranspose(Transpose node, C context) {
return visitChildren(node, context);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.ast.tree;

import com.google.common.collect.ImmutableList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.opensearch.sql.ast.AbstractNodeVisitor;

/**
* AST node for the {@code outputlookup} command: a terminal write sink that materializes pipeline
* rows into a lookup index. Overwrite-by-default (set {@code append=true} to append instead). See
* the outputlookup PPL design.
*/
@Getter
@Setter
@ToString
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor
@AllArgsConstructor
public class OutputLookup extends UnresolvedPlan {

private UnresolvedPlan child;

/** Destination lookup name. */
private final String indexName;

/** false (default) overwrites the destination; true appends to it. */
private boolean append = false;

/** true (default) clears the destination on empty results; false keeps it. */
private boolean overrideIfEmpty = true;

/**
* Fields whose values form the document {@code _id} for upsert; empty means auto-generated id.
*/
private List<String> keyFields = java.util.List.of();
Comment thread
noCharger marked this conversation as resolved.
Outdated

/** Cap on the number of rows written; null means unbounded. */
private Integer max;

@Override
public OutputLookup attach(UnresolvedPlan child) {
this.child = child;
return this;
}

@Override
public List<UnresolvedPlan> getChild() {
return this.child == null ? ImmutableList.of() : ImmutableList.of(this.child);
}

@Override
public <T, C> T accept(AbstractNodeVisitor<T, C> nodeVisitor, C context) {
return nodeVisitor.visitOutputLookup(this, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,67 @@ public RelNode visitReverse(
return context.relBuilder.peek();
}

@Override
public RelNode visitOutputLookup(
org.opensearch.sql.ast.tree.OutputLookup node, CalcitePlanContext context) {
Comment thread
noCharger marked this conversation as resolved.
Outdated
visitChildren(node, context);
String indexName = node.getIndexName();
if (indexName.startsWith(".")) {
throw new IllegalArgumentException(
"outputlookup destination [" + indexName + "] must not be a system (dot-prefixed) index");
}
RelNode child = context.relBuilder.build();
// Validate key_field names against the result schema: a missing/misspelled key field would
// otherwise encode identically for every row and collapse them into a single document.
if (!node.getKeyFields().isEmpty()) {
java.util.List<String> resultFields = child.getRowType().getFieldNames();
Comment thread
noCharger marked this conversation as resolved.
Outdated
for (String keyField : node.getKeyFields()) {
if (!resultFields.contains(keyField)) {
throw new IllegalArgumentException(
"outputlookup key_field ["
+ keyField
+ "] is not a field of the result; available fields: "
+ resultFields);
}
}
}
org.apache.calcite.plan.RelOptTable sourceTable = findSourceTable(child);
if (sourceTable == null) {
throw new IllegalArgumentException(
"outputlookup requires an OpenSearch source scan in the pipeline");
}
Comment thread
noCharger marked this conversation as resolved.
Outdated
org.apache.calcite.plan.RelOptSchema relOptSchema = context.relBuilder.getRelOptSchema();
if (!(relOptSchema instanceof org.apache.calcite.prepare.Prepare.CatalogReader)) {
throw new IllegalStateException("outputlookup could not obtain a Calcite catalog reader");
}
RelNode sink =
OutputLookupTableModify.create(
child,
sourceTable,
(org.apache.calcite.prepare.Prepare.CatalogReader) relOptSchema,
indexName,
node.isAppend(),
node.isOverrideIfEmpty(),
node.getKeyFields(),
node.getMax());
context.relBuilder.push(sink);
return context.relBuilder.peek();
}

/** Walk down to the first scan carrying a table, so the write rule can reach the client. */
private static org.apache.calcite.plan.RelOptTable findSourceTable(RelNode rel) {
if (rel.getTable() != null) {
return rel.getTable();
}
for (RelNode input : rel.getInputs()) {
org.apache.calcite.plan.RelOptTable found = findSourceTable(input);
if (found != null) {
return found;
}
}
return null;
}

@Override
public RelNode visitTranspose(
org.opensearch.sql.ast.tree.Transpose node, CalcitePlanContext context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.calcite;

import java.util.List;
import lombok.Getter;
import org.apache.calcite.plan.Convention;
import org.apache.calcite.plan.RelOptCluster;
import org.apache.calcite.plan.RelOptTable;
import org.apache.calcite.plan.RelTraitSet;
import org.apache.calcite.prepare.Prepare.CatalogReader;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.TableModify;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
* Terminal write node for outputlookup, modeled as a Calcite {@link TableModify} INSERT so the
* optimizer treats it as a mandatory table-modifying side effect (never dropped or reordered) and
* it exposes the standard rowcount row type. The referenced table is the source scan, used only so
* the lowering rule can reach the in-cluster client; the real destination lookup index is created
* and written at execution time by the physical operator, so a preexisting destination table is not
* required. Subclasses {@link TableModify} rather than LogicalTableModify so the built-in
* EnumerableTableModifyRule (which needs a ModifiableTable) does not fire on it.
*/
@Getter
public class OutputLookupTableModify extends TableModify {
Comment thread
noCharger marked this conversation as resolved.

private final String indexName;
private final boolean append;
private final boolean overrideIfEmpty;
private final java.util.List<String> keyFields;
Comment thread
noCharger marked this conversation as resolved.
Outdated
private final @Nullable Integer max;

public OutputLookupTableModify(
RelOptCluster cluster,
RelTraitSet traits,
RelOptTable table,
CatalogReader catalogReader,
RelNode input,
String indexName,
boolean append,
boolean overrideIfEmpty,
java.util.List<String> keyFields,
@Nullable Integer max) {
super(cluster, traits, table, catalogReader, input, Operation.INSERT, null, null, false);
this.indexName = indexName;
this.append = append;
this.overrideIfEmpty = overrideIfEmpty;
this.keyFields = keyFields;
this.max = max;
}

public static OutputLookupTableModify create(
RelNode input,
RelOptTable table,
CatalogReader catalogReader,
String indexName,
boolean append,
boolean overrideIfEmpty,
java.util.List<String> keyFields,
@Nullable Integer max) {
RelOptCluster cluster = input.getCluster();
RelTraitSet traits = cluster.traitSetOf(Convention.NONE);
return new OutputLookupTableModify(
cluster,
traits,
table,
catalogReader,
input,
indexName,
append,
overrideIfEmpty,
keyFields,
max);
}

@Override
public RelNode copy(RelTraitSet traitSet, List<RelNode> inputs) {
return new OutputLookupTableModify(
getCluster(),
traitSet,
getTable(),
getCatalogReader(),
inputs.get(0),
indexName,
append,
overrideIfEmpty,
keyFields,
max);
}
}
1 change: 1 addition & 0 deletions docs/category.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"user/ppl/cmd/head.md",
"user/ppl/cmd/join.md",
"user/ppl/cmd/lookup.md",
"user/ppl/cmd/outputlookup.md",
"user/ppl/cmd/mvcombine.md",
"user/ppl/cmd/nomv.md",
"user/ppl/cmd/mvexpand.md",
Expand Down
96 changes: 96 additions & 0 deletions docs/user/ppl/cmd/outputlookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@

# outputlookup

The `outputlookup` command is a terminal write sink: it materializes the current pipeline result into a lookup index and returns a single `rows_written` count. Use it to build or refresh a lookup (dimension) dataset from a search, which can then be read back with `source=<name>` or enriched into other searches with the `lookup` command.

A lookup name refers to a plain index. Overwrite replaces that index with the current result (its schema is replaced each run); append adds the result to it. Writes are eventually consistent: a reader during an overwrite may briefly see the lookup being rebuilt.

## Syntax

The `outputlookup` command has the following syntax:

```syntax
outputlookup [append=<bool>] [override_if_empty=<bool>] [key_field=<field>(, <field>)*] [max=<int>] <name>
```

The following are examples of the `outputlookup` command syntax:

```syntax
source = table | outputlookup my_lookup
source = table | where status = 'active' | outputlookup active_hosts
source = table | stats count by region | outputlookup region_counts
source = table | fields id, name | outputlookup append=true my_lookup
source = table | outputlookup override_if_empty=false my_lookup
source = table | fields id, name | outputlookup key_field=id my_lookup
source = table | fields region, host, val | outputlookup key_field=region, host my_lookup
source = table | outputlookup max=1000 sample_lookup
```

## Parameters

The `outputlookup` command supports the following parameters.

| Parameter | Required/Optional | Description |
| --- | --- | --- |
| `<name>` | Required | The lookup name to write to. It is a plain index, created on demand if it does not exist and replaced on overwrite. Lookup indices are tagged with a `_meta.lookup` marker; if the name is an existing **non-lookup** index (no marker), the command refuses rather than overwriting it — delete it first to reuse the name. If the name is a filtered alias created by the OpenSearch Dashboards data importer, overwrite migrates it onto a dedicated plain index (the shared index is never deleted). |
| `append` | Optional | When `false` (default), overwrites the lookup with the result. When `true`, appends the result to the existing lookup. Because OpenSearch is schemaless, appended rows may introduce new fields. Default is `false`. |
| `override_if_empty` | Optional | When `true` (default), an empty result clears the existing lookup. When `false`, an empty result leaves the existing lookup intact. Default is `true`. |
| `key_field` | Optional | One or more fields (comma-separated) used as the upsert key. Rows are written by a deterministic `_id` derived from the key values, so re-running the same command updates matching rows in place instead of creating duplicates. Setting `key_field` implies `append=true`. Every field listed must be a field of the result; a multivalue key value is rejected. |
| `max` | Optional | Caps the number of rows written. |

## Example 1: Building a lookup from an aggregation

The following query writes per-region counts into a lookup and returns the number of rows written:

```ppl ignore
source = events
| stats count as cnt by region
| outputlookup region_counts
```

The query returns the following result:

```text
+--------------+
| rows_written |
|--------------|
| 3 |
+--------------+
```

The lookup can then be read back:

```ppl ignore
source = region_counts
```

## Example 2: Idempotent upsert with `key_field`

The following query upserts by `id`, so re-running it updates existing rows instead of duplicating them:

```ppl ignore
source = users
| fields id, name, department
| outputlookup key_field=id users_lookup
```

Running the command a second time with overlapping `id` values leaves the row count unchanged for the overlapping keys and only inserts genuinely new keys.

## Example 3: Preserving a lookup on an empty result

The following query refreshes `error_hosts` only when the search returns rows; an empty result keeps the previous lookup intact:

```ppl ignore
source = logs
| where level = 'ERROR'
| fields host
| outputlookup override_if_empty=false error_hosts
```

## Limitations

- `outputlookup` is a terminal command: it returns a `rows_written` count rather than forwarding the input rows.
- The destination is always a lookup index; there is no file output target.
- Overwrite is weak/eventually consistent but gap-free: it writes a fresh slice and atomically repoints the alias, so a concurrent read always sees the whole old slice or the whole new one, never a partial state. The previous slice is orphaned until reclaimed.
- `outputlookup` is for bounded lookup (dimension) tables, not a bulk data sink. The number of rows a single write may produce is capped by `plugins.ppl.outputlookup.max_rows` (NodeScope, Dynamic, default `1000000`, minimum `1`). Exceeding it fails the query (no slice is written) rather than silently truncating. The optional `max=<int>` command argument truncates to at most N rows and must not exceed the setting. To write a larger lookup you can raise `plugins.ppl.outputlookup.max_rows` (it is dynamic; a higher ceiling costs more coordinator heap per write), or ingest large data through a bulk indexing path instead.
- The write executes under the caller's security context. The caller needs write and get privileges on the destination and alias privileges to publish or repoint the filtered alias, plus create-index the first time the shared `.lookups` index is created; no cluster-level privilege is required.
Loading
Loading