Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
85997d8
feat(workflow-operator): export the relational and sampling operators
kz930 Sep 11, 2026
ab530db
Merge remote-tracking branch 'upstream/main' into feat/standalone-rel…
kz930 Sep 18, 2026
ef38771
test(operator): run both routes of the exported If
kz930 Sep 18, 2026
64600d7
docs(operator): say where a sorted string column parts from the engine
kz930 Sep 18, 2026
76b4690
fix(operator): reject the draws Java rejects, and a reservoir of zero
kz930 Sep 18, 2026
8522786
fix(operator): answer SUM, AVERAGE and CONCAT the way the engine answ…
kz930 Sep 18, 2026
f4ba91d
fix(operator): rename a twice-colliding join column, and keep an inte…
kz930 Sep 18, 2026
a240f01
docs(operator): cut today's comments back to what the code cannot say
kz930 Sep 18, 2026
42404db
fix(operator): sort a null and a NaN where the engine sorts them
kz930 Sep 18, 2026
0ac4039
test(operator): keep a null and a NaN apart in the set, join and keye…
kz930 Sep 18, 2026
c381bec
Merge branch 'main' into feat/standalone-relational-and-sampling
kz930 Sep 18, 2026
c18b1e5
fix(operator): aggregate a timestamp in milliseconds, and cast only t…
kz930 Sep 18, 2026
b8e5dbf
fix(operator): emit a join key once when both sides name it the same
kz930 Sep 19, 2026
7ccb215
fix(operator): widen an outer join's integer columns before the merge
kz930 Sep 19, 2026
de40276
fix(operator): sort on keys of its own so every input column survives
kz930 Sep 22, 2026
a5f3576
fix(operator): set the probe key aside before renaming the payload
kz930 Sep 22, 2026
516a066
fix(operator): add timestamps in the zone the script runs in
kz930 Sep 22, 2026
88ff2a9
fix(operator): name a join's right columns by one rule in the schema …
kz930 Sep 26, 2026
d80f561
fix(operator): put an unmatched right row's key where the engine puts it
kz930 Sep 26, 2026
9281163
fix(operator): concatenate and sample as the engine does, say which r…
kz930 Sep 27, 2026
e831318
refactor(workflow-operator): leave Aggregate, the sort family, the sa…
kz930 Sep 27, 2026
77c64a3
fix(workflow-operator): match set-operator rows by the engine's equality
kz930 Oct 13, 2026
ce2b893
fix(workflow-operator): name a HashJoin probe's right columns from th…
kz930 Oct 13, 2026
72380df
Merge upstream/main into feat/standalone-relational-and-sampling
kz930 Oct 13, 2026
fa07950
fix(workflow-operator): let a NaN key match nothing in Hash Join, as …
kz930 Oct 14, 2026
c59c02f
fix(operator): compare interval join timestamps by the millisecond as…
kz930 Oct 14, 2026
f4004e8
fix(operator): add Interval Join's constant in the key's declared typ…
kz930 Oct 15, 2026
2036f15
fix(operator): add Interval Join's time interval on the machine's wal…
kz930 Oct 15, 2026
b9620be
fix(operator): add Interval Join's interval to the wall clock the scr…
kz930 Oct 23, 2026
16d13e1
feat(workflow-operator): keep the set and join helpers' notes out of …
kz930 Oct 23, 2026
d183c31
feat(workflow-operator): keep the sampling helper's notes out of the …
kz930 Oct 23, 2026
6856d50
feat(workflow-operator): read Interval Join's wall clocks with the sh…
kz930 Oct 25, 2026
770bbdc
feat(workflow-operator): match Hash Join's keys with the shared key c…
kz930 Oct 25, 2026
24d5d9b
feat(workflow-operator): keep Interval Join's exported Python free of…
kz930 Oct 25, 2026
9d7468d
feat(workflow-operator): select the set operators' rows with the shar…
kz930 Oct 26, 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 @@ -40,6 +40,9 @@ trait EndChannelHandler {
val portId = dp.inputGateway.getChannel(channelId).getPortId
dp.inputManager.getPort(portId).completed = true
dp.inputManager.initBatch(channelId, Array.empty)
// Before the operator finishes the port, so one that received no rows on
// it can still say what its columns were.
dp.executor.inputSchemas(portId.id) = dp.inputManager.getPort(portId).schema
try {
// The executor finishes: a setting that refers to loop variables must have them by now.
dp.bindStateReferences()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,18 @@ class EndChannelHandlerSpec extends AnyFlatSpec {
assert(executor.finishOutputPorts.toList == List(currentPortId.id))
}

it should "give the operator the finished port's declared schema before it finishes the port" in {
// A HashJoin probe worker that received no build rows names its unmatched right rows from
// this, so it has to be in place when the operator is asked to finish, not after.
val executor = new RecordingExecutor()
val fixture = new Fixture(executor, otherInputPortCompleted = false)

fixture.endChannel()

assert(executor.schemasAtFinish.toList == List(Map(currentPortId.id -> schema)))
assert(!executor.inputSchemas.contains(otherPortId.id))
}

it should "emit the boundary state the operator produced on finish, with no loop envelope" in {
val state = State(Map("boundary" -> "end"))
val fixture = new Fixture(new RecordingExecutor(onFinishState = _ => Some(state)))
Expand Down Expand Up @@ -464,8 +476,12 @@ object EndChannelHandlerSpec {
/** Ports handed to `onFinish`, i.e. what `onFinishMultiPort` forwarded. */
val finishOutputPorts: ArrayBuffer[Int] = ArrayBuffer()

/** `inputSchemas` as the first finish callback saw it. */
val schemasAtFinish: ArrayBuffer[Map[Int, Schema]] = ArrayBuffer()

override def produceStateOnFinish(port: Int): Option[State] = {
statePorts += port
schemasAtFinish += inputSchemas.toMap
onFinishState(port)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ package org.apache.texera.amber.core.executor

import com.google.common.collect.MapMaker
import org.apache.texera.amber.core.state.State
import org.apache.texera.amber.core.tuple.{Tuple, TupleLike}
import org.apache.texera.amber.core.tuple.{Schema, Tuple, TupleLike}
import org.apache.texera.amber.core.workflow.PortIdentity

import scala.collection.mutable

/**
* Inside a loop block a setting may refer to a loop variable (`$i`): the worker writes each
* iteration's value into the settings object the executor holds, in place (`registerState`). For
Expand Down Expand Up @@ -91,6 +93,17 @@ trait OperatorExecutor {
*/
final def bindStateReferences(): Unit =
Option(OperatorExecutor.bindings.get(this)).foreach(_.bind())

/**
* What each finished input port was declared to carry, keyed by port index
* and written by the runtime before `onFinish` for that port.
*
* The tuples say this too, so an operator only needs to ask when there were
* none: a port can finish having carried no rows, and its schema is then the
* only record of what its columns were.
*/
final def inputSchemas: mutable.Map[Int, Schema] =
OperatorExecutor.inputSchemas.computeIfAbsent(this, _ => mutable.Map())
}

object OperatorExecutor {
Expand All @@ -100,4 +113,6 @@ object OperatorExecutor {
private val states = new MapMaker().weakKeys().makeMap[OperatorExecutor, State]()
private[executor] val bindings =
new MapMaker().weakKeys().makeMap[OperatorExecutor, StateReferenceBinding]()
private val inputSchemas =
new MapMaker().weakKeys().makeMap[OperatorExecutor, mutable.Map[Int, Schema]]()
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,12 @@ package org.apache.texera.amber.operator
object SamplingHelpers {

/**
* A Python transcription of `java.util.Random`, for operators whose executor
* draws from one.
*
* A sampler decides per row whether to keep it, so which rows survive is
* fixed by the exact sequence the generator produces. Seeding Python's
* `random` or numpy's with the engine's seed selects a different set, and
* the script would then report a different sample than the workflow it came
* from. Only the same generator gives the same rows.
* `_TexeraJavaRandom`: `java.util.Random` in Python, so a sampler keeps the rows the engine
* keeps. Python's or numpy's generator with the same seed picks other rows. `next_int`
* rejects a draw where Java's int sum overflows, which Python's integers never do.
*/
val JavaRandom: String =
"""# java.util.Random, transcribed so sampling matches the engine.
|class _TexeraJavaRandom:
"""class _TexeraJavaRandom:
| _MASK = (1 << 48) - 1
| _MULTIPLIER = 0x5DEECE66D
| _ADDEND = 0xB
Expand All @@ -60,8 +54,6 @@ object SamplingHelpers {
| while True:
| bits = self._next(31)
| value = bits % bound
| # Java rejects a draw by letting this sum overflow an int.
| # Python would carry it and never reject.
| probe = bits - value + (bound - 1)
| if ((probe + (1 << 31)) % (1 << 32)) - (1 << 31) >= 0:
| return value""".stripMargin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import org.apache.texera.amber.core.executor.OpExecWithClassName
import org.apache.texera.amber.core.tuple.{Attribute, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}

class CartesianProductOpDesc extends LogicalOp {
class CartesianProductOpDesc extends LogicalOp with StandaloneCodeGenerator {

override def getPhysicalOp(
workflowId: WorkflowIdentity,
executionId: ExecutionIdentity
Expand Down Expand Up @@ -103,4 +104,27 @@ class CartesianProductOpDesc extends LogicalOp {
),
outputPorts = List(OutputPort())
)

// Schema mirrors SchemaPropagationFunc: left columns kept as-is, each right
// column renamed by repeatedly appending "#@1" while the candidate name
// collides with any left column OR any other right column's ORIGINAL name.
// The renamed-name table is recomputed at runtime from the actual DataFrame
// columns. Known divergence: row order — pandas cross-merge varies right
// fastest (L1R1, L1R2, L2R1, L2R2); the JVM op buffers left and emits per
// arriving right tuple (L1R1, L2R1, L1R2, L2R2). Cartesian product is set-
// semantically order-agnostic, so this is acceptable.
override def generateStandaloneCode(): String = {
"""_left_cols = list(in1df.columns)
|_right_cols = list(in2df.columns)
|_left_set = set(_left_cols)
|_right_set = set(_right_cols)
|_rename = {}
|for _col in _right_cols:
| _new = _col
| _others = _right_set - {_col}
| while _new in _left_set or _new in _others:
| _new = _new + "#@1"
| _rename[_col] = _new
|out1df = in1df.merge(in2df.rename(columns=_rename), how="cross").reset_index(drop=True)""".stripMargin
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ import com.google.common.base.Preconditions
import org.apache.texera.amber.core.executor.OpExecWithClassName
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator, StandaloneHelpers}

class DifferenceOpDesc extends LogicalOp {

class DifferenceOpDesc extends LogicalOp with StandaloneCodeGenerator {
override def getPhysicalOp(
workflowId: WorkflowIdentity,
executionId: ExecutionIdentity
Expand Down Expand Up @@ -61,4 +60,13 @@ class DifferenceOpDesc extends LogicalOp {
),
outputPorts = List(OutputPort(blocking = true))
)

// Distinct left (port 0) rows the right (port 1) does not hold, by the
// engine's row equality, which `_texera_key_codes` with `signed` numbers.
override def generateStandaloneCode(): String =
"""_texera_set_left, _texera_set_right = _texera_key_codes(in1df, in2df, signed=True)
|_texera_set_kept = ~_texera_set_left.duplicated() & ~_texera_set_left.isin(_texera_set_right)
|out1df = in1df[_texera_set_kept.to_numpy()].reset_index(drop=True)""".stripMargin

override def standaloneHelpers(): Seq[String] = Seq(StandaloneHelpers.AttributeCasts)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,21 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
import org.apache.texera.amber.core.executor.OpExecWithClassName
import org.apache.texera.amber.core.state.NoLoopVariable
import org.apache.texera.amber.core.tuple.{Attribute, Schema}
import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{
ExecutionIdentity,
PhysicalOpIdentity,
WorkflowIdentity
}
import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator, StandaloneHelpers}
import org.apache.texera.amber.operator.hashJoin.HashJoinOpDesc.HASH_JOIN_INTERNAL_KEY_NAME
import org.apache.texera.amber.operator.metadata.annotations.{
AutofillAttributeName,
AutofillAttributeNameOnPort1
}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

object HashJoinOpDesc {
Expand All @@ -54,8 +55,7 @@ object HashJoinOpDesc {
}
}
""")
class HashJoinOpDesc[K] extends LogicalOp {

class HashJoinOpDesc[K] extends LogicalOp with StandaloneCodeGenerator {
@JsonProperty(required = true)
@JsonSchemaTitle("Left Input Attribute")
@JsonPropertyDescription("attribute to be joined on the Left Input")
Expand Down Expand Up @@ -158,19 +158,19 @@ class HashJoinOpDesc[K] extends LogicalOp {
buildSchema.getAttributes.filterNot(_.getName == HASH_JOIN_INTERNAL_KEY_NAME)
val leftAttributeNames = leftAttributes.map(_.getName).toSet

// Filter and rename attributes from the probe schema to avoid conflicts
// Filter and rename attributes from the probe schema to avoid conflicts,
// by the rule the executor names its fields with. Checking the left
// side alone declared two columns under one name when the right side
// already carried the suffixed one, and the row, matched to the schema
// by name, lost the value the executor had put under the other name.
val rightNames = probeSchema.getAttributeNames.filterNot(_ == probeAttributeName)
val rightAttributes = probeSchema.getAttributes
.filterNot(_.getName == probeAttributeName)
.map { attr =>
var newName = attr.getName
while (leftAttributeNames.contains(newName)) {
val suffixIndex = """#@(\d+)$""".r
.findFirstMatchIn(newName)
.map(_.group(1).toInt + 1)
.getOrElse(1)
newName = s"${attr.getName}#@$suffixIndex"
}
new Attribute(newName, attr.getType)
new Attribute(
JoinUtils.renamed(attr.getName, leftAttributeNames.toSeq, rightNames),
attr.getType
)
}

// Combine left and right attributes into a new schema
Expand Down Expand Up @@ -203,4 +203,131 @@ class HashJoinOpDesc[K] extends LogicalOp {
),
outputPorts = List(OutputPort())
)

// Equi-join: the probe key is dropped and a colliding right column takes
// "#@1", as JoinUtils names it. Rows come out in the merge's order rather
// than the engine's.
/** Only the declared type can say which columns an outer join widened, so
* without a schema the widening stands.
*/
override def generateStandaloneCode(inputSchemas: Map[PortIdentity, Schema]): String = {
val integral = (a: Attribute) =>
a.getType == AttributeType.INTEGER || a.getType == AttributeType.LONG
val declaredIntegers = (port: PortIdentity) =>
inputSchemas.get(port).map(_.getAttributes.filter(integral).map(_.getName)).getOrElse(List())
// Each side keeps its own list, under the names that side's frame carries,
// so a column the merge renames is still the column its own schema typed.
if (joinType == JoinType.INNER) generateStandaloneCode()
else
generateStandaloneCode(
declaredIntegers(operatorInfo.inputPorts.head.id),
declaredIntegers(operatorInfo.inputPorts.last.id)
)
}

override def generateStandaloneCode(): String =
generateStandaloneCode(List(), List())

override def standaloneHelpers(): Seq[String] = Seq(StandaloneHelpers.AttributeCasts)

private def generateStandaloneCode(
leftIntegerColumns: List[String],
rightIntegerColumns: List[String]
): String = {
val buildKeyLit = objectMapper.writeValueAsString(buildAttributeName)
val probeKeyLit = objectMapper.writeValueAsString(probeAttributeName)
val how = joinType match {
case JoinType.INNER => "inner"
case JoinType.LEFT_OUTER => "left"
case JoinType.RIGHT_OUTER => "right"
case JoinType.FULL_OUTER => "outer"
}
// An unmatched row leaves a hole, and a hole costs a pandas integer column
// its type: int64 becomes float64, which rounds every value past 2^53
// before anything can put the type back. The engine writes a null and
// leaves the column INTEGER, so widen to the integer dtype that holds a
// hole before the merge digs one.
val widen = leftIntegerColumns.nonEmpty || rightIntegerColumns.nonEmpty
val namesLit = (names: List[String]) => names.map(pyStringLiteral).mkString("[", ", ", "]")
val widening =
if (!widen) ""
else
s"""_left_ints = {_c: "Int64" for _c in ${namesLit(leftIntegerColumns)} if _c in in1df.columns}
|_right_ints = {_c: "Int64" for _c in ${namesLit(rightIntegerColumns)} if _c in in2df.columns}
|""".stripMargin
val leftFrame = if (widen) "in1df.astype(_left_ints)" else "in1df"
// Cast before the rename, because the names above are the ones the right
// input declared.
val rightFrame =
if (widen) "in2df.astype(_right_ints).rename(columns=_rename)"
else "in2df.rename(columns=_rename)"
// HashJoinProbeOpExec's rename, written out: append "#@1" until the name is
// free. pandas' `suffixes` appends once and then refuses the duplicate it
// just made.
//
// JoinUtils sets the probe key aside before it renames anything, so a
// payload column never has to step around the key that is about to leave.
// The key itself still rides along, and it only has to land where nothing
// else already sits.
//
// The merge joins on codes and not on the keys. The probe looks each key up
// in a HashMap under Scala's ==, where a NaN finds nothing, not even another
// NaN, while 0.0 meets -0.0 and a null meets a null; merge pairs NaN with
// NaN. `_texera_key_codes` from StandaloneHelpers.AttributeCasts gives each
// NaN a code of its own.
val merge =
s"""_left_cols = set(in1df.columns)
|_right_cols = list(in2df.columns)
|_payload = [_c for _c in _right_cols if _c != $probeKeyLit]
|_payload_set = set(_payload)
|_rename = {}
|for _col in _payload:
| _new = _col
| _others = _payload_set - {_col}
| while _new in _left_cols or _new in _others:
| _new = _new + "#@1"
| _rename[_col] = _new
|_probe_key = $probeKeyLit
|_taken = _left_cols | set(_rename.values())
|while _probe_key in _taken:
| _probe_key = _probe_key + "#@1"
|if _probe_key != $probeKeyLit:
| _rename[$probeKeyLit] = _probe_key
|_join_key = "_texera_join_key"
|while _join_key in _taken or _join_key == _probe_key:
| _join_key = _join_key + "_"
|_left_codes, _right_codes = _texera_key_codes(in1df[[$buildKeyLit]], in2df[[$probeKeyLit]])
|""".stripMargin + widening +
s"""out1df = $leftFrame.assign(**{_join_key: _left_codes}).merge(
| $rightFrame.assign(**{_join_key: _right_codes}),
| how=${pyStringLiteral(how)},
| on=_join_key,
|).drop(columns=[_join_key])""".stripMargin
// `_probe_key` is whatever the rename settled on, so this drops the right
// column rather than a left one that happened to share its name. Asked of
// the name the rename produced and not of the two the operator was given:
// when both sides name the key alike the rename still moves the right one
// aside, and a test on the operator's own two names read that as one shared
// column and kept the copy, where the engine emits the key once.
//
// Where both sides name the key alike, the engine's unmatched right row
// fills the left key column with its own key. The merge left that column
// empty and put the key in the renamed right one, which is about to go.
// A matched row holds the same value in both, so filling the holes changes
// only the rows the right side alone brought.
val rightRowsKept = joinType == JoinType.RIGHT_OUTER || joinType == JoinType.FULL_OUTER
val fillKey =
if (rightRowsKept && buildAttributeName == probeAttributeName)
s"""out1df[$buildKeyLit] = out1df[$buildKeyLit].where(
| out1df[$buildKeyLit].notna(), out1df[_probe_key]
|)
|""".stripMargin
else ""
val tail =
fillKey +
s"""if _probe_key != $buildKeyLit:
| out1df = out1df.drop(columns=[_probe_key])
|out1df = out1df.reset_index(drop=True)""".stripMargin
s"$merge\n$tail"
}
}
Loading
Loading