Repository navigation
Description
Split out of #8260, item 1 ("BucketIterator.fetchNext() looks each record up two more times").
What #8260 found
For an in-page record, BucketIterator.fetchNext() (engine/src/main/java/com/arcadedb/engine/BucketIterator.java) already holds currentPage, the record's recordPositionInPage and its recordSize marker, but still resolves the record through:
bucket.existsRecord(rid)- re-reads the same marker through a fresh page-manager lookup of the same page, under its own per-file permission check.database.lookupByRID(rid, false)- builds a lazy shell (no existence check of its own).- On first property access,
ImmutableDocument.checkForLazyLoading()re-fetches the content viabucket.getRecord(rid), a third independent page-manager lookup, this time undercheckPermissionsOnFileagain too.
The reporter measured removing just the first of these (their "prototype A") at -72% on a bare iterateType scan, -17% to -18% once a property is read, and -8% to -14% on the full SQL GROUP BY/scan-count paths (Temurin 21, TPC-H-shaped LineItem, full numbers and repro in #8260).
Why this is a separate issue, not part of #8228/#8260's first PR
I prototyped the straightforward fix - existsRecord() removed, lookupByRID(rid, false) kept as-is - and it passes the full engine test suite (2324 tests, com.arcadedb.query.sql.parser.** + com.arcadedb.query.sql.executor.**, plus the targeted event/lazy-loading/corrupted-scan suites). Its correctness cost is real but negligible: TransactionContext.getPage() does not cache per-transaction under READ_COMMITTED, so existsRecord()'s fresh read can occasionally observe a delete that currentPage's already-read marker predates, and removing it narrows that detection to whenever the record is eventually lazy-loaded instead. In practice this does not widen the window in any way that matters: the dominant, unbounded gap - from shell creation to whenever a caller actually touches a property - is untouched either way, existsRecord() only ever covered the sub-microsecond gap between itself and lookupByRID().
I then tried to close that gap for real - construct the record straight from currentPage's bytes (recordSize[0] > 0 case), the way LocalBucket.scan() and this same method's PLACEHOLDER branch already do, firing AfterRecordReadListener/BeforeRecordReadListener immediately instead of deferring them to first property access. This is where it gets interesting: ImmutableVertex.modify() (and ImmutableDocument.modify(), for a record not yet tracked via getPageToModify()) reload the record UNCONDITIONALLY on first modify(), regardless of whether its buffer is already populated. So a record built eagerly in the scan still pays a second, mandatory fetch and fires the read listeners a second time on the ordinary "scan, then modify" pattern - which is about as common as it gets in a graph database - and broke DatabaseEventsTest#afterRead / #beforeRead (both assert the listener fires exactly once).
Closing the race properly therefore means also changing modify()'s unconditional-reload invariant (skip the reload when the buffer is already fresh and untouched), which is a separate, correctness-critical change to a different code path, deserving its own review and its own regression tests rather than riding along on a scan-performance PR.
Suggested follow-up
- Ship the safe part (
existsRecord()removed from the in-page branch, everything else unchanged) as a small, low-risk PR on its own. - Separately, look at whether
ImmutableVertex.modify()/ImmutableDocument.modify()can skip their unconditional reload when the record's buffer is already populated and not concurrently invalidated - only after that lands does eager construction inBucketIterator(closing the race and dropping the third lookup too) become safe to attempt again.
Activity
fix(ArcadeData#8260): GROUP BY no longer rebuilds the default projectβ¦
tae898 commentedon Sep 24, 2026
One more data point for step 2, from checking whether the SQL parallel scan would help. The same 2,000,000 rows went into a default type and into a type created with
BUCKETS 4, soarcadedb.queryParallelScanengages (PROFILEshowsFETCH FROM TYPE Four (parallel), and theArcadeDB-ParallelScanProducerthreads run). 4 cores, snapshot5b040a438e, two rounds each:GROUP BY l_shipmode, count(*), Temurin 21count(*) WHERE l_quantity >= 0, Temurin 21iterateTypeloop (never parallel), Temurin 21So the parallel scan makes this
GROUP BY24% to 35% slower and the scan count no faster. A JFR profile of the 4-bucketGROUP BY, split by thread, shows why. The producers do the cheap half:fetchNext,existsRecordandlookupByRID(rid, false). The consumer thread still does the expensive half:checkForLazyLoading()intoLocalBucket.getRecord(12.9% of all samples, 0% on the producers), the deserialization, the projection and the aggregation. The producers hand over lazy shells, so the content load that the parallel scan exists to spread stays on one thread. On top of that, every record crosses theLinkedBlockingQueueone at a time:pollis 8.5% of all samples, most of it waking a producer throughsignalNotFull().Two things follow, offered only as input to how you sequence this. Step 2 (content loaded in the iterator) is also what would let the parallel scan spread the load at all. And the per-record handoff could move in batches, a list per
offer, which does not depend on themodify()question. I have not prototyped either.BucketParallelScan.java
lvca commentedon Sep 24, 2026
That's a great idea, moving the
checkForLazyLoading()into the parallel fetch so it goes in parallel. Also the LinkedBlockingQueue could be not the most efficient choice for high concurrency. I'll take all this into consideration.fix(#8265,#8266): silence a PMD false positive on the sentinel identiβ¦
fix(#8265): parallel scan producers must respect loadContent()'s filtβ¦
docs(#8265): note the accepted LIMIT deserialization trade-off in theβ¦
fix(#8265): parallel-scan queue capacity rounds up instead of truncating
fix(#8265): bound parallel-scan batches by bytes, not just row count
docs(#8265): edges are ImmutableDocuments too, so the parallel scan lβ¦
fix(#8265): a non-positive queryParallelScanMaxBatchBytes disables thβ¦
fix(#8265): bound the rows a parallel-scan producer reads per read-loβ¦
Merge pull request #8311 from ArcadeData/fix/8265-8266-scan-double-loβ¦
lvca commentedon Sep 24, 2026
Fixed in #8311, available in 26.10.1.
On 1M rows,
GROUP BYwent from 408 ms to 314 ms on a 1-bucket type and from 601 ms to 324 ms on a 4-bucket type, so the parallel scan is no longer slower than the plain one. A bareiterateType()loop is about 3x faster.A new setting,
arcadedb.queryParallelScanMaxBatchBytes(default 16MB), caps how much record data one batch of the parallel scan holds in memory. Records hidden by an after-read event are now left out of parallel scan results.Not included: building records straight from the page during a plain (non-parallel) scan. That needs
modify()to stop reloading a record it already has, which deserves its own change.β€οΈ Thanks for helping make ArcadeDB better! ArcadeDB is free and open source, sustained by its community. If it's useful to you or your company, please consider becoming a sponsor to keep development going.
lvca commentedon Sep 24, 2026
@tae898 can't wait to see how much we improved!
perf(ArcadeData#8265,ArcadeData#8266): scans stop re-resolving each rβ¦
CAMPAIGN re-pin checklist: the bucket count re-opens (ArcadeData#8265 β¦
tae898 commentedon Sep 25, 2026
Measured on #8260 (#8260), together with #8266 since both landed in #8311: on Temurin 25 with compact object headers and on Temurin 21, the bare scan is 2.2x to 2.3x faster and a full-scan
GROUP BY1.34x to 1.36x, and the parallel scan now wins, four buckets about 1.4x faster than one where they were 35% slower before. One question about the default bucket count is there too. Thanks!