Bug #10385
Incorrect reset of the cursor cache in Reposition
100%
History
#1 Updated by Eduard Soltan 12 months ago
- Subject changed from Infinite loop in scrolling query to Incorrect reset of the cursor cache in Reposition
I have the following test case:
def temp-table tt1 field a1 as int
field a2 as char
field a3 as int
field a4 as logical
index main_idx a1 a2 a3.
def temp-table tt2 field b1 as int.
create tt1.
tt1.a1 = 3.
tt1.a2 = "abc".
tt1.a3 = 4.
tt1.a4 = yes.
release tt1.
create tt2.
tt2.b1 = 4.
release tt2.
def var qh as handle.
create query qh.
qh:set-buffers (buffer tt1:handle, buffer tt2:handle).
qh:QUERY-PREPARE ("for each tt1 where tt1.a4 = yes, first tt2").
qh:query-open().
qh:get-first().
tt1.a1 = 4.
message "1 " available(tt1) tt1.a1.
qh:get-next().
message "2 " available(tt1) tt1.a1.
qh:get-next().
message "3 " available(tt1).
The qh is a scrolling query. Lets suppose the that we have just one record in tt1 temp-table with recid 1432.
qh:get-first() will find this record and set it in the tt1 buffer. If I modify a index field in tt1 record , qh:get-next() will find the record with recid 1432 another time, this also happens in OE.
In fwd qh is transformed into a AdaptiveQuery, and for every record found it's recid is added in results array of the Cursor and also in repoCache.
Step by step break down of what happens in fwd:
1) qh:get-first() find the record with recid 1432. In Cursor.results is added 1432 value and repoCache is added a new entry 1432 -> 0.
2) index field a1 is updated.
3) qh:get-first() find the record with recid 1432 and updated index field. In Cursor.results is added another 1432 value (now look like [1432, 1432]), however repoCache is not updated. This will cause later issues, in CompoundQuery processing.
#3 Updated by Eduard Soltan 12 months ago
- Status changed from New to WIP
Committed on 10385a, rev. 16090.
#4 Updated by Eduard Soltan 12 months ago
- Status changed from WIP to Review
- % Done changed from 0 to 100
- reviewer Alexandru Lungu added
#5 Updated by Eduard Soltan 12 months ago
- reviewer Eric Faulhaber added
- reviewer deleted (
Alexandru Lungu)
#6 Updated by Constantin Asofiei 11 months ago
- Assignee set to Alexandru Lungu
- reviewer Alexandru Lungu added
Alex, please review this. To me the change makes sense.
#7 Updated by Alexandru Lungu 11 months ago
Eduard,
3) qh:get-first() find the record with recid 1432 and updated index field. In Cursor.results is added another 1432 value (now look like [1432, 1432]), however repoCache is not updated. This will cause later issues, in CompoundQuery processing.
Did you meant another qh:get-next(), right? If you do get-first a second time, the Cursor will get you the original cursor data. If you do get-next, indeed you will reach [1432, 1432].
Review of 10386a:
- I don't think the changes are right (or maybe I am misinterpreting something). The
repoCacheis a complex structure that maps a list of PK to a specific Row. If you have two list of PK that are identical and map to the same Row, the first prevails in OE. Try:
qh:reposition-to-rowid(rowid(tt1)). // it will reposition to the FIRST row in the cursor in OE qh:get-next().// it will go to the SECOND row in the cursor in OE
With 10385a (although not tested), I think the reposition will go to the SECOND row directly, because it was overridden. With trunk, the add is ignored, so the reposition will in fact go (theoretically) to the first row.
#8 Updated by Alexandru Lungu 11 months ago
Assignee set to Alexandru Lungu
Constantin, shall I pick this up or was it a mistake changing the Assignee?
#9 Updated by Constantin Asofiei 11 months ago
- Assignee changed from Alexandru Lungu to Eduard Soltan
Alexandru Lungu wrote:
Assignee set to Alexandru Lungu
Constantin, shall I pick this up or was it a mistake changing the Assignee?
I meant to set the reviewer.
#10 Updated by Eduard Soltan 11 months ago
Alexandru Lungu wrote:
- I don't think the changes are right (or maybe I am misinterpreting something). The
repoCacheis a complex structure that maps a list of PK to a specific Row. If you have two list of PK that are identical and map to the same Row, the first prevails in OE. Try:[...]
With 10385a (although not tested), I think the reposition will go to the SECOND row directly, because it was overridden. With trunk, the
addis ignored, so the reposition will in fact go (theoretically) to the first row.
You are right, reposition-to-rowid(rowid(tt1)) statement will reposition the cursor to the first element of the list.
In fact I think this is the whole problem. If you look in CompoundQuery.loadByValues, which in turn will call the query.load on each compound component. Looking at the load method in different query class (AdaptiveQuery, etc) there is a call for repositionByID method which will move the cursor to the first element in the list. next will move the cursor to the second element, because of this juggling we get a infinite loop.
#11 Updated by Eduard Soltan 11 months ago
Committed on 10385a, rev. 16091.
repositionByID is performed only in certain cases, added additional reposition parameter to loadByValues and load methods.
#12 Updated by Alexandru Lungu 11 months ago
- Status changed from Review to WIP
- % Done changed from 100 to 80
repositionByID is performed only in certain cases, added additional reposition parameter to loadByValues and load methods.
I don't quite understand why loadByValues shouldn't reposition.
Looking at the load method in different query class (AdaptiveQuery, etc) there is a call for repositionByID method which will move the cursor to the first element in the list.
Exactly. If the first component (EACH) has a cursor with ids [1, 2, 3], then loadByValues should reposition this first component to the right place. If you need to do something like loadByValues with id 1, then the AdaptiveQuery should be repositioned to value 1
For example, if you run GET FIRST + GET NEXT + GET NEXT, the AdaptiveQuery will have [1, 2, 3] cursor and it is positioned on the 3rd slot. If you run FIRST again, then the AdaptiveQuery should still have cursor [1, 2, 3], but positioned to the 1st slot (aka repositioned). This applies to the case where the AdaptiveQuery is a component of a CompoundQuery.
next will move the cursor to the second element
This is not a correct statement. If you reposition a query, it will get in between records (unless it is BROWSED). That means that GET FIRST will reposition the AdaptiveQuery component to 0.5 position. The upcoming cursor.next(); will simply put it on position 1.
Eduard, is #10385-1 the complete replicate?
PS: very improbable to be related, but I am still mentioning here: #10026
#13 Updated by Eduard Soltan 11 months ago
- % Done changed from 80 to 100
Alexandru Lungu wrote:
Exactly. If the first component (EACH) has a cursor with ids [1, 2, 3], then
loadByValuesshould reposition this first component to the right place. If you need to do something likeloadByValueswith id 1, then theAdaptiveQueryshould be repositioned to value 1For example, if you run GET FIRST + GET NEXT + GET NEXT, the AdaptiveQuery will have [1, 2, 3] cursor and it is positioned on the 3rd slot. If you run FIRST again, then the AdaptiveQuery should still have cursor [1, 2, 3], but positioned to the 1st slot (aka repositioned). This applies to the case where the AdaptiveQuery is a component of a CompoundQuery.
I understand the need to reposition the the cursor, I took care of that in my latest changes. Although not for NEXT/PREV, I am still in doubt whether it should be executed (inclined to say yes). I guess what really bothers me is this code in CompoundQuery.retrieveImpl:
if (activeIndex > 0 && isScrolling())
{
Object[] row = cursor.getRow();
if (row != null)
{
loadByValues(row, lockType, false);
}
}
Suppose I have in cursor [1432, 1432], cursor is set on the second element. After the execution of loadByValues and subsequently of the repositionById from AdapativeQuery.load, cursor gets moved to the first element of the cursor. I mean neither FIRST/LAST/NEXT/PREV is executed at this point, and we just introduce some side effects.
#14 Updated by Eduard Soltan 11 months ago
- % Done changed from 100 to 80
#15 Updated by Alexandru Lungu 11 months ago
Suppose I have in cursor [1432, 1432], cursor is set on the second element. After the execution of loadByValues and subsequently of the repositionById from AdapativeQuery.load, cursor gets moved to the first element of the cursor. I mean neither FIRST/LAST/NEXT/PREV is executed at this point, and we just introduce some side effects.
Ah, I see. CompoundQuery does a NEXT and forces its AQ to load that second row, but AQ actually loads the first row because of the 4GL behavior (or loading the first row with that id). Is this the core problem here?
Apparently, loadByValues has this invariant that rows are unique, but this is not true apparently. CompoundQuery thinks that it is uniquely loading the data in the AQ, but in fact it is susceptible to this invariant flaw (cursor not having unique id).
I indeed think now that the reposition should only occur when actually using REPOSITION statement. The FIRST/NEXT/PREV/LAST should be smarter to avoid the repositioning quirk with multiple equal ids in the cursor.
I need to put more thought on it, but my very first idea is about making the repoCache actually store a counter for that id - so that rows are unique? Instead of storing the id, to store a <id, crt> pair. By default, any reposition will use <crt> set on 0, but internal usages of the reposition (e.g. triggered by loadByValues) to actually use the a more described reposition. Maybe we can encapsulate the pair as a "CursorLocator" and pass it around. Using .toIds to get only the ids just like before and .toPairs to get the list of pairs as needed by loadByValues. Eventually, .toFirstPairs to get the list of pairs where the "crt" is aggressively set to 0 (to make it work nicely with the reposition).
#16 Updated by Eduard Soltan 11 months ago
Alexandru Lungu wrote:
Ah, I see.
CompoundQuerydoes a NEXT and forces its AQ to load that second row, but AQ actually loads the first row because of the 4GL behavior (or loading the first row with that id). Is this the core problem here?
This is exactly the problem.
#17 Updated by Eduard Soltan 11 months ago
Experimented a bit with the query a scrolling query.
def temp-table tt1 field a1 as int
field a2 as char
field a3 as int
field a4 as logical
index main_idx a1 a2 a3.
def temp-table tt2 field b1 as int.
create tt1.
tt1.a1 = 3.
tt1.a2 = "first".
tt1.a3 = 4.
tt1.a4 = yes.
release tt1.
create tt1.
tt1.a1 = 5.
tt1.a2 = "second".
tt1.a3 = 1.
tt1.a4 = no.
release tt1.
create tt1.
tt1.a1 = 11.
tt1.a2 = "third".
tt1.a3 = 14.
tt1.a4 = yes.
release tt1.
define query qh for tt1 scrolling.
OPEN QUERY qh FOR EACH tt1.
get first qh. // cursor [2304]
def var fRecId as rowid.
fRecId = rowid(tt1).
message "1 " available(tt1) tt1.a1 recid(tt1).
get next qh. // cursor [2304, 2305]
message "2 " available(tt1) tt1.a1 tt1.a2.
tt1.a1 = 11.
tt1.a2 = 'changed1'.
tt1.a3 = 15. // cursor [2304, 2305, 2305]
get next qh. // cursor [2304, 2305, 2305]
message "3 " available(tt1) tt1.a1 tt1.a2.
get next qh. // cursor [2304, 2305, 2305, 2306]
message "4 " available(tt1) tt1.a1 tt1.a2.
query qh:REPOSITION-TO-ROWID(fRecId). // cursor [2304, 2305, 2305, 2306]
get next qh. // cursor [2304, 2305, 2305, 2306]
message "5 " available(tt1) tt1.a1 tt1.a2 tt1.a3.
get next qh. // cursor [2304, 2305, 2305, 2306]
message "6 " available(tt1) tt1.a1 tt1.a2 tt1.a3.
get next qh. // cursor [2304, 2305, 2305, 2306]
message "7 " available(tt1) tt1.a1 tt1.a2 tt1.a3.
tt1.a1 = 16.
tt1.a2 = 'changed1'.
tt1.a3 = 17. // cursor [2304, 2305, 2305, 2306, 2305]
get next qh. // cursor [2304, 2305, 2305, 2306, 2305]
message "8 " available(tt1) recid(tt1) tt1.a1 tt1.a2 tt1.a3.
tt1.a1 = 25.
tt1.a2 = 'changed2'.
tt1.a3 = 12. // cursor [2304, 2305, 2305, 2306, 2305, 2306]
get next qh.
message "9 " available(tt1) recid(tt1) tt1.a1 tt1.a2 tt1.a3.
get next qh.
message "10 " available(tt1) recid(tt1) tt1.a1 tt1.a2 tt1.a3.
I found with changing of a index on a iteration record will put the newly created row at the end of the cursor. I think is better to store the index of the node in the cursor array..
#18 Updated by Eduard Soltan 11 months ago
Question, when a call to Cursor.getResult is made with the cursor positioned between the rows (lets say at position: 0.5), should the method return the value at postion: 0?
In Java docs it states the following:
Retrieve the result at the cursor's current position, which should always be at an index boundary (not between result indices).
This suggest that the cursor will be in a valid position (0, 1, 2, etc) when calling this method, however from empirical observation I saw that this is not always the case.
For example, when iterating over the query for the first time cursor.getNext, which returns null, will always position the cursor at currentPosition + 0.5. And a subsequent call to cursor.getRow will return the row at current position.
#19 Updated by Eduard Soltan 11 months ago
- % Done changed from 80 to 100
- Status changed from WIP to Review
I tried removing the following code in CompoundQuery.retrieveImpl:
if (activeIndex > 0 && isScrolling())
{
Object[] row = cursor.getRow();
if (row != null)
{
loadByValues(row, lockType, false, false);
}
}
But it does cause the regression in the following unit test class tests/query/TestScrollingCompoundQuery.cls, the change was added in relation with #9148. I guess the only solution left in refactor the RepositionCache to use multiple entries for a single id.
Committed on 10385a, rev. 16093.
#20 Updated by Alexandru Lungu 11 months ago
Review of 10385a:
- Please make the Locator immutable as it will be easier to keep track of its data without being concerned that it may change unexpectedly. Instead of setting ids, consider creating new instances with the right ids.
boolean reposition = false;andreposition = true;doesn't seem relevant anymore- Add javadoc to
CursorLocator - In
PQ.repositionByID, it is weird to have the following. This is triggered when you have a query with N components and you run a reposition with M rowids (M < N).+ Long[] ids = (Long[]) locator.ids; ids = DBUtils.trimToTableCount(ids, this);
#21 Updated by Eduard Soltan 11 months ago
Alexandru Lungu wrote:
Review of 10385a:
Committed changes to handle review issues on rev. 16094.
- In
PQ.repositionByID, it is weird to have the following. This is triggered when you have a query with N components and you run a reposition with M rowids (M < N).
Doesn't DBUtils.trimToTableCount method trim the ids array only when the array size is bigger then query.getTableCount();?
#22 Updated by Alexandru Lungu 11 months ago
Doesn't DBUtils.trimToTableCount method trim the ids array only when the array size is bigger then query.getTableCount();?
Hmm, you are right. But this should still hold for Locator, right? So trimToTableCount should return another "trimmed" locator?
#23 Updated by Eduard Soltan 11 months ago
Alexandru Lungu wrote:
Hmm, you are right. But this should still hold for Locator, right? So
trimToTableCountshould return another "trimmed" locator?
Right, I committed the change of DBUtils.trimToTableCount in rev. 16095.
#24 Updated by Alexandru Lungu 11 months ago
Review of 10385a:
- Mind that
cursor.is viable is cursor is not null (i.e. the query is scrolling). If the query is not scrolling, this will rise a NPE. For instance, seeAQ.repositionByID - Please rename
CursorLocatortoLocator. Qualified references don't make much sense (Cursor.CursorLocator). - Please make position
nullinstead of -1 if you don't know the exact reposition position. Even if it seems like the position can't be negative, this will change in #9724 (where after a reposition, all prev. rows have negative indexes and next rows have positive indexes).- Don't forget about
if (locator.position -1)kind of changes, to replace withnull
- Don't forget about
#25 Updated by Eduard Soltan 11 months ago
Alexandru Lungu wrote:
Review of 10385a:
- Mind that
cursor.is viable is cursor is not null (i.e. the query is scrolling). If the query is not scrolling, this will rise a NPE. For instance, seeAQ.repositionByID- Please rename
CursorLocatortoLocator. Qualified references don't make much sense (Cursor.CursorLocator).- Please make position
nullinstead of -1 if you don't know the exact reposition position. Even if it seems like the position can't be negative, this will change in #9724 (where after a reposition, all prev. rows have negative indexes and next rows have positive indexes).
- Don't forget about
if (locator.position -1)kind of changes, to replace withnull
Committed review issues on 10385a, rev 16096.
#26 Updated by Alexandru Lungu 11 months ago
- Status changed from Review to Internal Test
- Check javadoc:
If position = -1, this means that a operation was triggered by REPOSITION-BY-ID statement.this should be changed toposition is null. next.getLastRowuses double as parameter instead ofDouble. It may be correct, but it is used inrow = next.getLastRow(locator.position);and technically the position can be null. Shall we have some null checks here?- Please double-check usages of
positionto ensure there can't be any NPE
- Please double-check usages of
TreeSet<Integer> getRows()is now mutable. This means that the caller can change the tree-set. Is this expected? If no, please wrap it inside aCollections.unmodifiableSet.- Please double-check places where this method is called and see if this is actually required. If yes, check if it is mutable.
These are minor. Please analyze these and move on with internal testing.
#27 Updated by Constantin Asofiei 10 months ago
- % Done changed from 100 to 90
- Status changed from Internal Test to WIP
ETF fails with 10385a - see #10615-29
#28 Updated by Eduard Soltan 10 months ago
Manage to recreate the problem that appeared in ETF.
def temp-table tt1 field f1 as char
field f2 as char
field f3 as char
index idx f1 f2 f3.
for each book2:
delete book2.
end.
create book2.
book2.f1 = "something1".
release book2.
create book2.
book2.f1 = "something2".
release book2.
create tt1.
tt1.f1 = "something1".
tt1.f2 = "something2".
tt1.f3 = "something3".
release tt1.
for each tt1, each book2 where book2.f1 = tt1.f1 by tt1.f1 by book2.f1:
message tt1.f1.
end.
Components of the for each gets converted to Preselect queries without any cursor properties, which cause the NPE.
I solved this problem in 10385a, rev. 16098, alongside some issues in the another customer application.
#29 Updated by Eduard Soltan 10 months ago
- Status changed from WIP to Review
- % Done changed from 90 to 100
#30 Updated by Alexandru Lungu 8 months ago
- % Done changed from 100 to 90
- Status changed from Review to WIP
I run a large unit test suite and discovered a NPE in RepositionCache$Node.getRow, on rows.floor(position.intValue()). I think the floor returns null as there is no entry to match the position. Null can't be cast to int so NPE is generated. Full stack:
│ │ [31m at com.goldencode.p2j.persist.RepositionCache$Node.getRow(RepositionCache.java:391)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.RepositionCache$Node.getLastRow(RepositionCache.java:425)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.RepositionCache.getRow(RepositionCache.java:263)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.Cursor.reposition(Cursor.java:352)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.AdaptiveQuery.repositionByID(AdaptiveQuery.java:1713)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.AdaptiveQuery.load(AdaptiveQuery.java:2198)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.CompoundQuery.loadByValues(CompoundQuery.java:2793)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.CompoundQuery.loadByValues(CompoundQuery.java:2714)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.CompoundQuery.next(CompoundQuery.java:1390)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.CompoundQuery.next(CompoundQuery.java:1336)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.AbstractQuery.wrapNext(AbstractQuery.java:4533)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.AbstractQuery.getNext(AbstractQuery.java:2402)[0m │ │ │ │ │ [31m at com.goldencode.p2j.persist.QueryWrapper.getNext(QueryWrapper.java:5075)[0m
This is a CompoundQuery next operation that finds data in the cursor. It is loaded in the components using loadByValues, but one of the components AdaptiveQuery doesn't have the respective id in the rows or the floor is done incorrectly.
Let me know if you want me to dig in further and find the exact replication test-case.
#31 Updated by Eduard Soltan 6 months ago
Alexandru Lungu wrote:
This is a
CompoundQuerynext operation that finds data in the cursor. It is loaded in the components usingloadByValues, but one of the componentsAdaptiveQuerydoesn't have the respective id in the rows or the floor is done incorrectly.Let me know if you want me to dig in further and find the exact replication test-case.
I have set up the customer application, and debugged the issue. The problem is that a Cursor.locator is send from CompoundQuery to one of its components, but along the way cursor.position gets messed up with the cursor position of its component.
I committed the fix on 10385a, rev. 16357. I do however have to test it on over application.
#32 Updated by Eduard Soltan 6 months ago
Alex, I have rebased 10385a, to trunk rev. 16359. I had a lot of conflicts with rev. 16348, could you check if the changes from 11019a are correctly preserve.
#33 Updated by Alexandru Lungu 6 months ago
Review:
- please fix javadoc of parameter locator in
Cursor.reposition,Cursor.getLocator(both overloads),Cursor.getRepositionLocator,Locatorc'tor (both overloads),AdaptiveQuery.load,AQ,getCachedRow(javadoc for load),RAQ.load,trimToTableCount- Please do a second pass to check if javadoc is correct.
- move
getLocatorpublic methods before the private ones AQ.loadcan haveLong[] data = locator.ids;instead ofObject[] data = locator.ids;- Please let
getLocatorinputObject[] dataand useCursor.onlyIdto do the id extraction.CQ.retreieveImplnow has PK extraction that is implemented already in Cursor. - Make Locator constructors protected so that only
Cursorcan create new locators withgetLocatorandgetRepositionLocator.- Remove one c'tor, replace
Locator(Long[] ids, double position)withLocator(Long[] ids, Double position). Let caller send null for it.
- Remove one c'tor, replace
#34 Updated by Eduard Soltan 6 months ago
I have fixed review issues. Tested some customer applications. There is something which does not make sense to me in CQ.retrieveImpl:
Object[] subData = P2JQueryExecutor.getInstance().execute(database,
(String) null,
CompoundQuery::processComponent,
this,
navigation,
comp,
counters[i],
lockType,
peek);
try
{
Cursor.Locator locator = cursor.getLocator(subData);
comp.getQuery().load(locator, lockType, false);
}
catch (MissingRecordException exc)
{
continue;
}
Lets say a record is retrieved in a child component with a recid 1453, at this moment we should update the cursor of the child component. After that we return to CQ, and do a load on the same component that we just retrieved. It seems a bit unnecessary to me, and potentiality prone to the same issue which this task address.
Lets suppose I have the cursor for the first query component:
Position: 4 Cursor: [1415, 1416, 1417, 1418, 1416]
I have the cursor position on 4, the next record retrieved will have the recid: 1417 and the cursor should be updated in the following manner:
Position: 5 Cursor: [1415, 1416, 1417, 1418, 1416, 1417]
And this will happen in after the execution of CompoundQuery::processComponent method. However after load, the position will be changes 2 (in case of trunk), but 10385a will thrown an NPE.
#35 Updated by Alexandru Lungu 6 months ago
Eduard, please mind the peek option. In peek mode, you do not load buffers, but rather gather the actual data that should be loaded. In non-peek mode, you just load the buffers and move on. This is on top of my head, maybe there are other details.
Also mind that in #11021, I removed the reposition in child components when doing loadByValues and it worked mostly fine, except some errors in the browse in ChUI. If this is bothering you, please pick up the investigation effort in #11021 and lets get that fixed first. It will skip the extra friction to maintain that code in this solution.
#36 Updated by Eduard Soltan 6 months ago
Alexandru Lungu wrote:
Also mind that in #11021, I removed the reposition in child components when doing
loadByValuesand it worked mostly fine, except some errors in the browse in ChUI. If this is bothering you, please pick up the investigation effort in #11021 and lets get that fixed first. It will skip the extra friction to maintain that code in this solution.
Yes, that is exactly what is bothering me.
#37 Updated by Eduard Soltan 6 months ago
Alexandru Lungu wrote:
Also mind that in #11021, I removed the reposition in child components when doing
loadByValuesand it worked mostly fine, except some errors in the browse in ChUI. If this is bothering you, please pick up the investigation effort in #11021 and lets get that fixed first. It will skip the extra friction to maintain that code in this solution.
With the elimination of reposition in CQ components, I think the changes in this task are no longer required.
#38 Updated by Eduard Soltan 4 months ago
- % Done changed from 90 to 100
- Status changed from WIP to Internal Test
This task is the same issue as 11021, so it can be closed.