Bug #10806
Presort queries can sometimes dynamically find records created after opening the query
90%
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,PagedResultsorProgressiveResults
- integration with
- 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 (
OFsyntax) and embedded buffers (embeddedCAN-FIND) - projection queries vs full queries
- plenty of code has
instanceof AdaptiveQueryor 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.
- It materialises the two shapes OpenEdge walks lazily. That is #10806.
- 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 forBREAK BY <unindexed>andBY (computed).AdaptivePresortQuery extends AdaptiveQuery— new. Incremental and change sensitive. For aBREAK BYwhose order an index can produce.- Conversion picks between them with the discriminator it already computes:
clientSortinindex_selection.rules, plus the equality brackets that make a partial index match sound. AdaptivePresortQueryadds only break-group support on top ofAdaptiveQuery. 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 si2underwhere si1 = ...works only becausesi1is pinned by equality. If it varied, the delivered order would be wrong. - That bracket information exists at conversion and is not exposed at runtime.
QueryComponenthas no accessor for it. - The runtime already has a fork —
createHighLevelResultschoosesIncrementalResultsorSortedResults— and it is mis-gated:sortCriteriaMatchSortPhrasecompares 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
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);
- line 11: missing H entry and (C) year update. When here, please upgrade the H header format to 110 chars/line (
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;andreturn true;, at the end, if criteria from alloffsetpassed. I could not fully understand the logic ofcriteriaMatchAt(): it iteratessortHelpers, and compare then withSortCriterionstarting atoffset? - line 1495: stray javadoc.
- line 1525: method
backingReplaced()is missing its javadoc. Is it the one from line 1495?
- lines 234-283: something does not seem fine here. First: at 238/240 it seems more natural to say:
AdaptivePresortQuery- line 6:
Copyright (c)should be2026only,-JPRM--column should be dropped.
- line 6:
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
Presorter.java234-283 I didn't make this one, it flips the meaning.if (!criteriaMatchAt(...)) return falseasks whether every offset matches; we need whether some does.BREAK BY si2underWHERE si1 = ...on an index over(si1, si2)gives a 3-component phrase (si1,si2, PK tiebreaker) and one criterionsi2, 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 toAdaptivePresortQuery. 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 frozenPresortQuery 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 entryis suppressed on a re-delivery, so
NUM-RESULTS stays at 2; andredeliverRowBeforeSkippedOffEnd() defaults to false, overridden only inAdaptivePresortQuery, 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.
- 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.
AdaptiveQuery.java:- while the other new local flags used for identification of the exceptional situation are reinitialized each time the
next()method is invoked,deadEndDeliveredis not reset onopen(),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?
- while the other new local flags used for identification of the exceptional situation are reinitialized each time the
#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
BREAK BY over an index-satisfiable sort:
- delete one row ahead of the cursor — 9 rows delivered of 402,
NUM-RESULTS11 - create one row behind the cursor — 10 rows of 403,
NUM-RESULTS11
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
- I generated BREAK phrase
- I also loaded BREAK phrase (private)
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 methodscriteriaMatchAt()andsortsBefore()use two parallel iterators: enhancedforonsortHelpersandi. I think we discussed this and I understand that semantically these should be synchronized (for example, insortsBefore(),values[i]will not raise offset exception becausevalueswas constructed usingsortHelpers.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 toT, 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 testhighLevelResults instanceof SimpleResultsalso matchesFilteredResults(class FilteredResults extends SimpleResults), whichPreselectQuery.createResultsreturns for every plainAdaptiveQuerywith a client-side where. PreviouslyresetResultscalledFilteredResults.cleanup()(rows.clear()+reset()); now the cleanup is skipped,resultsNeedBackingre-executes into the adapter,createResultshands back the sameFilteredResultsandsetResultsskips cleanup because the object is identical.FilteredResults.first()/next()serve the stale cached rows before falling through to the new delegate. Trigger:CompoundQuerycallsquery.reset(!resolveOnce, false)on each inner component every loop pass (CompoundQuery.java:2381, 4242), so inFOR 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 inAdaptivePresortQuery), not onSimpleResults.
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: TheSESSION_CLOSING_NORMALLYguard became!resultsCached && !(highLevelResults instanceof SimpleResults), which (same root cause) matchesFilteredResults. For a scrolling client-whereAdaptiveQuery(OPEN QUERY ... SCROLLING/ browse walked withGET NEXTacross a committed transaction),TRANSACTION_COMMITTINGalready breaks onisScrolling(), and now session close no longer stores reference rows nor callsresetResults(). The adapter stays connected with its pre-close delegate (resultsNeedBacking()is false), so the nextGET NEXTreads aScrollingResultsJDBC cursor over a session thatPersistencecloses or marks reclaimable, and the cursor is no longer released at session close. Apply the same presort-specific test as forretainResultsOnReset.
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 withholdssetNonScrollingforindexSatisfiableFOR EACH presorts. On UNDO, RETRY,TransactionManagercallsPreselectQuery.retry()(setsbetweenRows = true); the re-run body then callsnext(), butAdaptiveQuery.next()honorsbetweenRowsonly when!isScrolling()(~line 2441) and neitherAdaptiveQuerynorAdaptivePresortQueryoverridesretry, soresults.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 previousPresortQuerypath, which was non-scrolling via the graftedsetNonScrollingand whosePreselectQuery.next()(line 3621) honorsbetweenRowsregardless.
To be investigated.
- [MAJOR] functional
Presorter.IncrementalResults.sessionEvent: On the firstTRANSACTION_COMMITTINGduring the walk, all remaining rows of the backing (theAdaptiveQueryadapter) are drained into aSimpleResults,resultsCached = trueis set and the listener deregisters; afterwardsloadRowreads only the frozen snapshot, so laterresetResults/consumeBackingReplacedhandling 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:(indexedname) whose body updatest(one transaction per iteration) and later creates a rowname = "ZZZ"past the read-ahead band: OE delivers it (per the patch's own 12.8 note inbehindReadAhead: "CCX, DDX and ZZZ all are"), FWD never does. Change sensitivity ofAdaptivePresortQueryeffectively 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.createResultswraps the presorted layer inFilteredResultswhenisClientWhere()), the reverse ofPresortQuery.createResults(filter first, thencreateIncrementalResults/createSortedResults).breakRowsare recorded against unfiltered row indexes whilePresorter.isFirstOfGroup/isLastOfBreakGroup/isLastlook upgetSortedResults().getRowNumber()from the filtered layer. Example: sorted rows A(si2=1, filtered out), B(si2=1), C(si2=2) givebreakRows= {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.si2with index (si1, si2);index_selectionruns beforewhere_clause_prep2(annotations.xml:676/681) soindex_satisfiable_sortignores client-side where. Apply the filter below the presort layer (mirroringPresortQuery) or exclude client-where queries fromindex_satisfiable_sort.
to be investigated.
- [MAJOR] performance
AdaptivePresortQuery.finishInitialize: Single-tableFOR EACH ... BREAK BYwith an index-satisfiable sort previously converted toPresortQuery+setNonScrolling(), i.e. a non-scrollingIncrementalResultsholding 3 rows withbreakRowstrimmed to the last 2 and no cursor. It now converts toAdaptivePresortQuerywithout the graft, sosetScrolling()stands:IncrementalResults.addRowappends every row'sLong[]ids,breakRowssets are never trimmed, and theCursorWrapperinstalled byAdaptiveQuery.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, andsortsBeforetreats 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 inresuppliedand 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:backingReplacedis raised unconditionally, including fromPreselectQuery.open(which callsresetResults()beforeexecute()) andreset, while neithercleanupnorcreatePresortedResultsclears it (its only consumer is Presorter.java:1715). Every open therefore builds a newIncrementalResultswith the flag already set, and the first genuine end of the backing (including an empty result) is taken for the "between delegates" case inIncrementalResults.loadRow, issuing one redundantnext()on the exhaustedResultsAdapter/ProgressiveResults(extramoveTo/bracket lookup) per open. Results stay correct. Clear the flag incleanupor aftercreatePresortedResults.
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.
AdaptiveQueryasked whether its results were materialised by testing forSimpleResults, which also matchesFilteredResults-- 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 throughretainResultsOnReset.AdaptivePresortQueryanswers it, and clears the backing replacement flag when the open state is discarded.PreselectQueryno 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.Presorterdrains the delegate the backing already holds when a transaction commits, rather than asking the adapter for rows, which re-enteredprovideNewResultsand 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.
AdaptiveQuerygainedresumeAnchorIdsandresumeAnchorByScan, both defaulting to the previous behaviour, plusrowMatches; the replacement may now be advanced to the anchor rather than only tested against its first row.AdaptivePresortQueryanswers with the furthest row its layer has read, whichPresorterexposes asfurthestRowRead.Presorter.useSortPhraseForBandwidens the read-position band from theBREAKcriteria to the query's whole sort phrase, so rows tying onsi1are still ordered bysi2. The band's resolvables take the DMO proxy the criteria already held; aRecordBufferis also aDataModelObjectand would compile, but resolves its getters against no record. TherecidcomponentSortCriterion.parseappends 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.