Bug #10962
Optimized multi-table compound components are not invalidated
90%
Related issues
History
#1 Updated by Alexandru Lungu 8 months ago
There is an issue with the invalidation of AdaptiveQuery instances that are generated by the optimizer. It seems like CQ.serverJoinAdaptive is creating a new AdaptiveQuery that is initialized, but the initialization is not registering the query to the change broker because it does not have records buffers yet; the buffers are created later on in CQ.serverJoinAdaptive. So, these AQ are not registered properly to the ChangeBroker:
USING Progress.Lang.*.
using OpenEdge.Core.Assert.
BLOCK-LEVEL ON ERROR UNDO, THROW.
CLASS Test:
DEFINE TEMP-TABLE tt FIELD f1 AS INT INDEX idx AS UNIQUE F1.
DEFINE TEMP-TABLE tt2 FIELD f1 AS INT INDEX idx2 AS UNIQUE F1.
@SetUp.
METHOD VOID before():
def var i as int.
empty temp-table tt.
empty temp-table tt2.
repeat i = 1 to 10:
create tt2.
tt2.f1 = i.
release tt2.
if i = 3 or i = 8 then next.
create tt.
tt.f1 = i.
release tt.
end.
END.
@Test.
METHOD VOID test():
def query q for tt, tt2 scrolling.
open query q for each tt, first tt2 of tt.
get first q.
Assert:Equals(1, tt.f1).
Assert:Equals(1, tt2.f1).
get next q.
Assert:Equals(2, tt.f1).
Assert:Equals(2, tt2.f1).
create tt.
tt.f1 = 3.
release tt.
get next q.
Assert:Equals(3, tt.f1). // this is not found!!!!
Assert:Equals(3, tt2.f1).
END.
END.
I recall this passing when working on #9724, so I am quite sure this is a regression. Please let me know if it looks familiar.
My temporary fix was to refactor CompoundQuery.maybeOptimize:
if (deferOptimization && components.size() < originalComponents.size())
{
// register the new, optimized queries as change listeners; if a query was left
// unoptimized, the change broker is tolerant of a listener being added multiple times
registerRecordChangeListeners();
}
to
registerRecordChangeListeners();
I would personally like to know if there was a change somewhere else that regressed this so we can assess this better.
#3 Updated by Alexandru Lungu 8 months ago
It is not enough apparently. Even if I force the multi-table AQ to invalidate, it will get to the absolute first row. In other words, there is no AQ.setWorkerRefererenceRecord for AQ.createCompoundQuery.
Now I really think something was changed in the invalidation of multi-table AQ and it is severely broken.
#4 Updated by Alexandru Lungu 8 months ago
- Priority changed from Normal to Urgent
#5 Updated by Alexandru Lungu 8 months ago
+ Stefanel
#6 Updated by Alexandru Lungu 8 months ago
- Priority changed from Urgent to High
I went to ~rev. 15550 but this is still reproducible. I still tend to think this is a regression and this worked at some point, but apparently today it is not working.
#7 Updated by Alexandru Lungu 8 months ago
FYI, there is CQ.stateChanged that sets useSnapshot, initReferenceRowSupport and SnapshotParameterFilter to be used to handle optimized CQ invalidation. Still, I am not certain they reflect well within the optimized query implementation.
#9 Updated by Alexandru Lungu 4 months ago
- % Done changed from 0 to 80
- Subject changed from [Regression] Optimized multi-table compound components are not invalidated to Optimized multi-table compound components are not invalidated
- Status changed from New to WIP
- Assignee set to Alexandru Lungu
I fixed most of this in 10962a:
- Register optimized components to ChangeBroker
- Fixed CQ generated by AQ invalidation by setting a proper reference row. I also skipped resetting the CQ on first use to avoid resetting the reference row.
- Fixed
AdaptiveComponentthat was not properly overriding the super-method. This resulted in NPE when callinggetFQLPreprocessorand the dynamic preproc was not prepared.
My tests from #9724 are way better (from 32 failures to 16). #10962-1 is fixed. But a customer unit tests has 4 new failures due to the changes in getFQLPreprocessor. These were mandatory to avoid a NPE in my tests, but maybe it was fixed incorrectly. Thus, this is still WIP, but I feel it is very close to a resolution. The error message is due to an ArrayOutOfBounds as some parameter was inlined in the invalided query, but not inlined in the preselected query.
#10 Updated by Alexandru Lungu 3 months ago
- % Done changed from 80 to 90
Made an update on 10962a/rev. 16540:
- Removed dynamicPreproc and dynamicArgs from AdaptiveComponent. In fact, these were not used at all; there was a typo so that dynamicPreproc was created when
fallbackwas null (instead of when it should have been non-null). In previous changes I fixed the typo, activating the new code usingdynamicPreproc. However, this seems to be fundamentally broken so I dropped it (the arguments gathered were already resolved, so I had to "un-resolve them"). Basically, I had to use the fallback arguments for the optimized query, but this made little technical sense. The preproc was used (and shall be used) only for preselect mode. If in dynamic mode, it doesn't make much sense to gather the non-inlined FQL and arguments. - Reset
subQuerywhen building the CQ back from invalidated AQ. This is needed because I saw records leaking in cursor in an invalidation - revalidation - invalidation sequence. The records in the first invalidation leaked in the second invalidation, leaving a gap in the cursor. - I did an experimental change in my indexed-reposition changes to override the reference row when doing a sync no matter if it is multi-table or not. It makes more sense to do so. Technically, if not, scrolling to an end, invalidating and then scrolling to the other end will start yielding results from the wrong end. With this aggressive override, I ensure that the query will be invalidated with the right data.
- TODO: I will need some unit tests for this, though I think it is indirectly covered by some of the unit tests I already have. Note to self: double-check.
- Reintroduced optimization based on
lastLoadedResultIsAtEndinRandomAccessCursor. This improved performance by avoiding query invalidation if the iteration happens at the same end already: scrolling upwards multiple times in a row will preserve the query state; the same for downwards. This increases chances for revalidation.
- I have an all time low for my unit tests with indexed-reposition. Most of the unit tests that I have left were due to this issue (#10962). I had an intermediate solution that achieved even lower, but one of my last 2 changes brought 2 fixes and 4 apparent regressions; so I would like to look into that first.
- I reckon there is a confusing separation of
storeReferenceRowandoverrideReferenceRow. They are technically the same, but one is meant for scrolling queries and the other for RAC. I will want to mitigate than confusion now and keep only one API for it. ArrayOutOfBoundsis no longer appearing and a customer unit tests suite passes completely.- adaptive query tests (temp and persistent) are not regressed.
- there is a dark area related to record snapshot when invalidating an CQ. I did not explore this much, but I am aware that adaptive-query tests are intensively testing this. There is a 50% pass rate there, so I might say that trunk is quite broken in that regard. My changes don't make things worse, but I reckon most of the complexity in CQ/AQ reference record management is related to that. Thus, I will need to deepen my investigation in that area at some point; maybe another task. For indexed-reposition issues in #9724, current changes are enough.
#11 Updated by Alexandru Lungu 3 months ago
Made an update on 10962a/rev. 16541:
- Fixed the blurred line between
storeReferenceRowandoverrideReferenceRow. AQ is now consistently relying onstoreReferenceRowbefore constructing dynamic query after invalidation. - I really wanted to have the same pattern for CQ, but didn't managed to have it like so. For AQ, there was a convenient "invalidateIfReferenceRowExists" that is called for each next/prev, etc. For CQ, this is not the case:
- for scrolling cases, CQ relies that the underlying sub-queries have the right state at the tip of the cursor. So there is no need to "invalidate"; the subqueries got invalidated anyway.
- for indexed reposition cases, CQ relies the the underlying sub-queries are at one end, but the iteration can happen towards the other. I this case, I am still stuck with the manual reposition to the other end so that results are consistent. So all the work around referenceRow I did was only for AQ (even multi-table), but I couldn't make it work for CQ as well :/
- Currently, there are still some tests that fail I need to investigate. I need to understand how my changes on cursor reference row destined for AQ are now influencing the CQ.
- I re-enabled revalidation after indexed reposition.
- I fixed a weird bug in which deleting a record from a CQ query buffer, then calling NEXT, would skip till the deleted record is no longer in the buffer (4GL). In FWD, the deleted record messed up the query and started from first again. This is unrelated to my changes, but I spot it now.
This is still WIP; trying to stabilize. I am clear on what I can and cannot do ATM. I will drop the effort of using reference-row (front/back version) to manage CQ uniformly as AQ.
#12 Updated by Alexandru Lungu 3 months ago
Made an update on 10962a/rev. 16542:
- Stabilized 10962a. The biggest issue was the lack of support of indexed-reposition in conjucation with revalidation. After
breakValuewas set, certain invariants were broken for indexed-reposition (e.g. run first/previous on indexed-reposition query that has a break values). - My unit tests are now sound.
- Large customer unit tests are sound.
- GUI testing was almost flowless. I still have a browse which, while it scrolls upwards, it repeats rows. All other use-cases are working completely fine.
#13 Updated by Alexandru Lungu 2 months ago
- Status changed from WIP to Review
- % Done changed from 90 to 100
- reviewer Ovidiu Maxiniuc added
Finally completed in 10962a/16509.
The single most painful instability from #10962-12 sounded like:
- scroll a browse
- run a ROW DISPLAY trigger
- the ROW DISPLAY trigger ended and the DB session is closed! This is because there is no active session user after finishing the ROW DISPLAY.
- the query driving the browse is dynamic, so it is not bound to the DB
- the session closing is dropping the results
- scrolling further needs to rebuild the dynamic results
- some of the state was hold by the dynamic query underneath and is lost. Most importantly, the reference row is lost.
To fix this, I extract the state from the dynamic query back in the AQ when the session is closing. In fact, only the reference-row is saved. When the query is re-executed, the reference-row drops back into the dynamic query and continues from there.
Tested with common screens of #9724 - no regressions.
#14 Updated by Alexandru Lungu 2 months ago
To simplify the process, I plugged in the 10962a branch through an AI reviewer. I am assessing this now ATM.
- [MAJOR] style
AdaptiveQuery.header: Existing history entry "172 CA 20260506 When a AdaptiveQuery gets invalidated and moved to RAQ..." was deleted from the file header, and a new entry with the same sequence ID (172) was added; existing history must not be removed and sequence IDs must remain unique. - [MAJOR] style
AbstractCursor.header: The history entry "AL2 20260223 Extracted loadReferenceRow to be used with overrideReferenceRow." is duplicated; it already appears under entry #001 and was re-added under entry #002. - [MAJOR] style
AbstractCursor.imports: Unused importjava.util.function.Consumeradded at line 77. - [MAJOR] style
AbstractCursor.imports: Self-importcom.goldencode.p2j.persist.AbstractCursor.ReferenceRowAssignerat line 81 is redundant — importing a class's own inner type from within the class is unnecessary and should be removed. - [MAJOR] style
RandomAccessCursor.moveQueryAtBackEnd: Method body uses 4-space indentation instead of the required 3-space indent. - [MAJOR] style
AbstractCursor.BackReferenceRowAssigner: Extraneous semicolon after closing brace of inner class};; should be just}. - [MAJOR] style
RandomAccessCursor.FrontReferenceRowAssigner: Extraneous semicolon after closing brace of inner class};; should be just}. - [MAJOR] performance
RandomAccessCursor.storeReferenceRows: Unconditionally loads both back and front reference rows even when only one end has moved. The short-circuit (getTableCount() == 1 && backReferenceRow != null && frontReferenceRow != null) only fires for single-table queries, so multi-table queries always run two fullloadReferenceRecordpasses on everyRecordChangeEvent. Per-slotisNullAtchecks avoid redundant DB loads when slots remain populated, but the unconditional double-pass still wastes work; consider a per-end "dirty" flag. - [MAJOR] functional
AdaptiveQuery.setReferenceRecord: Method dereferencesreferenceRecord.primaryKey()without a null-check. TheJoinable.setReferenceRecord(Record)contract makes no non-null guarantee — peer implementationRandomAccessQuery.setReferenceRecordtolerates null, and the underlyingbackReferenceRowis documented to allow null entries (seeresetReferenceRowusingArrays.fill(..., null)andBackReferenceRowAssigner.isNullAt). With the new multi-index callersetWorkerRefererenceRecord(Joinable, int)usingpersistable[index]forindex > 0, a partially-populated reference row will trigger an NPE. Add a null guard at method entry or at the caller before dispatching. - [MAJOR] functional
CompoundQuery.stateChanged: Replacescursor.peekLastLoadedResult()withcursor.peekLastLoadedResult(true)(hardcoded back-end). ThelastLoadedResultIsAtEndflag is flipped tofalsefor backward iterations and front-end loads, so this comparison checks event PKs against the back-end row instead of the actual last-loaded row, causinguseSnapshot[index]to be set incorrectly and ultimately producing wrong parameter selection inchooseParameters. The author's own "TODO: why peek last loaded result as true?" comment confirms this is unverified. - [MAJOR] functional
AdaptiveQuery.createCompoundQuery: AftersubQuery.reset(true, true)+forceDynamicOperation(), callssetWorkerRefererenceRecord(subQuery, i). When the worker subQuery is aPreselectQuery,PreselectQuery.setReferenceRecordemitsLOG.severe("Attempting to set a reference record for the preselect query")unconditionally. Since preselect-as-worker is a supported case (per change 243: "a CompoundQuery can join a preselect with a RandomAccessQuery"), this produces SEVERE log spam on every AQ-to-CQ degradation involving a PreselectQuery. Either downgrade the log level, gate it behindLOG.isLoggable, or skip the call insetWorkerRefererenceRecordfor PreselectQuery workers. - [MAJOR] functional
CompoundQuery.loadByValues: TheConditionExceptioncatch setsactiveIndex = -1(whenloadAll) beforerestore = true; throw exc;. The newfinallyblock then unconditionally overwritesactiveIndexwitholdActiveIndex(becauserestore == true), silently erasing the-1catastrophic-failure marker that the original code preserved and that other paths in this class (e.g.,retrieveImpl) use to signal "compound query state is invalidated." Apply the-1after thefinally, or use a separate flag so theConditionExceptionpath skips the restore. - [MINOR] style
AbstractCursor.ReferenceRowAssigner: Javadoc typo "tru" should be "true" at lines 730 and 796. - [MINOR] style
RandomAccessCursor.FrontReferenceRowAssigner: Javadoc typo "tru" should be "true" at line 1289. - [MINOR] style
AbstractCursor.ReferenceRowAssigner: Blank line before closing brace}of the interface body violates "No blank lines before }". - [MINOR] style
AbstractCursor.loadReferenceRecord: Method signature wraps parameters but leaves trailing whitespace after commas; parameters should be aligned properly without trailing whitespace. - [MINOR] style
AbstractCursor.BackReferenceRowAssigner: Class declaration placesimplements ReferenceRowAssigneron its own line but has trailing whitespace on both the class-name line and the implements line. - [MINOR] style
RandomAccessCursor.FrontReferenceRowAssigner: Class declaration placesimplements ReferenceRowAssigneron its own line but has trailing whitespace on both lines. - [MINOR] style
AdaptiveQuery.setReferenceRecord: Method overridesJoinable.setReferenceRecordbut is missing the@Overrideannotation (inconsistent withRandomAccessQuery). - [MINOR] style
PreselectQuery.setReferenceRecord: Method overridesJoinable.setReferenceRecordbut is missing the@Overrideannotation. - [MINOR] style
RandomAccessCursor.isRandomAccess: Missing@Overrideannotation; this method overrides the parentAbstractCursor/Cursordeclaration. - [MINOR] style
AdaptiveQuery.buildCompoundFromAdaptive: Trailing whitespace afterif (this.cursor != null)(extra space before{). - [MINOR] style
AdaptiveQuery.previous: Trailing whitespace blocks added (e.g.try {and{at multiple places). - [MINOR] style
AbstractCursor.loadReferenceRecord: Lineif (assigner.isNull())has trailing whitespace before{. - [MINOR] style
ScrollingCursor.storeReferenceRows: Many lines added by this diff have trailing spaces (100+ trailing-whitespace additions across files), violating clean formatting. - [MINOR] style
AbstractCursor.header/RandomAccessCursor.header/ScrollingCursor.header: Newly added history lines include trailing whitespace. - [MINOR] style
CompoundQuery.header: Continuation history entry " 20260430 Prior to retrieve, the load should not corrupt the counters when failing." is missing the author initials (AL2) where other continuation entries include them. - [MINOR] performance
AbstractCursor.loadReferenceRecord: The refactor replaces direct array reads/writes in the per-buffer loop with virtualReferenceRowAssignerinterface calls (isNullAt,setAt) plus a field dereference through the assigner's cursor reference. With two implementations (BackReferenceRowAssigner/FrontReferenceRowAssigner) the call site is bimorphic and HotSpot can usually inline, but the abstraction does add hot-path overhead compared to the previous direct array access. Consider whether the indirection is needed when both implementations differ only in which array field they index. - [MINOR] performance
CompoundQuery.skipReset: The new method initializescounterswith a manualforloop setting each element to 1. Replace withArrays.fill(this.counters, 1);for consistency with theArrays.fillusage already present in this diff for the reference-row arrays. - [MINOR] functional
RandomAccessCursor.moveQueryAtFrontEnd/moveQueryAtBackEnd: For non-CompoundQuery(singleAdaptiveQuerycursor), the methods replace the previous iterativetryMoveAtEndretry loop (introduced by change 003 specifically to defend against boundary-record deletions, with comment "Move to end could end up in infinite loop if a record was deleted") with a singlestoreReferenceRows(null)that probes only the last loaded result. If that boundary row is evicted/deleted, the reference row remains null and there is no retry across other cached rows; the query then falls back toactivateNext/activatePreviousfrom a possibly-stalebuffer.getSnapshot()whilelastLoadedResultIsAtEndhas already been flipped, leaving the query positioned inconsistently with the cursor's expected end. The fallbackactivateFirst/activateLastpath mitigates the worst case only when the buffer snapshot is also null.
#15 Updated by Alexandru Lungu about 2 months ago
- % Done changed from 100 to 90
- Status changed from Review to WIP
[MAJOR] functional CompoundQuery.stateChanged: Replaces cursor.peekLastLoadedResult() with cursor.peekLastLoadedResult(true)
This is not a regression, but rather applying the default from trunk explicitly. I opened another task (...) to analyze this as a potential functional issue. It is not introduced by this task.
[MAJOR] performance RandomAccessCursor.storeReferenceRows
Hmm, I think that query.getTableCount() == 1 && limitation can be lifted, in order to avoid setting the reference row of a CQ on each DMO change. I need to test this.
[MAJOR] functional AdaptiveQuery.createCompoundQuery
I think the only way to achieve this is to have a cross-databse join that generates a CQ with PQ elements. But in this case, the optimizer won't be able to join in a multi-table query the components, so we can't invalidate it to get back the PQ. Thus, I think this is an impossible execution path. I will investigate the logs when running the tests for such warning.
[MAJOR] functional CompoundQuery.loadByValues
I am no longer restoring if a ConditionException it thrown.
[MINOR] functional RandomAccessCursor.moveQueryAtFrontEnd / moveQueryAtBackEnd
I will look more into this.
Fixed all other items, all non-functional. (10962a/16579)
#16 Updated by Alexandru Lungu about 2 months ago
- Related to Bug #11501: Investigate useSnapshot usage in multi-table indexed-reposition queries added
#17 Updated by Alexandru Lungu 29 days ago
- Status changed from WIP to Internal Test
- % Done changed from 90 to 100
Hmm, I think that query.getTableCount() == 1 && limitation can be lifted, in order to avoid setting the reference row of a CQ on each DMO change. I need to test this.
This is a limitation that is not affecting the functionality or the performance (in a bad way). Removing it may improve performance and I am not sure of functionality yet.
I will eventually leave this opened to assess this last bit with lower priority. The point is that there are already plenty of changes in #10962 and #10803 already requires them for a production system. In other words, I am moving this in Internal Test to ensue that current work is stable so we can deliver it to the customers and look into the extra performance thing with lower priority. Again, it is not a regression, but a potential optimization.
Stefanel, please help with testing:
- I will test the large GUI application in #10803 (unit tests and smoke tests)
- please test the other application REST API tests.
- I will go ahead with ETF and ChUI tests.
- If time permits, I will also go with a test over the POC I have installed.
#18 Updated by Stefanel Pezamosca 28 days ago
- File CompoundOuterJoinTest.cls added
Alexandru Lungu wrote:
Stefanel, please help with testing:
- please test the other application REST API tests.
Didn't get any issues using 10962a.
Also these are the results from the testcases uploaded in #10803-23 with 10962a:
└─ tests.CompoundOuterJoinTest ✔
├─ testOuterJoinQueryEef1 ✔
├─ testOuterJoinQueryEef2 ✔
├─ testOuterJoinQueryEee1 ✘ Expected: 1,1,?|1,1,1|1,2,1|2,1,?|2,1,2|2,2,2| but was: 1,1,?|1,2,1|2,1,?|2,2,2|
├─ testOuterJoinQueryEee2 ✘ Expected: 1,1,1|1,1,1|1,2,1|1,2,1|2,1,?|2,1,2|2,2,2| but was: 1,1,1|1,1,1|1,2,1|1,2,1|2,1,?|2,2,2|
├─ testOuterJoinQueryEfe1 ✘ Expected: 1,1,?|1,1,1|2,1,?|2,1,2| but was: 1,1,?|2,1,?|
├─ testOuterJoinQueryEfe2 ✘ Expected: 1,1,1|1,1,1|2,1,?|2,1,2| but was: 1,1,1|1,1,1|2,1,?|
├─ testOuterJoinQueryEff1 ✔
└─ testOuterJoinQueryEff2 ✘ Expected: 1,1,1|2,1,?| but was: 1,1,1|1,2,1|2,1,?|
With trunk:
└─ tests.CompoundOuterJoinTest ✔
├─ testOuterJoinQueryEef1 ✘ Expected: 1,1,?|1,2,1|2,1,?|2,2,2| but was: 1,1,?|1,1,1|1,2,1|2,1,?|2,1,2|2,2,2|
├─ testOuterJoinQueryEef2 ✘ Expected: 1,1,1|1,2,1|2,1,?|2,2,2| but was: 1,1,1|1,2,1|2,1,?|2,1,2|2,2,2|
├─ testOuterJoinQueryEee1 ✘ Expected: 1,1,?|1,1,1|1,2,1|2,1,?|2,1,2|2,2,2| but was: 1,1,?|1,2,1|2,1,?|2,2,2|
├─ testOuterJoinQueryEee2 ✘ Expected: 1,1,1|1,1,1|1,2,1|1,2,1|2,1,?|2,1,2|2,2,2| but was: 1,1,1|1,1,1|1,2,1|1,2,1|2,1,?|2,2,2|
├─ testOuterJoinQueryEfe1 ✘ Expected: 1,1,?|1,1,1|2,1,?|2,1,2| but was: 1,1,?|1,1,1|2,1,?|
├─ testOuterJoinQueryEfe2 ✘ Expected: 1,1,1|1,1,1|2,1,?|2,1,2| but was: 1,1,1|1,1,1|1,1,1|1,1,1|2,1,?|
├─ testOuterJoinQueryEff1 ✘ Expected: 1,1,?|2,1,?| but was: 1,1,?|1,1,1|2,1,?|
└─ testOuterJoinQueryEff2 ✘ Expected: 1,1,1|2,1,?| but was: 1,1,1|1,2,1|2,1,?|2,2,?|
I will also test with 10962a+10803a to see if it fixes other things. The duplication issue reported in #10803 is fixed by 10962a alone.#19 Updated by Alexandru Lungu 25 days ago
- Status changed from Internal Test to Merge Pending
POC testing passed.
ETF testing passed.
Preparing to merge.
#20 Updated by Alexandru Lungu 25 days ago
- Status changed from Merge Pending to WIP
- % Done changed from 100 to 90
Branch 10962a was merged to trunk rev 16623 and archived.
This is opened for assessing potential performance improvements from #10962-15.