Project

General

Profile

Bug #10806

Presort queries can sometimes dynamically find records created after opening the query

Added by Alexandru Lungu 11 months ago. Updated 1 day ago.

Status:
Review
Priority:
Normal
Target version:
-
Start date:
Due date:
% Done:

90%

billable:
No
vendor_id:
GCD
case_num:
version_reported:
version_resolved:
production:
No
env_name:
topics:

History

#1 Updated by Alexandru Lungu 11 months ago

Part of #10737, I discovered that BY clause index overlapping resolution is influenced by WHERE clause. This happens for any kind of query.
For presort however, the regression tests shown that FWD trunk doesn't properly find new records after OPEN QUERY, which apparently is possible. The scenario is rather limited in the sense that it happens only if the BY clauses partially overlap an index, but the WHERE clause ensures that this is enough (with equality checks on leading components).

I committed testcases/1813 with tests/query/index/ByAndWherePresortTestCases.cls. Check with tests/query/index/ByAndWherePreselectTestCases.cls for non-presort cases that work fine in trunk.

#4 Updated by Artur Școlnic 26 days ago

  • Status changed from New to WIP
  • Assignee set to Artur Școlnic

#5 Updated by Artur Școlnic 25 days ago

  • Status changed from WIP to Review
  • reviewer Alexandru Lungu added

Summary

A presort query took a fixed, preselected snapshot of its results, so records created or changed after the query opened were never seen. This makes PresortQuery change-sensitive by inheriting AdaptiveQuery instead of PreselectQuery, so it can switch to dynamic retrieval when the session holds pending changes.

Three files, +91/-10.

The change

PresortQuery now extends AdaptiveQuery. Its createResults override became createHighLevelResults (see below), assembleOrderByClause was split so the body keeps its checked exception while the override matches AdaptiveQuery's signature, and executeQuery now asks for a preselected result set explicitly on the template-rowid path.

AdaptiveQuery gained one hook, createHighLevelResults, called from createResults in place of the inline super.createResults(adapter). The default implementation is exactly what it replaced, so AdaptiveFind and every other caller are unaffected; PresortQuery is the only override.

PreselectQuery gained executePreselected, split out of executeQuery, so a subclass whose parent executes lazily can still ask for a fully materialised result set. Java offers no way to reach a grandparent implementation directly.

Why the results wrapper moved

This is the part worth a close look. SortedResults materialises and sorts in its constructor and never re-reads its delegate. Building it per delegate change meant that once the query went dynamic, every next() rebuilt the presorted view over a cursor that had already moved, and rows were lost. Building it once per open, over the adapter rather than the delegate, is what AdaptiveQuery already does for its own results; the hook simply lets a subclass participate in that.

The wrapper is cached for the life of an open, and cleared on re-open through open() -> close() -> cleanup().

Testing

Patterns covered, in FWD and against an OpenEdge reference where the semantics were in question:

  • an uncommitted CREATE that matches the predicate, read from another buffer
  • an uncommitted UPDATE that moves a row into or out of the predicate
  • a pending change to a field the query SORTS on, and to a field it BREAKS on, where the row still matches the predicate so only its position and group can be wrong
  • re-opening a presort statement, which must rebuild the presorted set rather than replay the previous open
  • an existing corpus of BY/WHERE index-selection queries in both presort and preselect shapes, as a regression guard
Suite Trunk With change
Presort visibility (45 tests) 24 pass / 21 fail 44 pass / 1 fail
BY/WHERE index selection (368 tests) 351 pass / 17 fail 351 pass / 17 fail

The one remaining failure in the first suite fails on trunk as well and is out of scope here. The BY/WHERE failing-method set is identical to trunk, method for method. One test was added for the re-open case and passes, bringing that suite to 46.

Alex, please review the first iteration of the implementation in 10806a.

#6 Updated by Artur Școlnic 23 days ago

  • Status changed from Review to WIP

Will do a few more rounds of self review and generate more tests.

#7 Updated by Artur Școlnic 21 days ago

I keep finding failing testcases and fixing them, will post here when there are no issues to be solved.

#8 Updated by Artur Școlnic 21 days ago

OE testing is conclusive when it comes to the queries that are converted to FWD PresortQuerie. Result rows membership is additive, but never subtractive. Once resolved, nothing is removed, but new matches ahead of the cursor are picked up. That's not a preselected snapshot, and it's not a live cursor. It's a frozen-size list whose forward contents are reevaluated. The bottom line is that simply inheriting the behavior of AdaptiveQuery is not enough, I am working on matching it.

#9 Updated by Alexandru Lungu 21 days ago

Once resolved, nothing is removed, but new matches ahead of the cursor are picked up. That's not a preselected snapshot, and it's not a live cursor.

This sounds like the behavior of a scrolling query, that uses a Cursor. This is implemented for any DynamicQuery: AdaptiveQuery and CompoundQuery.

#10 Updated by Alexandru Lungu 21 days ago

Artur, please take a step back and do a design plan for this porting. The simple reroute of the extentds from PreselectQuery to AdaptiveQuery doesn't sound enough. There are >10 use-cases that are handled through the P2JQuery API and we should rather have them documented, before porting. I will provide some examples:

  • scrolling vs non-scrolling vs forward-only behavior
  • invalidation:
    • revalidation
    • cacheOnReval
    • cross-session invalidation due to foreign commits
    • dirty-share honoring including earlyInvalidation
  • results delegation (there are plenty of results, results-adapter, highlevelresults instances). We need to standardize this to be able to make room for PresortQuery
    • integration with ScrollingResults, ForwardResults, PagedResults or ProgressiveResults
  • FQL composition:
    • _multiplex consideration
    • breakValue consideration
    • sort order consideration and trailing recid
  • caching on session commit if result-set is forward only (due to same session-commit).
  • consideration of PresortCompoundQuery
  • locks, triggers, buffers loading, related buffer handling (OF syntax) and embedded buffers (embedded CAN-FIND)
  • projection queries vs full queries
  • plenty of code has instanceof AdaptiveQuery or similar.

This task doesn't resonate as a simple porting, but as a very careful surgery that requires a comprehensive plan and skilled surgeon.
Tests are most welcome, but please make them on groups. We know before hand what testing should tests and we can drive the AI to generate very quirky tests to capture revalidation, cacheOnReval and other things like this.

OE testing is conclusive when it comes to the queries that are converted to FWD PresortQuerie. Result rows membership is additive, but never subtractive. Once resolved, nothing is removed, but new matches ahead of the cursor are picked up. That's not a preselected snapshot, and it's not a live cursor. It's a frozen-size list whose forward contents are reevaluated. The bottom line is that simply inheriting the behavior of AdaptiveQuery is not enough, I am working on matching it.

I would have qualified this as the "scrolling" behavior. If not please ignore my comment.
If it is the "scrolling" behavior, than this should be leveraged from the AdaptiveQuery and not reimplemented in PresortQuery.
I would say that most of the bugs that occur in the process is a gap in linking PresortQuery to the AdaptiveQuery parent, as each functionality discovered is most probably already implemented in the super-class, but the sub-class fails to leverage it.

#11 Updated by Artur Școlnic 21 days ago

First I want to record the 4GL constructs that convert to PresortQuery and their measured behaviour.

4GL construct OE resolution OE change sensitive FWD trunk resolution FWD trunk change sensitive
BREAK BY <indexed field> walks the index, incremental yes materialised at open no
BREAK BY <fld1> BY <fld2> (= the index) walks the index, incremental yes materialised at open no
BREAK BY <unindexed field> materialised at open no materialised at open no
BY (<computed expression>) materialised at open no materialised at open no
BY <field> DESCENDING (against an ascending index) materialised at open no materialised at open no
BY <field> BY <field> (same field twice) materialised at open no incremental yes

Resolution is read from NUM-RESULTS sampled immediately after OPEN QUERY, before any fetch: equal to the row count means the set was materialised at the open, 0 means resolution is incremental. Change sensitive is whether a row committed after the open, sorting past the end of the set, is delivered by the continuing walk. Both post-open rows are committed, so no cell below depends on any dirty-share setting.

OpenEdge applies one rule: can the database produce this order by walking an index? If it can, the walk is incremental and picks up rows committed ahead of the point resolution has reached. If it cannot, an unindexed break field, a computed expression, a descending sort against an ascending index, a duplicated sort field, the set is materialised at the open and nothing committed afterwards is seen.

FWD trunk diverges in both directions.

  1. It materialises the two shapes OpenEdge walks lazily. That is #10806.
  2. It resolves BY <field> BY <field> incrementally and change-sensitively, where OpenEdge materialises it. This is the opposite divergence.

#12 Updated by Alexandru Lungu 21 days ago

I think OE change sensitive is matching the FOR EACH conversion decision on FWD (to AdaptiveQuery or PreselectQuery).
In other words, does it make sense to have PresortQuery still extend PreselectQuery and have a separate AdaptivePresortQuery that extends AdaptiveQuery?

#13 Updated by Teodor Gorghe 21 days ago

Or maybe something that fits in the Composite design pattern, or a Query which uses a delegate (switch between AdaptiveQuery and PreselectQuery, setDelegate).

#14 Updated by Artur Școlnic 21 days ago

After reanalyzing the tests, the table above has to be corrected.

4GL construct OE resolution OE change sensitive FWD trunk resolution FWD trunk change sensitive
BREAK BY <indexed field> walks the index, incremental yes materialised at open no
BREAK BY <fld1> BY <fld2> (= the index) walks the index, incremental yes materialised at open no
BREAK BY <unindexed field> materialised at open no materialised at open no
BY (<computed expression>) materialised at open no materialised at open no

The two deleted rows: BY <field> DESCENDING converts to PreselectQuery, and BY <field> BY <field> to AdaptiveQuery. Neither is a presort query, so neither belongs in a PresortQuery table.
What the corrected table says:
- FWD trunk is never change-sensitive for any presort construct.
- OpenEdge is change-sensitive for exactly the two whose order the database can produce by walking an index.
- The other two agree already.

So the design is now simpler.

#15 Updated by Artur Școlnic 21 days ago

1. The design

Two classes, chosen at conversion:

  • PresortQuery extends PreselectQuery — unchanged. Materialised and frozen. Already correct for BREAK BY <unindexed> and BY (computed).
  • AdaptivePresortQuery extends AdaptiveQuery — new. Incremental and change sensitive. For a BREAK BY whose order an index can produce.
  • Conversion picks between them with the discriminator it already computes: clientSort in index_selection.rules, plus the equality brackets that make a partial index match sound.
  • AdaptivePresortQuery adds only break-group support on top of AdaptiveQuery. Scrolling, invalidation, cursor and results handling all come from the parent, untouched.

2. Why the choice belongs at conversion time

  • A partial index match is sound only when the skipped leading sort components are constant — BREAK BY si2 under where si1 = ... works only because si1 is pinned by equality. If it varied, the delivered order would be wrong.
  • That bracket information exists at conversion and is not exposed at runtime. QueryComponent has no accessor for it.
  • The runtime already has a fork — createHighLevelResults chooses IncrementalResults or SortedResults — and it is mis-gated: sortCriteriaMatchSortPhrase compares both lists from index 0, so a criterion matching at offset 1 fails and the eager path is always taken. The runtime cannot currently make this call correctly with the information it has.

#16 Updated by Artur Școlnic 21 days ago

Existing coverage for PresortQuery, by subject

Slice tests/persistence/flushing_validation/query_shape_uncommitted_visibility — 34 classes, 125 methods. Trunk figures are from a run with the dirty-share nodes absent (defaults).
group what it targets methods red on trunk
Pending-change visibility Whether a presort read sees a change the same session has made but not committed — through another buffer, in or out of the predicate, on indexed, unindexed and unique fields, with and without a strong scope. The original subject of the task. 46 21
Post-open delivery Whether a row created or changed after OPEN QUERY is delivered by the continuing walk, and what NUM-RESULTS reports while that happens. 19 8
Result-list editing CREATE-RESULT-LIST-ENTRY and DELETE-RESULT-LIST-ENTRY: where the entry lands, whether the edit survives a reposition, a commit, and a backward or re-forward move. 19 1
Position and navigation CURRENT-RESULT-ROW against NUM-RESULTS after GET LAST, GET PREV and a re-forward move; REPOSITION-TO-ROW and REPOSITION-TO-ROWID, ordinal and indexed, single and multi-table. 21 0
Multi-table and lifetime Multi-table presort joins, break groups across an invalidation, a wide result set walked across a nested scope, a commit and an undone transaction. 12 0
Block semantics FOR FIRST / FOR LAST over a where clause matching nothing must skip the block silently rather than raise. 4 0
Cost Emitted measurements rather than thresholds: GET LAST cost, and how many times the sort expression is evaluated across a commit. 4 0
total   125 30

#17 Updated by Alexandru Lungu 21 days ago

Or maybe something that fits in the Composite design pattern, or a Query which uses a delegate (switch between AdaptiveQuery and PreselectQuery, setDelegate).

Do you mean emitting PresortQuery like we do a QueryWrapper to use a delegate? I think it makes sense - it is indeed composition.
However, I am not sure how tightly coupled this PresortQuery shall be. Will it simply proxy the next/prev/first/last/etc. API and build the groups on its own? I think this can work, but I think we are just scratching the surface now. I am not fully confident we see all the functionality or PresortQuery: LAST-OF, FIRST-OF and maybe other aggregates (e.g. ACCUMULATE and ACCUM?).

Two classes, chosen at conversion:

I am inclined to approve this approach. The composition would make sense if the presort implementation would have to share some behavior, but IMHO PresortQuery and AdaptivePresortQuery wouldn't duplicate much of the logic. Also, there might be different logics for these after all, even if we can't find one now, so I would prefer a more pessimistic approach now in order to provide clarity to conversion and have flexibility later on to change only PresortQuery or AdaptivePresortQuery.

#18 Updated by Alexandru Lungu 21 days ago

  • reviewer Constantin Asofiei, Ovidiu Maxiniuc added
  • reviewer deleted (Alexandru Lungu)

#20 Updated by Artur Școlnic 20 days ago

I committed the initial implementation of the plan above to 10806a, at this point I have only 9 failing tests, no regressions compared to trunk. Working on fixing them.

#21 Updated by Artur Școlnic 19 days ago

  • % Done changed from 0 to 50

The remaining failing tests are:

test 4GL shape query class fails because scope
testPostOpenDeleteOfUnvisitedRow FOR EACH … BREAK BY si2, 2 rows; the unvisited last row is deleted and committed mid-walk AdaptivePresortQuery OpenEdge skips past the dead entry, finds nothing behind it, and leaves the buffer holding the previous record (BBB,BBB, AVAILABLE yes); FWD releases the buffer and ends the walk (BBB) in — but the corner, not the rule; the skip rule itself is implemented and verified on four rows
testCreatedEntryLandsAtRepositionedRow BY (<computed expr>), 5 rows; GET LAST → REPOSITION-TO-ROW(2) → CREATE-RESULT-LIST-ENTRY PresortQuery (frozen) the create succeeds and NUM-RESULTS before it is right (5), but after it reads 0 instead of 6; the sibling arm without the reposition passes out — frozen path, restored to trunk form by this branch; result-list editing, not post-open visibility
testRepeatPreselect REPEAT PRESELECT EACH … WHERE si2 = "NEW" EXCLUSIVE-LOCK, after an uncommitted update through a second buffer PreselectQuery the preselect does not see the session's own uncommitted change — lFound = no out — no sort, no presorted layer; would fail identically with the branch removed

They also fail with trunk.
The branch is at rev 16767.

#22 Updated by Ovidiu Maxiniuc 19 days ago

  • % Done changed from 50 to 0

Review of 10806a / r16767

  • Presortable.java
    • line 11: missing H entry and (C) year update. When here, please upgrade the H header format to 110 chars/line (-T- column should also be dropped);
  • Presorter.java
    • lines 234-283: something does not seem fine here. First: at 238/240 it seems more natural to say: if (!criteriaMatchAt(criteria, offset)) return false; and return true;, at the end, if criteria from all offset passed. I could not fully understand the logic of criteriaMatchAt(): it iterates sortHelpers, and compare then with SortCriterion starting at offset?
    • line 1495: stray javadoc.
    • line 1525: method backingReplaced() is missing its javadoc. Is it the one from line 1495?
  • AdaptivePresortQuery
    • line 6: Copyright (c) should be 2026 only, -JPRM-- column should be dropped.

Note:
This is a complex update, involving both conversion and runtime. It is difficult for me to understand the reasons for some changes without seeing the code in action. So this is the result of first passes through the branch. I need to look again at Presorter and especially to the new AdaptivePresortQuery for more accurate review. Maybe use the AI for suggestions. I'm keeping the task to review state, but feel free to address the few issues I have already found.

#23 Updated by Ovidiu Maxiniuc 19 days ago

  • % Done changed from 0 to 50

This was not intended. Sorry.

#24 Updated by Artur Școlnic 19 days ago

All four addressed in 10806a.
  • Presorter.java 234-283 I didn't make this one, it flips the meaning. if (!criteriaMatchAt(...)) return false asks whether every offset matches; we need whether some does. BREAK BY si2 under WHERE si1 = ... on an index over (si1, si2) gives a 3-component phrase (si1, si2, PK tiebreaker) and one criterion si2, matching at offset 1 only inverted, offset 0 fails and we're back on the eager path.

But you were right that it doesn't read well. The bound is now a named lastOffset, there's a comment saying the criteria are a run to find rather than something every offset must satisfy, and criteriaMatchAt says what it walks: the sortHelpers keys in insertion order against criteria.get(offset) onward.

Working on fixing the last 'in scope' test.

#25 Updated by Artur Școlnic 16 days ago

  • Status changed from WIP to Review
  • reviewer Alexandru Lungu added

testPostOpenDeleteOfUnvisitedRow — fixed

Pattern. FOR EACH … BREAK BY si2 over two rows (BBB, CCC), converted to
AdaptivePresortQuery. BBB is consumed, then CCC — unvisited, and the last row — is
deleted and committed. OpenEdge does not advance past a run of deleted rows at the end
of the list: it hands back BBB once more (rows=2 seq=BBB,BBB, NUM-RESULTS 2) and
only the next move ends the walk. FWD advanced past the end and stopped (rows=1).

Measured across three shapes over the same fixture: a plain AdaptiveQuery and a frozen
PresortQuery both end the walk on OpenEdge, and FWD matched both. Only the
index-satisfiable presort diverged.

Fix. Not buffer lifetime — the record is re-fetched every move. It is the position.
In AdaptiveQuery.next's missing-record retry loop, when the skip exhausts the list,
restore the position to the row before it and deliver that row again:

  if (!avail && skipped && !deadEndDelivered && redeliverRowBeforeSkippedOffEnd() &&
      rowBefore != null && rowBefore >= 0 && results.setRowNumber(rowBefore))
  {
     avail = true;
     redelivered = true;
     deadEndDelivered = true;
  }

deadEndDelivered is a one-shot, so the following move ends the walk; the cursor entry
is suppressed on a re-delivery, so NUM-RESULTS stays at 2; and
redeliverRowBeforeSkippedOffEnd() defaults to false, overridden only in
AdaptivePresortQuery, leaving the frozen path untouched.

This was the last failing test, the final score is trunk 30/125 failures, 10806 2/125. The 2 remaining failures are also in trunk and are not in scope.

Alex or Ovidiu, please review 10806a/16769.

#26 Updated by Ovidiu Maxiniuc 15 days ago

I looked at 10806a/r16769.

This is basically a patch for a quirk. I remember something vaguely similar when reading a stream, if the last line does not finish with the line terminator (\n), the previous line is read twice before returning EOF, and the actual last line is never read.

So, I understand perfectly the need for this kind of handling exceptional cases. But, because of its nature:
  • it needs to be well tested to be sure we do not affect the normal execution of the query;
  • it must be well documented in the code. Luckily, the patch is localised and can be easily annotated with supplementary Java comments/javadoc.
Related to implementation:
  • AdaptiveQuery.java:
    • while the other new local flags used for identification of the exceptional situation are reinitialized each time the next() method is invoked, deadEndDelivered is not reset on open(), first(), etc which should normally invalidate this flag;
    • what about the mirrored case, when the result is navigated in reverse order (using previous())? How does the quirk manifest?

#27 Updated by Artur Școlnic 14 days ago

You were right about deadEndDelivered: it was only cleared by an ordinary forward delivery, so anything else that moved the query left it stale and a stale guard suppresses the re-delivery next time the same dead end is reached, which is the bug it exists to prevent, inverted. Added forgetDeadEnd() and call it from first, last, previous, both reposition variants and cleanup. The field javadoc now says what the guard means and why anything else invalidates it.

New tests proc_probe_dead_end_guard.p + TestDeadEndGuardResetsAndLeavesNormalWalksAlone,
every value measured on OpenEdge:

pattern OpenEdge branch
clean 3-row walk, no deletes AAA,BBB,CCC same
mid row deleted, live row behind it CCC same
dead end, pass 1 BBB same
dead end, pass 2 on a second open of the same query BBB same

The pass1/pass2 pair is the regression test for the guard same dead end twice, and a stale guard drops the second. The first two are the "normal execution untouched" check.

The reverse case. There isn't one. OpenEdge rejects backward navigation on a BREAK BY query outright: Cannot LAST or PREV Query when FORWARD-ONLY or -noautoreslist or BREAK BY enabled. (12393), and AdaptivePresortQuery is only emitted for BREAK BY. But FWD
allows the move, which is what makes previous() reachable for a presorted query here when it is unreachable in 4GL so the forgetDeadEnd() call in previous() is doing real work.

I split that into its own test, TestBackwardNavigationOnBreakByIsRejected, which fails. It is pre-existing and separate from this task, nothing in the presort work adds or removes that validation.

Committed the changes to rev 16770.

#28 Updated by Ovidiu Maxiniuc 13 days ago

  • Status changed from Review to Internal Test
  • reviewer deleted (Alexandru Lungu)

Indeed, the latests revision should fix the issue from review. I think you can start testing. Be sure it passes the full set of tests; due to nature of the changes, all projects are affected.

The %done is still at 50%. Is this accurate? If not, please update.

#29 Updated by Artur Școlnic 13 days ago

  • % Done changed from 50 to 100

Thank you, Ovidiu.
Will start with multi session tests, since the new query must be sensitive to other users changes too, then move on to standard regression testing.

#30 Updated by Artur Școlnic 13 days ago

  • Status changed from Internal Test to WIP
  • % Done changed from 100 to 90

Multi session tests identified a few issues.
1. Regression: presort walk truncates at the first fetch bracket

A foreign session committing a change at or behind the resolution point ends the walk at ten rows. Measured on a 404-row set, BREAK BY over an index-satisfiable sort:
  • delete one row ahead of the cursor — 9 rows delivered of 402, NUM-RESULTS 11
  • create one row behind the cursor — 10 rows of 403, NUM-RESULTS 11

A commit landing past the end of the set is unaffected. The boundary is ProgressiveResults.BASE@ = 10, the first fetch bracket. NUM-RESULTS = 11 is a correct report of what was resolved, not a separate bug.

Impact: a loop over a presort query silently processes ten rows instead of four hundred, with no error. Not reachable on trunk, where PresortQuery materialises at the open.

2. Pre-existing: dead-end re-delivery does not fire cross-session

When another session deletes every row ahead of the cursor, the row before them is not re-delivered. Fails on trunk and on the branch; AdaptiveQuery.redeliverRowBeforeSkippedOffEnd covers the single-session case only.

Working on fixing the issues and extending the multi session test suite.

#31 Updated by Ovidiu Maxiniuc 12 days ago

  • reviewer Adrian Lungu added

Adding back Alex as reviewer. It was not in my intention to remove him from the list, but somehow, that just happened.

#32 Updated by Artur Școlnic 12 days ago

  • Status changed from WIP to Review
  • % Done changed from 90 to 100
  • reviewer deleted (Adrian Lungu)

Issues the multi-session tests found and solved

1. The walk truncated at the first fetch bracket. Any committed change by another session at or behind the resolution point killed the walk at ten rows — 9 rows delivered of 402, no error. A loop over a presort query silently processed ten rows instead of four hundred.

2. The dead-end re-delivery didn't fire cross-session. When another session deleted every row ahead, the walk delivered nothing instead of re-delivering the row before the dead end. Cause: the rebuilt backing handed back rows whose records were gone, so the query reported a result the caller couldn't read.

Both were reachable only from the branch's incremental path, and only cross-session — single-session probes pass. Neither was visible to the existing suite, whose post-open tests all use 2–4 row fixtures and never cross a bracket boundary.

Also found, not fixed: PC06 — with an OUTER JOIN, a foreign delete of the joined row drops the driving row. Fails on trunk too, so it's pre-existing and outside this ticket.

The fix

Three files; every existing presort query keeps today's code path.

- Presortable.consumeBackingReplaced — new default method returning false. Nothing else implements it.
- AdaptivePresortQuery — raises the flag in resetResults, already on the invalidation path. Asking consumes it, bounding the retry to one per replacement.
- Presorter.IncrementalResults.loadRow — on next() == false, retry instead of tearing the backing down if the query says it was replaced; and skip a row none of whose components resolved to a record.

Please review rev 16771.
Meanwhile I will continue testing .

#33 Updated by Artur Școlnic 12 days ago

  • reviewer Alexandru Lungu added

#34 Updated by Ovidiu Maxiniuc 12 days ago

Indeed, the fix in r16771 should work as explained in #10806-32.

#35 Updated by Artur Școlnic 12 days ago

Multi tenant app testing is ok, tested conversion, harness, web ui navigation, reports and load testing.
Continuing with etf and chui.

#36 Updated by Artur Școlnic 10 days ago

Etf housing and chui regression testing passed. Rebased 10806a on trunk/16782.
Will ask colleagues to test the other projects.

#37 Updated by Artur Școlnic 9 days ago

Stefanel and Razvan will help me with further testing, thanks guys!
Please post here the results.

#38 Updated by Alexandru Lungu 9 days ago

Artur, please take a look and let me know if some of the documented behavior is out of place. I will review and do some tests on 10806a next.

#39 Updated by Artur Școlnic 9 days ago

Three items from the #10806 measurement work. Evidence: OpenEdge 12.8 single-user, plus the FWD trunk run of the query_shape_uncommitted_visibility corpus.

1. The OPEN QUERY grammar permits a combination the compiler rejects

openQueryStmt lists INDEXED-REPOSITION and BREAK as sibling options in any order, but the reference refuses that pairing at compile time. Re-measured on a temp-table, with controls both ways:

brk_ir.p    (BREAK BY + INDEXED-REPOSITION) -> error=yes
    [14297] BREAK BY only allowed if query q is not INDEXED-REPOSITION. (14297)
brk_only.p  (BREAK BY alone)                -> error=no
ir_only.p   (plain BY + INDEXED-REPOSITION) -> error=no

Pure language restriction, no database involved. Since backward navigation is also rejected (12393), a BREAK BY query's reposition surface is effectively nil.

2. Pending-change bullet — split out the third case

The third case, "an update to a sort or break field that changes the record's position or group", measured as working on FWD trunk: TestPresortOrdersByPendingSortValue asserts "CCC,ZZZ" where stored values give "ZZZ,CCC", and TestPresortGroupsByPendingBreakValue asserts one group where stored values give two. Both pass — SortedResults resolves keys from temp records set with setTempRecord, which already carry the pending values, so ordering and grouping use the new value while selection does not.

Only the eager SortedResults path was exercised; the IncrementalResults post-commit quickLoad path the private page blames was never reached. Suggested: keep cases one and two, scope case three to the path where it was observed.

3. The unindexed/computed exemption is stated as Sure but recorded as unreconciled

"BREAK BY an unindexed field, or BY a computed expression, behaves the same as the reference." The Performance entry on the same page says the set is taken in full at open "even when an index could produce the order", and the private page calls the IncrementalResults streaming question "the main reconcile point recorded in the audit".

Suggested: move to Unconfirmed. Every corpus probe uses an indexed break key behind a WHERE equality — the mis-gated eager path — so the exemption is untested.

I am committing query_shape_uncommitted_visibility now.

#40 Updated by Stefanel Pezamosca 8 days ago

Artur, I found an issue, please take a look.

A PRESELECT loop with BREAK BY on an indexed field must convert to a PresortQuery, as on trunk, not to an AdaptivePresortQuery. PresortQuery extends PreselectQuery and walks a list built once at open, so a record created in the loop body does not join the walk. AdaptivePresortQuery is sensitive to changes, and the new record joins the walk. The cause is the new index_satisfiable_sort note (index_selection.rules:1167), which ignores the PRESELECT keyword; database_access.rules then emits AdaptivePresortQuery (database_access.rules:1480, 2035).

Testcase:

DEFINE TEMP-TABLE tt NO-UNDO FIELD f AS INTEGER INDEX ixf f.
DEFINE BUFFER b FOR tt.
DEFINE VARIABLE n AS INTEGER NO-UNDO.

DO n = 1 TO 3:
   CREATE tt.
   tt.f = n.
END.

n = 0.
REPEAT PRESELECT EACH tt BREAK BY tt.f:
   FIND NEXT tt NO-ERROR.
   IF NOT AVAILABLE tt THEN LEAVE.
   n = n + 1.
   IF n = 1 THEN DO TRANSACTION:
      CREATE b.
      b.f = 2.
   END.
END.
MESSAGE "visited=" n.

Note: The expected result is visited=3, with 10806a is visited=4

#41 Updated by Artur Școlnic 8 days ago

  • Status changed from Review to WIP
  • % Done changed from 100 to 90

Thank you, Stefanel. I am on it.

#42 Updated by Artur Școlnic 8 days ago

The new index_satisfiable_sort note (index_selection.rules:1167) is computed from indexSatisfiable and !sortDegraded and table_count == 1, with no reference to the PRESELECT keyword, so database_access.rules (:1480, :2035) emits AdaptivePresortQuery for a REPEAT PRESELECT ... BREAK BY <indexed field>. That class is change sensitive by design, so a record created in the loop body joins the walk. PRESELECT resolves the set at open however the order is produced.

Fix 1 — index_selection.rules: added !descendant(prog.kw_presel, 1) to the note. This restores trunk's query class for every PRESELECT shape and leaves the true arm, and therefore the FOR EACH behaviour the task was written for, untouched. Measured on OpenEdge 12.8, the rule's own comment is correct for FOR EACH: an index-driven sort stays change sensitive, a client-side sort is materialised and frozen. It is simply not a statement about PRESELECT.

Fix 2 — Presorter.java: a BREAK phrase cannot close a group without reading the row after the current one, so the read position of a break walk is one row ahead of the row whose body is running, and a row created into that band is never delivered (seeded BBB,CCC,DDD and paused on BBB: BBX is not delivered, CCX/DDX/ZZZ are, and dropping the BREAK delivers BBX too). IncrementalResults now passes over a supplied row that sorts strictly before the furthest row it had already read when the backing was replaced. Note that AdaptiveQuery.changedRecordSortsAfter cannot be used for this — it is reached through doubleCheckInvalidation, which returns early for a scrolling query, and a BREAK BY query is scrolling by language rule.

Result

pass fail
OpenEdge 12.8 33 0
FWD trunk (16782) 25 8
FWD 10806a as committed 22 11
FWD 10806a + both fixes 32 1

Across the whole slice, 185 tests go from 152/33 to 162/23. The single remaining failure, testPreselectAgainstDeleteAhead, fails on trunk as well: a row deleted ahead of the cursor is still delivered from a frozen result list. It is a pre-existing PresortQuery defect on a different axis from this task — the branch's own AdaptivePresortQuery handles the same case correctly — so I propose a separate issue rather than widening this one. The test is new, so this is a newly discovered trunk defect rather than a known one.

Committed the fix to rev 16791.

#43 Updated by Artur Școlnic 8 days ago

  • % Done changed from 90 to 100
  • Status changed from WIP to Review

#44 Updated by Ovidiu Maxiniuc 6 days ago

  • Status changed from Review to Internal Test

Artur,

I think the code changes are fine. But I guess the full set of tests needs to be redone.

A couple of minor things to mention:
  • in Presorter.java, the methods criteriaMatchAt() and sortsBefore() use two parallel iterators: enhanced for on sortHelpers and i. I think we discussed this and I understand that semantically these should be synchronized (for example, in sortsBefore(), values[i] will not raise offset exception because values was constructed using sortHelpers.size()), but this is not evident locally. I think a short note should be added to javadoc.
  • there is one thing I missed in previous review, when I was focused on code changes rather than on comments: in the header of Presortable.java, the -T- column should be dropped, nut not the --JPRM--. Although that is deprecated now, it is still valuable information. As opposed to T, whose values were evident. Keep the max width to 110 chars.

#45 Updated by Alexandru Lungu 6 days ago

  • Status changed from Internal Test to WIP
  • % Done changed from 100 to 90

Review from ai:

  • [CRITICAL] functional AdaptiveQuery.retainResultsOnReset: The test highLevelResults instanceof SimpleResults also matches FilteredResults (class FilteredResults extends SimpleResults), which PreselectQuery.createResults returns for every plain AdaptiveQuery with a client-side where. Previously resetResults called FilteredResults.cleanup() (rows.clear() + reset()); now the cleanup is skipped, resultsNeedBacking re-executes into the adapter, createResults hands back the same FilteredResults and setResults skips cleanup because the object is identical. FilteredResults.first()/next() serve the stale cached rows before falling through to the new delegate. Trigger: CompoundQuery calls query.reset(!resolveOnce, false) on each inner component every loop pass (CompoundQuery.java:2381, 4242), so in FOR EACH a, EACH b WHERE b.k = a.k AND <client-side expr> the inner query for outer row N returns the rows cached for outer row N-1, then its own. Key the exemption on the presort layer (a flag/override in AdaptivePresortQuery), not on SimpleResults.

I warned about something similar in my previous requirements:

plenty of code has instanceof AdaptiveQuery or similar.

I should have been more specific, but AQ and PQ have plenty of "hard-coded" checks to identify certain query states. Checking if the results are dynamic using results instanceof DynamicResults, instead of checking whether the query is in preselect/dynamic mode. As the Presort feature is being ported to this infrastructure, most of the instanceof require second pass to see if they still fit the logic.

  • [MAJOR] functional AdaptiveQuery.sessionEvent: The SESSION_CLOSING_NORMALLY guard became !resultsCached && !(highLevelResults instanceof SimpleResults), which (same root cause) matches FilteredResults. For a scrolling client-where AdaptiveQuery (OPEN QUERY ... SCROLLING / browse walked with GET NEXT across a committed transaction), TRANSACTION_COMMITTING already breaks on isScrolling(), and now session close no longer stores reference rows nor calls resetResults(). The adapter stays connected with its pre-close delegate (resultsNeedBacking() is false), so the next GET NEXT reads a ScrollingResults JDBC cursor over a session that Persistence closes or marks reclaimable, and the cursor is no longer released at session close. Apply the same presort-specific test as for retainResultsOnReset.

Same mention as above. Mentioning SESSION_CLOSING_NORMALLY may raise us a complete new perspective; are the presort query results invalidated on commit as they might be forward-only? If so, do we need caching BatchResults on top of the extracted FilteredResults?

  • [MAJOR] functional AdaptivePresortQuery.finishInitialize: setScrolling() is always called and the conversion change withholds setNonScrolling for indexSatisfiable FOR EACH presorts. On UNDO, RETRY, TransactionManager calls PreselectQuery.retry() (sets betweenRows = true); the re-run body then calls next(), but AdaptiveQuery.next() honors betweenRows only when !isScrolling() (~line 2441) and neither AdaptiveQuery nor AdaptivePresortQuery overrides retry, so results.next() advances. Trigger: FOR EACH customer BREAK BY customer.name: UPDATE customer.credit-limit. IF ... THEN UNDO, RETRY. END. (index on name) skips the retried row and proceeds with the next one. Regression vs the previous PresortQuery path, which was non-scrolling via the grafted setNonScrolling and whose PreselectQuery.next() (line 3621) honors betweenRows regardless.

To be investigated.

  • [MAJOR] functional Presorter.IncrementalResults.sessionEvent: On the first TRANSACTION_COMMITTING during the walk, all remaining rows of the backing (the AdaptiveQuery adapter) are drained into a SimpleResults, resultsCached = true is set and the listener deregisters; afterwards loadRow reads only the frozen snapshot, so later resetResults/consumeBackingReplaced handling never reaches it. The patch edits this drain block but adds no guard for the adaptive backing. Trigger: FOR EACH t EXCLUSIVE-LOCK BREAK BY t.name: (indexed name) whose body updates t (one transaction per iteration) and later creates a row name = "ZZZ" past the read-ahead band: OE delivers it (per the patch's own 12.8 note in behindReadAhead: "CCX, DDX and ZZZ all are"), FWD never does. Change sensitivity of AdaptivePresortQuery effectively ends at the first commit, and the rest of the table is read eagerly at that point.

Same comment as above regarding results caching. Please create some performance tests in this idea to ensure that the caching is done in a performant way for presort queries (if this is the case).

  • [MAJOR] functional AdaptivePresortQuery.createHighLevelResults: The client-side where filter is applied after sorting (super.createHighLevelResults(presorted) → PreselectQuery.createResults wraps the presorted layer in FilteredResults when isClientWhere()), the reverse of PresortQuery.createResults (filter first, then createIncrementalResults/createSortedResults). breakRows are recorded against unfiltered row indexes while Presorter.isFirstOfGroup/isLastOfBreakGroup/isLast look up getSortedResults().getRowNumber() from the filtered layer. Example: sorted rows A(si2=1, filtered out), B(si2=1), C(si2=2) give breakRows = {0, 2}; after filtering B=0, C=1, so LAST-OF for B and FIRST-OF for C both return false. Trigger: FOR EACH t WHERE t.si1 = 1 AND <expr not executable server-side, e.g. a UDF call> BREAK BY t.si2 with index (si1, si2); index_selection runs before where_clause_prep2 (annotations.xml:676/681) so index_satisfiable_sort ignores client-side where. Apply the filter below the presort layer (mirroring PresortQuery) or exclude client-where queries from index_satisfiable_sort.

to be investigated.

  • [MAJOR] performance AdaptivePresortQuery.finishInitialize: Single-table FOR EACH ... BREAK BY with an index-satisfiable sort previously converted to PresortQuery + setNonScrolling(), i.e. a non-scrolling IncrementalResults holding 3 rows with breakRows trimmed to the last 2 and no cursor. It now converts to AdaptivePresortQuery without the graft, so setScrolling() stands: IncrementalResults.addRow appends every row's Long[] ids, breakRows sets are never trimmed, and the CursorWrapper installed by AdaptiveQuery.setScrolling() records ids for every row. A forward-only report loop goes from O(1) to several O(n) structures. The rules comment acknowledges giving up the ForwardResults optimisation, but nothing bounds this for large reports; consider a forward-only mode for this shape.

to be investigated.

  • [MINOR] functional Presorter.IncrementalResults.behindReadAhead: The read-ahead band holds only the BREAK/BY criteria values, and sortsBefore treats a full tie as "not behind". Trigger: index (name, seq), FOR EACH t BREAK BY t.name, cursor on ("BBB", x), read-ahead ("CCC", 5); the body creates ("CCC", 1), the query is invalidated, the replacement resumes after the cursor in (name, seq) order and yields ("CCC", 1), which is not in resupplied and is appended after the cached ("CCC", 5) — delivered out of index order, whereas the patch's own model ("passes over anything behind the furthest row it has read") implies OE skips it. Break flags stay correct. The OE 12.8 observation quoted only covers the strictly-between BBX case; compare against the read-ahead row's full index key, or add an OE test for the tie case.

to be investigated.

  • [MINOR] functional AdaptivePresortQuery.resetResults: backingReplaced is raised unconditionally, including from PreselectQuery.open (which calls resetResults() before execute()) and reset, while neither cleanup nor createPresortedResults clears it (its only consumer is Presorter.java:1715). Every open therefore builds a new IncrementalResults with the flag already set, and the first genuine end of the backing (including an empty result) is taken for the "between delegates" case in IncrementalResults.loadRow, issuing one redundant next() on the exhausted ResultsAdapter/ProgressiveResults (extra moveTo/bracket lookup) per open. Results stay correct. Clear the flag in cleanup or after createPresortedResults.

to be investigated.

There are also plenty of style issues you can regenerate I suppose using the AI FWD review skill.

#46 Updated by Artur Școlnic 6 days ago

  • % Done changed from 90 to 100

I will try to find testcases for the technical issues.

#47 Updated by Artur Școlnic 6 days ago

  • % Done changed from 100 to 90

#48 Updated by Artur Școlnic 6 days ago

# Finding Status Action
7 read-ahead tie loses rows confirmed by test -- regression introduced in 16791 fix first
4 change sensitivity dies at the first commit confirmed by test fix
5 break flags computed over unfiltered row numbers confirmed by test fix
3 UNDO, RETRY skips the retried row confirmed in source (not runnable in batch) fix
1+2 instanceof SimpleResults also catches FilteredResults confirmed in source, latent -- no trigger found one fix, both sites
6 always-scrolling presort keeps O(n) structures not measurable from 4GL measure, then decide
8 backingReplaced raised on every open harmless as stated by review one line, optional

Most of the findings can be reproduced, working on extending the test suite and fixes.

#49 Updated by Artur Școlnic 4 days ago

I introduced ~ 50 more tests in the suite, there are 3 regressing tests, compared to trunk, the failure reasons are:

Over-delivery — break by si1 (tests 1 and 2). The branch decides "behind the read position" by comparing sort keys. readAheadBand holds the resolved values of the break criteria only — just si1 = CCC. The created row is also CCC, and sortsBefore treats a full tie as "not behind", so CCC/1 is let in. The comparison needs the total order (si1, si2, rowid); the band is two levels too coarse. Result: BBB/1,CCC/5,CCC/1,DDD/1, with the row out of index order.

Truncation — break by si1 by si2 (test 3). There the band happens to cover the whole key, so the row is correctly refused — and the walk then dies, losing DDD/1. Refusing a row inside Presorter.loadRow breaks the reconstruction: the row never enters the presort cache, so cursor.peekLastLoadedResult — the anchor the re-executed backing is repositioned on — is stale, the re-execution comes back empty, and the loop reads that as end-of-results (addRow(null) + cleanupBackingResults()).

One cause underneath both. The branch has no representation of the walk's read position. It approximates it with four derived pieces of state — resupplied, backingResultsOffset and readAheadBand in Presorter.IncrementalResults, plus peekLastLoadedResult in AdaptiveQuery — which agree only while every row the replacement stream produces is either handed back or appended. OE semantics requires refusing some rows, and that breaks the invariant.

#50 Updated by Artur Școlnic 4 days ago

I need to mention that for establishing an OE baseline, I use a self hosted database, client/server uses the message buffer which heavily skews the results. Since we are dealing with prefetched records and invalidation, another layer of caching/buffering is highly confusing and is a matter for future improvements.

#51 Updated by Artur Școlnic 1 day ago

  • Status changed from WIP to Review

Changes in the branch:
16793 -- Keep a presorted result list alive across the resets beneath it

A presorted layer is built once per open and the caller walks it, so the parent must not empty or rebuild it when the backing underneath is replaced. Four places assumed otherwise.

  • AdaptiveQuery asked whether its results were materialised by testing for SimpleResults, which also matches FilteredResults -- a filter over the delegate, not a materialised view, and one which must still be cleaned up. The question is now put to the subclass through retainResultsOnReset.
  • AdaptivePresortQuery answers it, and clears the backing replacement flag when the open state is discarded.
  • PreselectQuery no longer cleans up the previous results when they are materialised: a re-execution can reach that path from underneath a walk, and emptying the list leaves the caller walking nothing.
  • Presorter drains the delegate the backing already holds when a transaction commits, rather than asking the adapter for rows, which re-entered provideNewResults and handed back a rewound delegate.

Two behaviours measured against OpenEdge 12.8 rather than inferred: a client-side where belongs below the presorted layer, since break rows are recorded against the layer's own row numbers while the break flags look the current row up in whatever the caller is walking; and re-delivery of the row before a dead end depends on how the walk is driven, not on the query -- a named query walked with GET NEXT hands that row back while an inline FOR EACH block ends the walk.

16794 -- Give a presorted walk a read position, and measure it in the whole sort phrase

A BREAK walk reads one row past its cursor to answer LAST-OF, and which side of that read position a changed row falls on decides whether the legacy system delivers it. Nothing represented that position: a replacement backing resumed at the cursor, so the rows between the two were offered again for the layer to recognise, which it could not do for a row whose key tied with one it was holding.

  • AdaptiveQuery gained resumeAnchorIds and resumeAnchorByScan, both defaulting to the previous behaviour, plus rowMatches; the replacement may now be advanced to the anchor rather than only tested against its first row.
  • AdaptivePresortQuery answers with the furthest row its layer has read, which Presorter exposes as furthestRowRead.
  • Presorter.useSortPhraseForBand widens the read-position band from the BREAK criteria to the query's whole sort phrase, so rows tying on si1 are still ordered by si2. The band's resolvables take the DMO proxy the criteria already held; a RecordBuffer is also a DataModelObject and would compile, but resolves its getters against no record. The recid component SortCriterion.parse appends is skipped.

Testing:
Measured against OpenEdge 12.8 self-service, 226 comparable tests: trunk 170, this branch 218, no regressions. The branch fixes 48 of trunk's 56 failures; the 8 remaining fail identically on trunk.

Also available in: Atom PDF