Repository navigation
feat(workflow-operator): let Filter combine predicates with AND - #8484
gupta-sahil01 wants to merge 1 commit into
Conversation
The Filter operator hard-coded OR semantics, so users needing AND had to chain multiple Filter operators. Add a PredicateCombinator enum exposed as a descriptor property, defaulting to OR so existing workflows are unaffected — a stored desc without the field deserializes to OR, so no migration is needed. Empty predicate lists keep dropping every tuple in both modes; a bare forall would have flipped that to passing everything.
Automated Reviewer SuggestionsBased on the
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8484 +/- ##
============================================
- Coverage 93.94% 93.94% -0.01%
- Complexity 4827 4833 +6
============================================
Files 1209 1210 +1
Lines 49705 49717 +12
Branches 6074 6075 +1
============================================
+ Hits 46695 46706 +11
Misses 1522 1522
- Partials 1488 1489 +1
*This pull request uses carry forward flags. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 419 | 0.256 | 24,036/31,032/31,032 us | 🔴 +14.7% / 🔴 +92.3% |
| 🟢 | bs=100 sw=10 sl=64 | 925 | 0.565 | 105,845/138,018/138,018 us | 🟢 -7.6% / 🔴 +27.0% |
| ⚪ | bs=1000 sw=10 sl=64 | 1,086 | 0.663 | 924,025/990,868/990,868 us | ⚪ within ±5% / 🟢 -7.6% |
Baseline details
Latest main ded7ba1 from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 419 tuples/sec | 453 tuples/sec | 755.28 tuples/sec | -7.5% | -44.5% |
| bs=10 sw=10 sl=64 | MB/s | 0.256 MB/s | 0.276 MB/s | 0.461 MB/s | -7.2% | -44.5% |
| bs=10 sw=10 sl=64 | p50 | 24,036 us | 20,949 us | 12,957 us | +14.7% | +85.5% |
| bs=10 sw=10 sl=64 | p95 | 31,032 us | 32,247 us | 16,134 us | -3.8% | +92.3% |
| bs=10 sw=10 sl=64 | p99 | 31,032 us | 32,247 us | 20,333 us | -3.8% | +52.6% |
| bs=100 sw=10 sl=64 | throughput | 925 tuples/sec | 950 tuples/sec | 980.1 tuples/sec | -2.6% | -5.6% |
| bs=100 sw=10 sl=64 | MB/s | 0.565 MB/s | 0.58 MB/s | 0.598 MB/s | -2.6% | -5.6% |
| bs=100 sw=10 sl=64 | p50 | 105,845 us | 103,451 us | 101,894 us | +2.3% | +3.9% |
| bs=100 sw=10 sl=64 | p95 | 138,018 us | 149,307 us | 108,718 us | -7.6% | +27.0% |
| bs=100 sw=10 sl=64 | p99 | 138,018 us | 149,307 us | 122,482 us | -7.6% | +12.7% |
| bs=1000 sw=10 sl=64 | throughput | 1,086 tuples/sec | 1,101 tuples/sec | 1,011 tuples/sec | -1.4% | +7.4% |
| bs=1000 sw=10 sl=64 | MB/s | 0.663 MB/s | 0.672 MB/s | 0.617 MB/s | -1.3% | +7.4% |
| bs=1000 sw=10 sl=64 | p50 | 924,025 us | 905,258 us | 996,422 us | +2.1% | -7.3% |
| bs=1000 sw=10 sl=64 | p95 | 990,868 us | 986,338 us | 1,037,670 us | +0.5% | -4.5% |
| bs=1000 sw=10 sl=64 | p99 | 990,868 us | 986,338 us | 1,072,152 us | +0.5% | -7.6% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,477.77,200,128000,419,0.256,24036.19,31031.70,31031.70
1,100,10,64,20,2161.79,2000,1280000,925,0.565,105845.27,138018.28,138018.28
2,1000,10,64,20,18413.24,20000,12800000,1086,0.663,924025.35,990867.74,990867.74|
/request-review @parshimers |
|
/request-review @xuang7 |
### What changes were proposed in this PR? Adds a check to `pr-assignment.yml` that flags a PR closing an issue someone else has already claimed. It also stops the merge-time credit step from unassigning a claimant whose own PR is still open. ``` Before: apache#8267 open (claimant) ── apache#8339 "Closes apache#8149" ── (silence) ── apache#8339 merges ── claimant unassigned from apache#8149, apache#8267 left with 0 changed files After: apache#8267 open (claimant) ── apache#8339 "Closes apache#8149" ── comment on apache#8339 names the claimant + apache#8267 ── apache#8339 merges ── claimant stays assigned while apache#8267 is open ``` | Where | Change | | --- | --- | | New step "Flag linked issues someone else has claimed" (PR `opened` / `edited`) | For each open same-repo issue in `closingIssuesReferences`, flags it when **(a)** it has an assignee other than the PR opener, or **(b)** its `closedByPullRequestsReferences` lists another `OPEN` PR by a different author. Posts or updates one `<!-- texera:issue-claim-conflict -->` comment with an issue / claimant / PR table, and deletes the comment once nothing matches. If overlapping runs each post a copy, every run keeps the oldest and deletes the others, so they end up keeping the same comment (`claimCommentChanges`). A run can also read the PR before an edit and write after a newer run has finished, so after any write it reads the PR again and repeats until a pass changes nothing (`syncClaimComment`, at most 3 passes). The last run to write is then also the last to read, and it corrects a stale write. | | "Credit issue assignees on PR merge" | Keeps an assignee who has their own open PR on the issue. If that PR later closes without merging, the existing close-without-merge step still unassigns them. | | `.github/scripts/issue-claims.js` | The decision logic, which both steps `require`. It is checked out from the base branch, which is safe under `pull_request_target`. The workflow now declares `contents: read` for that checkout. | **(b) is needed on its own.** GitHub silently drops the opener self-assign for an outside contributor who never commented on the issue. For example, apache#6674 shows no assignee but has the open PR apache#8603. **It posts a comment and does not fail a check:** | Reason | Detail | | --- | --- | | Both PRs match | The claimant's own PR matches the rule too: apache#8267 would flag apache#8339 back. A failing check would also land on the person the rule protects. | | Some takeovers are agreed | Only a person can tell a duplicate from an agreed takeover. One of the 2 PRs it would flag today (below), apache#8484, is an agreed takeover, and a failing check would block it. | | It would go stale | The check runs again only when this PR is opened or edited. Closing the other PR or unassigning someone doesn't re-run it, so a failed check would stay red until someone edits the PR. | On its first post, the comment @-mentions the claimant, so they get a notification. The apache#8149 claimant never got one. Closed issues are skipped, because the risk is two PRs in progress at once. Both queries read up to 100 linked PRs per issue. That is the GraphQL maximum; no issue in this repo has more than 5. There is no concurrency group: with the default queue, a group cancels a waiting run, and the body-edit unassign and the merge credit need every event. One known limitation: the comment is refreshed only when this PR is edited, not when the other PR closes. ### Any related issues, documentation, discussions? Closes apache#8676 Complements apache#8675, which adds an AGENTS.md rule to check claims before coding. This PR catches the cases that rule misses. ### How was this PR tested? Unit tests: 32 pass. Two of them replay the apache#8149 and apache#6674 incidents as GraphQL fixtures. ```bash bash .github/scripts/test_issue_claims.sh # tests 42, pass 42, fail 0 ``` The `infra` job picks these up through its `test_*.sh` discovery. Mutation check: reverting the credit fix fails the apache#8149 merge test, and dropping (b) fails the apache#6674 test. For `claimCommentChanges`, removing any one of the id sort, the Bot-only filter, the deletion of extra copies or the unchanged-body check fails at least one test. For `syncClaimComment`, limiting it to one pass fails both delayed-run tests. `pull_request_target` runs the workflow from `main`, so this PR can't run its own steps. Instead, I ran both edited `github-script` steps, extracted from the YAML, in a local harness. GraphQL calls went to the live API (read-only). Every REST write was recorded, not sent. | Scenario | Result | | --- | --- | | apache#8339 opened, fixture = apache#8149 as of 2026-09-02 | posts 1 comment: `apache#8149 · <claimant> (assignee) · apache#8267` | | edited, with an older bot comment and a human comment quoting the marker | updates only the bot comment | | edited again, comment already current | no write | | edited after `Closes apache#8149` is removed | deletes the bot comment; the human comment is left alone | | live: apache#8603 opened (apache#6674) | no conflict, no writes | | live: apache#8484 edited | posts 1 comment naming the apache#6939 assignee | | credit step, fixture = apache#8339's merge on 2026-09-25 | apache#8149 keeps its claimant (`kept (own open PR)=[…]`); an assignee without a PR on apache#8338 is still removed | | credit step, live query on apache#8339 | query valid, no writes | | race: this run posts, then its re-list shows an older copy from another run | deletes its own copy (and first updates the older one if it is stale) | | re-list after posting fails, or a delete returns 404 | logs a warning and stops; a 404 skips only that one delete | | edited, with two copies left by an earlier race | updates the oldest, deletes the other | | delayed run: reads the claim, a newer run removes it, then the delayed run posts | its re-read finds nothing to flag and deletes its own post | | delayed run: writes old text over a newer run's comment | its re-read restores the newer text | | live: guard and credit queries with `first: 100` | both valid against the schema | I also modelled two racing runs against one shared comment list and tried every ordering of their API calls, using the real `claimCommentChanges`. Whenever both runs see the same state, exactly one up-to-date comment is left; 104 orderings for an open followed by a quick edit. When both runs find nothing to flag, no comment is left. Dry run over every open PR: 218 open, 188 with closing references, **2 flagged**. Both are real claims: | PR | Issue | Claim | | --- | --- | --- | | apache#5558 (draft) | apache#5422 | the issue's author assigned themselves | | apache#8484 | apache#6939 | a `/take` that was never released; a maintainer later told apache#8484's author to go ahead | ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Any updates here @aglinxinyuan ? |
aglinxinyuan
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The enum, the executor change and the empty-list guard look right, and defaulting to OR is the right call. Two things need fixing before this merges; smaller notes are inline.
-
After the rebase, Python export ignores AND. Since this branched, #8502 added
generateStandaloneCode()toSpecializedFilterOpDesc. It joins the predicates with" | ". That file merges cleanly with your change; onlySpecializedFilterOpDescSpecconflicts. So once rebased, an AND Filter still exports as OR:age > 30ANDcountry = "US"comes out as(…age > 30) | (…country == "US"), which keeps rows matching either predicate. Please:- join on the combinator:
val joiner = if (predicateCombinator == PredicateCombinator.AND) " & " else " | " s"out1df = in1df[${conditions.mkString(joiner)}].reset_index(drop=True)"
- fix the two comments that say the executor uses
predicates.exists, one in the descriptor and one in its spec; - add a two-predicate AND export test.
When you resolve the spec conflict, keep both sides.
- join on the combinator:
-
required = truemakes saved Filters invalid. See the inline comment. "No migration needed" holds for the backend, but not for the frontend validator.
Also, docs/reference/operators/data-cleaning/_index.md:42 still says Filter uses OR only.
| @JsonProperty(value = "predicates", required = true) | ||
| @JsonPropertyDescription("multiple predicates in OR") | ||
| @JsonPropertyDescription("multiple predicates to be combined") | ||
| var predicates: List[FilterPredicate] = List.empty | ||
|
|
||
| @JsonProperty(required = true, defaultValue = "any (OR)") | ||
| @JsonSchemaTitle("Combine Predicates With") | ||
| @JsonPropertyDescription("how to combine multiple predicates") | ||
| var predicateCombinator: PredicateCombinator = PredicateCombinator.OR |
There was a problem hiding this comment.
Two things here:
required = trueputspredicateCombinatorin the schema'srequiredlist.- Workflows saved before this PR don't have that key, and nothing fills in schema defaults when a workflow loads.
- The frontend validator then rejects every existing Filter: Run shows "Invalid Workflow" until someone opens each Filter's property panel, and Form View readers can't fix it at all.
- Projection's
isDropand Keyword Search'sisCaseSensitivewent in the same way, but here it's free to avoid. Making it optional keeps the default and the preselected dropdown, likeHistogram2DOpDesc's normalization property does.
- Could you declare it above
predicates, as Add option to combine Filter operator predicates with AND instead of only OR #6939 proposed? Right now the dropdown renders below every predicate row and the add button, so with several predicates you can't see which mode applies.
| @JsonProperty(value = "predicates", required = true) | |
| @JsonPropertyDescription("multiple predicates in OR") | |
| @JsonPropertyDescription("multiple predicates to be combined") | |
| var predicates: List[FilterPredicate] = List.empty | |
| @JsonProperty(required = true, defaultValue = "any (OR)") | |
| @JsonSchemaTitle("Combine Predicates With") | |
| @JsonPropertyDescription("how to combine multiple predicates") | |
| var predicateCombinator: PredicateCombinator = PredicateCombinator.OR | |
| @JsonProperty(required = false, defaultValue = "any (OR)") | |
| @JsonSchemaTitle("Combine Predicates With") | |
| @JsonPropertyDescription("how to combine multiple predicates") | |
| var predicateCombinator: PredicateCombinator = PredicateCombinator.OR | |
| @JsonProperty(value = "predicates", required = true) | |
| @JsonPropertyDescription("multiple predicates to be combined") | |
| var predicates: List[FilterPredicate] = List.empty |
| setFilterFunc((tuple: Tuple) => | ||
| desc.predicateCombinator match { | ||
| case PredicateCombinator.AND => | ||
| desc.predicates.nonEmpty && desc.predicates.forall(_.evaluate(tuple)) | ||
| case _ => desc.predicates.exists(_.evaluate(tuple)) | ||
| } | ||
| ) |
There was a problem hiding this comment.
Nit: with case _, a future third combinator would silently run as OR. Naming OR explicitly gets a compiler warning instead, and choosing the function once avoids re-matching on every tuple. No null case is needed: getPhysicalOp serializes without nulls, so the executor always gets the OR default.
| setFilterFunc((tuple: Tuple) => | |
| desc.predicateCombinator match { | |
| case PredicateCombinator.AND => | |
| desc.predicates.nonEmpty && desc.predicates.forall(_.evaluate(tuple)) | |
| case _ => desc.predicates.exists(_.evaluate(tuple)) | |
| } | |
| ) | |
| setFilterFunc(desc.predicateCombinator match { | |
| case PredicateCombinator.AND => | |
| (tuple: Tuple) => desc.predicates.nonEmpty && desc.predicates.forall(_.evaluate(tuple)) | |
| case PredicateCombinator.OR => | |
| (tuple: Tuple) => desc.predicates.exists(_.evaluate(tuple)) | |
| }) |
| opExec.close() | ||
| } | ||
|
|
||
| it should "drop a tuple under AND when only one predicate matches" in { |
There was a problem hiding this comment.
Every AND case puts int = 0 first, and that predicate is true for nonNullTuple. So no test shows the first predicate can reject a row under AND: predicates.tail.forall(...) or predicates.last.evaluate(...) would pass the whole suite. Could you add the same pair in the other order?
it should "drop a tuple under AND when the first predicate fails" in {
opDesc.predicates = List(
new FilterPredicate("string", ComparisonType.EQUAL_TO, "world"),
new FilterPredicate("int", ComparisonType.EQUAL_TO, "0")
)
opDesc.predicateCombinator = PredicateCombinator.AND
val opExec = new SpecializedFilterOpExec(objectMapper.writeValueAsString(opDesc))
opExec.open()
assert(opExec.processTuple(nonNullTuple, inputPort).isEmpty)
opExec.close()
}Minor: line 148 does nothing, because the before hook already resets the combinator to OR.
| restored.asInstanceOf[SpecializedFilterOpDesc].predicates shouldBe empty | ||
| } | ||
|
|
||
| "SpecializedFilterOpDesc" should |
There was a problem hiding this comment.
Could you also test bad input at this level? Two cases:
"predicateCombinator": "xor"read asLogicalOpshould fail with aValueInstantiationExceptionthat names the field.- An executor built from JSON with
"predicateCombinator": nullshould behave as OR.
AGENTS.md's test rules ask for bad-input coverage on user input.
| return this.name; | ||
| } | ||
|
|
||
| // Handle custom deserialization for enum |
There was a problem hiding this comment.
Optional: Jackson already reads a @JsonValue enum back from its value, and most enums in this module (JoinType, for example) don't have this creator. Fine to keep if you'd rather match ComparisonType.
| | ↳ Attribute | ✓ | String | - | | | ||
| | ↳ Condition | ✓ | =, >, >=, <, <=, !=, is null,<br>is not null | - | | | ||
| | ↳ Value | | String | - | | | ||
| | Combine Predicates With | ✓ | any (OR), all (AND) | any (OR) | How to combine multiple predicates | |
There was a problem hiding this comment.
If the property becomes optional, drop the ✓, and if it moves above Predicates, move this row up too. Also worth one line saying that "all (AND)" with no predicates keeps no rows, since most people expect "all" of nothing to keep everything.
What changes were proposed in this PR?
The
Filteroperator hard-codes OR semantics — a tuple passes as soon as any onepredicate matches. Users who need AND (e.g.
age > 30 AND country = "US") have tochain multiple
Filteroperators, which adds workflow clutter and an extra hop percondition.
This PR makes the combination configurable:
PredicateCombinatorenum (OR→"any (OR)",AND→"all (AND)"),following the
@JsonValue/@JsonCreatorpattern already used byComparisonTypein the same package.predicateCombinatorproperty onSpecializedFilterOpDesc, defaulting toOR. The UI dropdown renders automatically from the JSON schema, so there are nofrontend changes.
SpecializedFilterOpExecnow selectsforallfor AND andexistsfor OR.Backward compatibility: the default is
OR, and a stored descriptor without thefield deserializes to
OR, so existing workflows behave exactly as before and nomigration is needed.
One behavioral note for reviewers: an empty predicate list continues to drop every
tuple in both modes. A bare
forallreturnstrueon an empty list, which wouldhave silently flipped an empty Filter from "drop everything" to "pass everything", so
the AND branch is guarded with
predicates.nonEmpty &&. There is a test covering this.Nested predicate trees (e.g.
(A AND B) OR C) are intentionally out of scope — theyneed more extensive UI work and are noted as a follow-up in the issue.
Any related issues, documentation, discussions?
Closes #6939
Operator documentation updated in the same PR:
docs/reference/operators/data-cleaning/filter.md.How was this PR tested?
New and updated unit tests, all run locally via
sbt "WorkflowOperator/testOnly *filter*"— 67 tests across 7 suites, all passing.PredicateCombinatorSpec(new): wire names viagetName, case-insensitivefromString, rejection of an unknown name, and the Jackson round-trip.SpecializedFilterOpDescSpec: the property defaults toOR; a hand-writtendescriptor JSON with no
predicateCombinatorfield deserializes toOR(this is the backward-compatibility claim above); an explicit
ANDsurvives theround-trip through the polymorphic
LogicalOpbase;operatorInfoadvertises thenew behavior.
SpecializedFilterOpExecSpec: with two predicates where only one matches, ORkeeps the tuple and AND drops it; AND keeps it when both match; AND with an empty
predicate list drops everything; AND and OR agree on a single predicate. A
beforeblock resets the shared descriptor between tests so a mode set in onetest cannot leak into the ones after it.
The empty-list guard was additionally verified by mutation: removing
predicates.nonEmpty &&from the executor makes exactly one test fail, confirmingthe case is genuinely covered rather than incidentally passing.
Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)