This is an automated email from the ASF dual-hosted git repository.

quinnj pushed a commit to branch core-rewrite
in repository https://gitbox.apache.org/repos/asf/arrow-julia.git


The following commit(s) were added to refs/heads/core-rewrite by this push:
     new d3ea7a7  fix: resolve round 64 findings — Arrow-owned range reads, 
ETag pinning, one body round
d3ea7a7 is described below

commit d3ea7a7cf16001985a0237e18c9c873307acbc6c
Author: Jacob Quinn <[email protected]>
AuthorDate: Wed Aug 19 02:01:01 2026 -0600

    fix: resolve round 64 findings — Arrow-owned range reads, ETag pinning, one 
body round
    
    - (H) `readranges` is gone from the source interface: Arrow reads a round's
      planned spans itself, through `readrange`, with a worker pool of
      `concurrentreads(src)` tasks (default 1) pulling requests off one counter
      and storing every result by request index — a transport's completion
      order can never permute payloads, and the reads in flight are bounded
      whatever the span count. `readrange` results are type- and
      length-checked; the docstring states the trusted-source boundary (Arrow
      cannot authenticate the bytes a source returns for a range)
    - (M) CloudStore extension: every range GET carries `If-Match` with the
      object's ETag, so a handle is pinned to one object version — an
      overwritten key fails the next read instead of mixing versions across
      request rounds (pinned against Minio); `concurrentreads` is 16
    - (M) the planner fetches the selected dictionary bodies and the selected
      record buffers in ONE round (dictionaries decode first from the shared
      spans); the dead `blockwants` map is gone
    - (L) a Footer larger than the tail window is read once and cached on the
      handle; ranges the cached tail already covers are served from it without
      a request (a file no larger than the window costs exactly one request)
    - (L) `sourcelength` is validated as an Integer before conversion; the
      coalescer's negative-range guard is a `ValidationError`
    - (L) `Arrow.Table(src)` without a scan, with an unpushable scan, over a
      zero-field file, or over a stream-format object reads the object whole
      (the cached tail plus its prefix: two requests) — what the manual and
      docstring say; the DESIGN request-count model matches
    - tests: facade pins for request rounds through the public path, tail-once,
      tail-cache serving, bounded concurrency with out-of-order completion, and
      every contract violation (short/long/wrong-type payloads, invalid
      lengths) as ValidationError; the ext test pins ETag rejection; the
      battery's coalescing check plans outside a small tail
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
---
 docs/dev/DESIGN-scan-ranges-trim.md |  27 +++--
 docs/dev/core-README.md             |   2 +-
 docs/src/manual.md                  |  10 +-
 docs/src/reference.md               |   2 +-
 ext/ArrowCloudStoreExt.jl           |  35 ++++---
 src/scan.jl                         | 194 ++++++++++++++++++++++--------------
 src/source.jl                       |  25 +++--
 src/table.jl                        |  60 ++++++-----
 test/cloudstore_tests.jl            |  18 +++-
 test/facade_tests.jl                | 113 +++++++++++++++++++++
 test/scan_battery.jl                |  10 +-
 11 files changed, 341 insertions(+), 155 deletions(-)

diff --git a/docs/dev/DESIGN-scan-ranges-trim.md 
b/docs/dev/DESIGN-scan-ranges-trim.md
index 0d888d6..60670a7 100644
--- a/docs/dev/DESIGN-scan-ranges-trim.md
+++ b/docs/dev/DESIGN-scan-ranges-trim.md
@@ -153,7 +153,7 @@ sequential — cloud-native access is a file-format feature, 
stated plainly.
    `tailbytes` (default 64 KiB). Its trailing magic decides file vs stream
    format (a stream object is read whole instead), and it covers
    footer-length + magic + the whole Footer in almost every real file; if
-   `footerlen + 10 > tailbytes`, one exact follow-up fetch.
+   `footerlen + 10 > tailbytes`, one exact follow-up fetch (also cached).
    → schema, Block indexes, (§3) statistics — everything pruning needs.
    The leading magic is not fetched: the Footer is the sole authority.
 2. **Statistics prune** from the Footer metadata — zero additional fetches.
@@ -168,9 +168,11 @@ sequential — cloud-native access is a file-format feature, 
stated plainly.
    ranges → **coalesce** ranges with gaps below `coalesce_gap` (default
    ~256 KiB — a gap fetch is usually cheaper than a request round-trip;
    both knobs are options, not constants).
-5. **Body fetches**: each coalesced range lands in its own owned heap
-   region. Decode resolves each declared buffer `(offset, len)` to its
-   containing fetched range and subslices — the message-body-authority
+5. **Body fetches** — the selected dictionary bodies and the selected record
+   buffers in ONE round: each coalesced range lands in its own owned heap
+   region; the dictionaries decode first from the shared spans. Decode
+   resolves each declared buffer `(offset, len)` to its containing fetched
+   range and subslices — the message-body-authority
    invariant becomes *"every buffer must fall inside a fetched range that
    was itself derived from the verified buffer table"*: same trust story,
    sparse backing.
@@ -194,23 +196,26 @@ planner; transports live in extensions:
     abstract type AbstractArrowSource end
     sourcelength(src)::Integer                  # total object length, known 
up front
     readrange(src, offset, len)::Vector{UInt8}  # one exact range, 0-based 
offset
-    readranges(src, ranges) -> Vector{Vector{UInt8}}
-        # default: serial map over readrange; transports override for
-        # concurrent range GETs — concurrency stays in the extension,
-        # never in Arrow.
+    concurrentreads(src)::Int                   # default 1
+        # Arrow reads a round's planned ranges through readrange with a
+        # worker pool of that size, storing every result by request index —
+        # a source's completion order can never permute payloads, and the
+        # reads in flight are bounded whatever the span count.
 
 - The entry point is `Arrow.Table(src; scan=…)`, which builds the internal
   `SourceFile(src; limits, tailbytes, coalesce_gap)` handle and runs
   `Tables.scan(sf, scan)`; the source's length is read once, at handle
   construction, and the tail once per handle. `Arrow.Table(src)` without a
-  scan plans every column; `Arrow.Stream(src)` reads the object whole.
+  scan, with a scan that cannot be pushed down, over a zero-field file, or
+  over a stream-format object reads the object whole (one request beyond
+  the cached tail), as does `Arrow.Stream(src)`.
 - The abstract type is a dynamic call under `--trim` for a source type the
   trimmed app never mentions; an app that names its concrete source type
   resolves statically. The planner itself is arithmetic over `Int64`s.
 - Extension: `ext/ArrowCloudStoreExt.jl` (loaded with CloudStore.jl) makes
   a `CloudStore.Object` a source — its known `size` is the length, one
-  range is one HTTP `Range` GET, and `readranges` issues a round's ranges
-  concurrently — and adds `Arrow.Table(::CloudStore.Object; …)` and
+  range is one HTTP `Range` GET pinned to the object's ETag with
+  `If-Match`, and `concurrentreads` is 16 — and adds 
`Arrow.Table(::CloudStore.Object; …)` and
   `Arrow.Stream(::CloudStore.Object; …)`. An HTTP transport is the same two
   methods. Zero new hard deps.
 - The differential test: sparse fetch ≡ whole-file read, plus
diff --git a/docs/dev/core-README.md b/docs/dev/core-README.md
index e429258..24672f3 100644
--- a/docs/dev/core-README.md
+++ b/docs/dev/core-README.md
@@ -36,7 +36,7 @@ scope of every layer.
 | `src/ipc_read.jl` | Checked IPC stream framing, resource limits, 
metadata-to-Core mapping, dictionary state, one registry-driven decoder, 
per-buffer decompression |
 | `src/ipc_write.jl` | The write half over the same registry: Core-to-metadata 
mapping, one generic registry-driven encoder, replacement-on-change dictionary 
batches, per-buffer compression, the file format (Block index + Footer), and 
the lazy random-access `ArrowFile` reader |
 | `src/cdata.jl` | C data and C stream interfaces both directions: zero-copy 
ownership, move semantics, exactly-once release, field and schema metadata 
transport |
-| `src/source.jl` | The `AbstractArrowSource` byte-range source interface 
(`sourcelength`, `readrange`, `readranges`) |
+| `src/source.jl` | The `AbstractArrowSource` byte-range source interface 
(`sourcelength`, `readrange`, `concurrentreads`) |
 | `src/scan.jl` | `Tables.Scan` pushdown over the file format, sparse 
byte-range reads over a source (`SourceFile`), embedded per-batch statistics |
 | `src/table.jl`, `src/write.jl` | The facade |
 | `ext/ArrowCloudStoreExt.jl` | CloudStore.jl objects as sources: HTTP `Range` 
reads, concurrent per planned range |
diff --git a/docs/src/manual.md b/docs/src/manual.md
index 2105b2c..a96f7df 100644
--- a/docs/src/manual.md
+++ b/docs/src/manual.md
@@ -271,10 +271,12 @@ Arrow.readrange(s::HTTPSource, offset, len) = 
fetchbytes(s.url, offset, len)  #
 tbl = Arrow.Table(HTTPSource(url, objectsize); scan = Scan(select = (:id,)))
 ```
 
-Overriding [`Arrow.readranges`](@ref) lets a transport issue the planned
-ranges concurrently; the default reads them one at a time. Without a scan
-the whole object is read; a stream-format object (no footer) is always read
-whole. Arrow.jl has no HTTP or cloud dependency of its own.
+Overriding [`Arrow.concurrentreads`](@ref) lets Arrow issue a round's
+planned ranges concurrently through `readrange` (up to that many at a
+time, results placed by request); the default reads them one at a time.
+Without a scan the whole object is read, as is a stream-format object (no
+footer) and a scan that cannot be pushed down. Arrow.jl has no HTTP or
+cloud dependency of its own.
 
 ## Writing
 
diff --git a/docs/src/reference.md b/docs/src/reference.md
index a572a0e..5831288 100644
--- a/docs/src/reference.md
+++ b/docs/src/reference.md
@@ -42,7 +42,7 @@ Arrow.DictEncode
 Arrow.AbstractArrowSource
 Arrow.sourcelength
 Arrow.readrange
-Arrow.readranges
+Arrow.concurrentreads
 ```
 
 ## The C data and C stream interfaces
diff --git a/ext/ArrowCloudStoreExt.jl b/ext/ArrowCloudStoreExt.jl
index 9f749ef..6ba9c60 100644
--- a/ext/ArrowCloudStoreExt.jl
+++ b/ext/ArrowCloudStoreExt.jl
@@ -26,41 +26,48 @@ using CloudStore: CloudStore, Object
     CloudObjectSource(obj::CloudStore.Object) <: Arrow.AbstractArrowSource
 
 A `CloudStore.Object` as an [`Arrow.AbstractArrowSource`](@ref): the length
-is the object's known size, one range is one HTTP `Range` GET, and the
-planned ranges of a scan are fetched concurrently. `Arrow.Table(obj; …)` and
-`Arrow.Stream(obj; …)` construct one implicitly.
+is the object's known size, one range is one HTTP `Range` GET pinned to the
+object's ETag with `If-Match` (an overwritten key fails the read instead of
+mixing versions across requests), and Arrow issues up to
+`CONCURRENT_RANGE_READS` of a round's ranges at once. `Arrow.Table(obj; …)`
+and `Arrow.Stream(obj; …)` construct one implicitly.
 """
 struct CloudObjectSource{O<:Object} <: Arrow.AbstractArrowSource
     obj::O
 end
 
+# Independent HTTP range GETs are latency-bound, so a round's requests are
+# worth overlapping; the bound keeps a fragmented object from becoming a
+# request storm.
+const CONCURRENT_RANGE_READS = 16
+
 Arrow.sourcelength(s::CloudObjectSource) = Int64(s.obj.size)
+Arrow.concurrentreads(::CloudObjectSource) = CONCURRENT_RANGE_READS
+
+# The object's ETag as an `If-Match` value (the header wants it quoted).
+function _ifmatch(etag::AbstractString)
+    isempty(etag) && return nothing
+    return startswith(etag, '"') ? String(etag) : string('"', etag, '"')
+end
 
 function Arrow.readrange(s::CloudObjectSource, off, len)
     len == 0 && return UInt8[]
     last = off + len - 1
     obj = s.obj
+    headers = ["Range" => "bytes=$(off)-$(last)"]
+    etag = _ifmatch(obj.eTag)
+    etag === nothing || push!(headers, "If-Match" => etag)
     bytes = CloudStore.get(
         obj.store,
         obj.key;
         credentials=obj.credentials,
-        headers=["Range" => "bytes=$(off)-$(last)"],
+        headers=headers,
         allowMultipart=false,
         objectMaxSize=Int(len),
     )
     return bytes isa Vector{UInt8} ? bytes : Vector{UInt8}(bytes)
 end
 
-# One task per planned range: the requests are independent GETs, and the
-# planner has already coalesced neighbours, so the remaining ranges are
-# worth issuing at once.
-function Arrow.readranges(s::CloudObjectSource, 
ranges::Vector{NTuple{2,Int64}})
-    length(ranges) <= 1 &&
-        return Vector{UInt8}[Arrow.readrange(s, off, len) for (off, len) in 
ranges]
-    tasks = [Threads.@spawn Arrow.readrange(s, off, len) for (off, len) in 
ranges]
-    return Vector{UInt8}[fetch(t)::Vector{UInt8} for t in tasks]
-end
-
 Arrow.Table(obj::Object; kw...) = Arrow.Table(CloudObjectSource(obj); kw...)
 Arrow.Stream(obj::Object; kw...) = Arrow.Stream(CloudObjectSource(obj); kw...)
 
diff --git a/src/scan.jl b/src/scan.jl
index 87bbf85..01424e6 100644
--- a/src/scan.jl
+++ b/src/scan.jl
@@ -769,6 +769,9 @@ struct SourceFile{S<:AbstractArrowSource}
     # The last `tailbytes` of the object, read once and reused by every
     # footer parse and format probe on this handle: `(bytes, tailstart)`.
     tail::Base.RefValue{Union{Nothing,Tuple{Vector{UInt8},Int64}}}
+    # A Footer that did not fit in the tail window, read once as
+    # `(footerstart, bytes)` and reused by every footer parse on this handle.
+    footer::Base.RefValue{Union{Nothing,Tuple{Int64,Vector{UInt8}}}}
 end
 function SourceFile(
     src::AbstractArrowSource;
@@ -778,15 +781,17 @@ function SourceFile(
 )
     gap = Int64(coalesce_gap)
     gap >= 0 || throw(ArgumentError("negative coalesce gap"))
-    len = Int64(sourcelength(src))
-    len >= 0 || throw(ValidationError("source reports a negative length"))
+    reported = sourcelength(src)
+    (reported >= 0 && reported <= typemax(Int64)) ||
+        throw(ValidationError("source reports an invalid length $reported"))
     return SourceFile(
         src,
-        len,
+        Int64(reported),
         limits,
         Int64(max(tailbytes, 32)),
         gap,
         Base.RefValue{Union{Nothing,Tuple{Vector{UInt8},Int64}}}(nothing),
+        Base.RefValue{Union{Nothing,Tuple{Int64,Vector{UInt8}}}}(nothing),
     )
 end
 
@@ -801,6 +806,18 @@ function _fetchtail(sf::SourceFile)
     return (tail, tailstart)
 end
 
+# A Footer that escapes the tail window: one exact read, cached on the handle
+# so the schema pass and the scan pass share it.
+function _fetchfooter(sf::SourceFile, footerstart::Int64, footerlen::Int64)
+    cached = sf.footer[]
+    if cached !== nothing && cached[1] == footerstart && length(cached[2]) == 
footerlen
+        return cached[2]
+    end
+    bytes = _fetchexact(sf, footerstart, footerlen)
+    sf.footer[] = (footerstart, bytes)
+    return bytes
+end
+
 # Whether the object is an IPC FILE (trailing `ARROW1` magic) — a stream
 # object has no footer and is read whole instead of range-planned.
 function _isfilesource(sf::SourceFile)
@@ -816,11 +833,22 @@ function _wholeobject(sf::SourceFile)
 end
 
 # One exact range through the source, bounds-checked against the length
-# read at construction and length-checked on return.
+# read at construction and length-checked on return; a range the cached
+# tail window already covers is served from it without a request.
 function _fetchexact(sf::SourceFile, off::Int64, len::Int64)
     (off >= 0 && len >= 0 && off <= sf.len - len) ||
         throw(ValidationError("range fetch [$off, $len] escapes the object"))
-    bytes = readrange(sf.src, off, len)
+    cached = sf.tail[]
+    if cached !== nothing
+        tail, tailstart = cached
+        if off >= tailstart && off + len <= tailstart + length(tail)
+            return tail[(off - tailstart + 1):(off - tailstart + len)]
+        end
+    end
+    got = readrange(sf.src, off, len)
+    got isa AbstractVector{UInt8} ||
+        throw(ValidationError("readrange must return a Vector{UInt8}, got 
$(typeof(got))"))
+    bytes = got isa Vector{UInt8} ? got : Vector{UInt8}(got)
     length(bytes) == len ||
         throw(ValidationError("range fetch returned $(length(bytes)) bytes, 
expected $len"))
     return bytes
@@ -834,7 +862,7 @@ function _coalesce(ranges::Vector{NTuple{2,Int64}}, 
gap::Int64)
     isempty(ranges) && return NTuple{2,Int64}[]
     gap >= 0 || throw(ArgumentError("negative coalesce gap"))
     all(r -> r[1] >= 0 && r[2] >= 0, ranges) ||
-        throw(ArgumentError("negative range offset or length"))
+        throw(ValidationError("negative range offset or length"))
     sorted = sort(ranges)
     out = NTuple{2,Int64}[sorted[1]]
     for (off, len) in Iterators.drop(sorted, 1)
@@ -868,21 +896,48 @@ function _fetchspans(
     all(s -> s[1] >= 0 && s[2] >= 0 && s[1] <= sf.len - s[2], spans) ||
         throw(ValidationError("planned range escapes the object"))
     budget === nothing || foreach(s -> _charge!(budget, s[2], what), spans)
-    payloads = readranges(sf.src, spans)
-    length(payloads) == length(spans) || throw(
-        ValidationError(
-            "range fetch returned $(length(payloads)) payloads, expected 
$(length(spans))",
-        ),
-    )
-    for (payload, (_, len)) in zip(payloads, spans)
-        length(payload) == len || throw(
-            ValidationError("range fetch returned $(length(payload)) bytes, 
expected $len"),
-        )
-    end
+    payloads = _readspans(sf, spans)
     slices = BufferSlice[BufferSlice(heapregion(p), 0, length(p)) for p in 
payloads]
     return FetchedSpans(Int64[s[1] for s in spans], Int64[s[2] for s in 
spans], slices)
 end
 
+# Read one round's spans through the source, each length-checked, every
+# result stored by its request index: serially, or through a worker pool of
+# `concurrentreads(src)` tasks pulling requests off one counter — so a
+# source's completion order can never permute payloads, and the number of
+# reads in flight is bounded whatever the span count.
+function _readspans(sf::SourceFile, spans::Vector{NTuple{2,Int64}})
+    n = length(spans)
+    results = Vector{Vector{UInt8}}(undef, n)
+    k = min(n, max(1, Int(concurrentreads(sf.src))))
+    if k <= 1
+        for i = 1:n
+            results[i] = _fetchexact(sf, spans[i][1], spans[i][2])
+        end
+        return results
+    end
+    next = Threads.Atomic{Int}(1)
+    try
+        @sync for _ = 1:k
+            Threads.@spawn while true
+                i = Threads.atomic_add!(next, 1)
+                i > n && break
+                results[i] = _fetchexact(sf, spans[i][1], spans[i][2])
+            end
+        end
+    catch e
+        rethrow(_firstcause(e))
+    end
+    return results
+end
+
+# The underlying exception of a failed worker task (`@sync` wraps it).
+function _firstcause(e)
+    e isa CompositeException && !isempty(e) && return _firstcause(first(e))
+    e isa TaskFailedException && return _firstcause(e.task.result)
+    return e
+end
+
 function _spanslice(fs::FetchedSpans, off::Int64, len::Int64)
     len == 0 && return BufferSlice()
     i = searchsortedlast(fs.starts, off)
@@ -992,7 +1047,7 @@ function _rangedfooter(sf::SourceFile, 
budget::AllocationBudget)
     footerbytes =
         footerstart >= tailstart ?
         tail[(footerstart - tailstart + 1):(footerstart - tailstart + 
footerlen)] :
-        _fetchexact(sf, footerstart, footerlen)
+        _fetchfooter(sf, footerstart, footerlen)
     version, features, dictblocks, recordblocks, reserve =
         verify_footer(footerbytes, limits, budget.left)
     _charge!(budget, reserve, "verified footer expansion")
@@ -1266,17 +1321,55 @@ function _applyscan(sf::SourceFile, scan::Tables.Scan)
             "record batch references dictionary id $(first(missingids)) before 
its dictionary batch",
         ),
     )
+    # One body round: the selected dictionary bodies and the selected record
+    # buffers are planned together and fetched in a single pass; the
+    # dictionaries decode first from the shared spans.
+    bodyranges = NTuple{2,Int64}[
+        (dictblocks[i][1] + dictblocks[i][2], dictblocks[i][3]) for i in 
wanted_dict
+    ]
+    for (p, _, _) in window
+        block = recordblocks[recidxs[p]]
+        header = headers[p]
+        buffers = something(header.buffers, Meta.Buffer[])
+        variadics = variadiccounts(header)
+        varidx = Ref(1)
+        wants = NTuple{2,Int64}[]
+        bufidx = 1
+        for (j, fld) in enumerate(fields)
+            span64 = _bufferspan(fld, variadics, varidx)
+            span64 <= typemax(Int) || throw(
+                ValidationError("field buffer span $span64 exceeds the host 
index range"),
+            )
+            span = Int(span64)
+            if mask[j]
+                for k = bufidx:(bufidx + span - 1)
+                    k <= length(buffers) || throw(
+                        ValidationError(
+                            "metadata declares fewer buffers than the schema 
requires",
+                        ),
+                    )
+                    buf = buffers[k]
+                    len = Int64(buf.length)
+                    off = Int64(buf.offset)
+                    (off >= 0 && len >= 0 && AC.checked_add(off, len) <= 
block[3]) || throw(
+                        ValidationError(
+                            "batch buffer [$off, $len] escapes its message 
body",
+                        ),
+                    )
+                    len == 0 && continue
+                    push!(wants, (off, len))
+                end
+            end
+            bufidx += span
+        end
+        bodystart = block[1] + block[2]
+        append!(bodyranges, NTuple{2,Int64}[(bodystart + off, len) for (off, 
len) in wants])
+    end
+    bodyspans = _fetchspans(sf, bodyranges, sf.coalesce_gap)
+
     state = DecodeState(budget)
     try
         if !isempty(wanted_dict)
-            bodyspans = _fetchspans(
-                sf,
-                NTuple{2,Int64}[
-                    (dictblocks[i][1] + dictblocks[i][2], dictblocks[i][3]) for
-                    i in wanted_dict
-                ],
-                sf.coalesce_gap,
-            )
             for i in wanted_dict
                 block = dictblocks[i]
                 msg, v = blockmeta[i]
@@ -1309,55 +1402,6 @@ function _applyscan(sf::SourceFile, scan::Tables.Scan)
             end
         end
 
-        bodyranges = NTuple{2,Int64}[]
-        blockwants = Dict{Int,Vector{NTuple{2,Int64}}}()
-        for (p, _, _) in window
-            block = recordblocks[recidxs[p]]
-            header = headers[p]
-            buffers = something(header.buffers, Meta.Buffer[])
-            variadics = variadiccounts(header)
-            varidx = Ref(1)
-            wants = NTuple{2,Int64}[]
-            bufidx = 1
-            for (j, fld) in enumerate(fields)
-                span64 = _bufferspan(fld, variadics, varidx)
-                span64 <= typemax(Int) || throw(
-                    ValidationError(
-                        "field buffer span $span64 exceeds the host index 
range",
-                    ),
-                )
-                span = Int(span64)
-                if mask[j]
-                    for k = bufidx:(bufidx + span - 1)
-                        k <= length(buffers) || throw(
-                            ValidationError(
-                                "metadata declares fewer buffers than the 
schema requires",
-                            ),
-                        )
-                        buf = buffers[k]
-                        len = Int64(buf.length)
-                        off = Int64(buf.offset)
-                        (off >= 0 && len >= 0 && AC.checked_add(off, len) <= 
block[3]) ||
-                            throw(
-                                ValidationError(
-                                    "batch buffer [$off, $len] escapes its 
message body",
-                                ),
-                            )
-                        len == 0 && continue
-                        push!(wants, (off, len))
-                    end
-                end
-                bufidx += span
-            end
-            blockwants[p] = wants
-            bodystart = block[1] + block[2]
-            append!(
-                bodyranges,
-                NTuple{2,Int64}[(bodystart + off, len) for (off, len) in 
wants],
-            )
-        end
-        bodyspans = _fetchspans(sf, bodyranges, sf.coalesce_gap)
-
         parts = Dict{Int,Vector{Any}}(idx => Any[] for idx in decodeidx)
         outrows = 0
         for (p, skip, take) in window
diff --git a/src/source.jl b/src/source.jl
index 7373728..8310512 100644
--- a/src/source.jl
+++ b/src/source.jl
@@ -38,11 +38,15 @@ An implementation defines two methods:
 
 and may override
 
-    Arrow.readranges(src, ranges::Vector{NTuple{2,Int64}}) -> 
Vector{Vector{UInt8}}
+    Arrow.concurrentreads(src)::Int                   # default 1
 
-whose default reads the planned `(offset, len)` ranges one at a time through
-`readrange`; a transport that can issue them concurrently should. Every
-returned vector must have exactly the requested length.
+to let Arrow issue the planned ranges of one round through `readrange`
+concurrently, at most that many at a time (Arrow places each result by its
+request, so completion order never matters). Every returned vector must have
+exactly the requested length; Arrow validates lengths and offsets and
+refuses violations with a `ValidationError`, but the source is trusted to
+return the bytes that live at the requested range — Arrow cannot
+authenticate them.
 
 ```julia
 struct BytesSource <: Arrow.AbstractArrowSource
@@ -77,11 +81,12 @@ elements. Required of every implementation.
 function readrange end
 
 """
-    Arrow.readranges(src::AbstractArrowSource, 
ranges::Vector{NTuple{2,Int64}}) -> Vector{Vector{UInt8}}
+    Arrow.concurrentreads(src::AbstractArrowSource) -> Int
 
-One result per requested `(offset, len)`, in order. The default reads them
-serially through [`Arrow.readrange`](@ref); a transport overrides this to
-issue the planned ranges concurrently.
+How many [`Arrow.readrange`](@ref) calls Arrow may have in flight at once
+when it fetches the planned ranges of one round. The default, `1`, reads
+them one at a time; a transport whose requests are independent (HTTP range
+GETs) returns a bound suited to it. Arrow runs a worker pool of that size
+and stores every result by request index.
 """
-readranges(src::AbstractArrowSource, ranges::Vector{NTuple{2,Int64}}) =
-    Vector{UInt8}[readrange(src, off, len) for (off, len) in ranges]
+concurrentreads(::AbstractArrowSource) = 1
diff --git a/src/table.jl b/src/table.jl
index bb67f1a..6b501b6 100644
--- a/src/table.jl
+++ b/src/table.jl
@@ -49,9 +49,10 @@ One exception: a scan that cannot run in the storage domain 
— a filter
 literal with no exact storage representation (a cross-domain or
 out-of-range value), or an empty projection (`select=()`), whose row count
 only the full read can carry — falls back to reading the whole source and
-evaluating over the converted public values. Over a ranged source that
-fallback fetches the entire object, as does reading a zero-field source;
-plan remote filters in each column's public value domain.
+evaluating over the converted public values. An `AbstractArrowSource` is
+read whole in that fallback, as it is without a scan, for a zero-field
+file, and for a stream-format object; plan remote filters in each column's
+public value domain.
 
 Columns are materialized (plain `Vector`s): the returned table does not
 borrow the source bytes, and [`Arrow.close!`](@ref) may be called at any
@@ -462,8 +463,9 @@ const _FILE_MAGIC = b"ARROW1"
 
 _isfilebytes(bytes::Vector{UInt8}) = length(bytes) >= 6 && view(bytes, 1:6) == 
_FILE_MAGIC
 
-function _openbytes(bytes::Vector{UInt8})
-    return _isfilebytes(bytes) ? readfile(bytes) : readstream(bytes)
+function _openbytes(bytes::Vector{UInt8}; limits::Limits=Limits())
+    return _isfilebytes(bytes) ? readfile(bytes; limits=limits) :
+           readstream(bytes; limits=limits)
 end
 
 function _opensource(path::AbstractString; mmap::Bool=true)
@@ -503,34 +505,30 @@ _sourceregions(f::ArrowFile) = AC.OwnerRegion[f.region]
 function Table(source; scan::Union{Nothing,Tables.Scan}=nothing, 
mmap::Bool=true)
     if source isa AbstractArrowSource || source isa SourceFile
         sf = source isa SourceFile ? source : SourceFile(source)
-        # A stream-format object has no footer to plan from: read it whole
-        # (its tail window is already in hand) and scan after decode.
-        _isfilesource(sf) || return Table(_wholeobject(sf); scan=scan)
-        # The schema up front (from the cached tail): literal lowering,
-        # exactly-once output conversion, and DataAPI metadata all need it.
-        sch, rfields = _sourceschema(sf)
-        theScan = scan === nothing ? Tables.Scan() : scan
-        if isempty(rfields)
-            # A zero-field object is bytes-tiny; read it whole so the row
-            # count survives the read.
-            bytes = _wholeobject(sf)
-            return _publicscan(
-                _materialize_table(readfile(bytes; limits=sf.limits), 
AC.OwnerRegion[]),
-                sch,
-                rfields,
-                theScan,
-                AC.OwnerRegion[],
-            )
-        end
-        pushscan, pushable = _lowerscan(theScan, rfields)
-        if pushable
-            got = Tables.scan(sf, pushscan)
-            return _wrapscanned(got, sch, rfields, theScan)
+        # Range planning pays off only for a pushable scan over a file-format
+        # object with columns. With a scan the schema comes up front from
+        # the cached tail: literal lowering, exactly-once output conversion,
+        # and DataAPI metadata all need it.
+        if scan !== nothing && _isfilesource(sf)
+            sch, rfields = _sourceschema(sf)
+            if !isempty(rfields)
+                pushscan, pushable = _lowerscan(scan, rfields)
+                pushable &&
+                    return _wrapscanned(Tables.scan(sf, pushscan), sch, 
rfields, scan)
+            end
         end
-        full = _wrapscanned(Tables.scan(sf, Tables.Scan()), sch, rfields, 
Tables.Scan())
-        return _publicscan(full, sch, rfields, theScan, AC.OwnerRegion[])
+        # No scan, a stream-format object (no footer to plan from), a
+        # zero-field file (bytes-tiny; the whole read carries its row count),
+        # or a scan that cannot be pushed down: read the object whole — its
+        # tail window is already in hand — and proceed as with bytes.
+        return _tablefrom(_openbytes(_wholeobject(sf); limits=sf.limits), scan)
     end
-    src = _opensource(source; mmap=mmap)
+    return _tablefrom(_opensource(source; mmap=mmap), scan)
+end
+
+# A Table from an opened IPC source: the whole thing, or a scan pushed
+# where the format can prove it and evaluated over the rest.
+function _tablefrom(src::Union{IPCStream,ArrowFile}, 
scan::Union{Nothing,Tables.Scan})
     regions = _sourceregions(src)
     fields = _corefields(src)
     scan === nothing && return _materialize_table(src, regions)
diff --git a/test/cloudstore_tests.jl b/test/cloudstore_tests.jl
index 1e419ee..172233a 100644
--- a/test/cloudstore_tests.jl
+++ b/test/cloudstore_tests.jl
@@ -44,13 +44,12 @@ using CloudBase.CloudTest: Minio
         CloudStore.put(bucket, "t.arrows", streambytes; 
credentials=credentials)
         obj = CloudStore.Object(bucket, "t.arrow"; credentials=credentials)
         @test Arrow.sourcelength(ext.CloudObjectSource(obj)) == 
length(filebytes)
-        # one range, and the batched form, byte-exact against the object
+        # one range, byte-exact against the object; a bounded concurrency
         src = ext.CloudObjectSource(obj)
         @test Arrow.readrange(src, 8, 16) == filebytes[9:24]
-        @test Arrow.readranges(
-            src,
-            NTuple{2,Int64}[(0, 6), (100, 50), (length(filebytes) - 6, 6)],
-        ) == [filebytes[1:6], filebytes[101:150], filebytes[(end - 5):end]]
+        @test Arrow.readrange(src, length(filebytes) - 6, 6) == filebytes[(end 
- 5):end]
+        @test Arrow.readrange(src, 0, 0) == UInt8[]
+        @test Arrow.concurrentreads(src) == ext.CONCURRENT_RANGE_READS > 1
         # a projected, windowed scan over the object
         t = Arrow.Table(obj; scan=Tables.Scan(select=(:b,), limit=3, 
offset=1500))
         @test Tables.columnnames(t) == [:b]
@@ -68,6 +67,15 @@ using CloudBase.CloudTest: Minio
         @test st.a == mem.a && st.b == mem.b
         @test [length(batch.a) for batch in Arrow.Stream(sobj)] == [1000, 1000]
         @test [length(batch.a) for batch in Arrow.Stream(obj)] == [1000, 1000]
+        # A handle is pinned to the object version it was made from: after the
+        # key is overwritten, its next range read fails (If-Match) rather than
+        # mixing bytes of two versions across request rounds.
+        stale = ext.CloudObjectSource(obj)
+        CloudStore.put(bucket, "t.arrow", streambytes; credentials=credentials)
+        @test_throws Exception Arrow.readrange(stale, 8, 16)
+        @test_throws Exception Arrow.Table(obj; scan=Tables.Scan(select=(:a,)))
+        fresh = CloudStore.Object(bucket, "t.arrow"; credentials=credentials)
+        @test Arrow.Table(fresh).a == mem.a
     end
 end
 
diff --git a/test/facade_tests.jl b/test/facade_tests.jl
index 099d018..eb91a6a 100644
--- a/test/facade_tests.jl
+++ b/test/facade_tests.jl
@@ -42,6 +42,61 @@ function Arrow.readrange(s::_MeteredSource, off, len)
     s.fetched[] += len
     return s.data[(off + 1):(off + len)]
 end
+# A source that logs every request, and a concurrent one whose reads finish
+# in reverse request order (the last-issued read of a round completes first)
+# while a counter records the most reads ever in flight.
+struct _LoggingSource <: Arrow.AbstractArrowSource
+    data::Vector{UInt8}
+    requests::Vector{NTuple{2,Int64}}
+end
+Arrow.sourcelength(s::_LoggingSource) = length(s.data)
+function Arrow.readrange(s::_LoggingSource, off, len)
+    push!(s.requests, (Int64(off), Int64(len)))
+    return s.data[(off + 1):(off + len)]
+end
+mutable struct _ConcurrentSource <: Arrow.AbstractArrowSource
+    const data::Vector{UInt8}
+    const limit::Int
+    @atomic inflight::Int
+    @atomic peak::Int
+    @atomic issued::Int
+end
+_ConcurrentSource(data, limit) = _ConcurrentSource(data, limit, 0, 0, 0)
+Arrow.sourcelength(s::_ConcurrentSource) = length(s.data)
+Arrow.concurrentreads(s::_ConcurrentSource) = s.limit
+function Arrow.readrange(s::_ConcurrentSource, off, len)
+    n = @atomic s.inflight += 1
+    while true
+        p = @atomic s.peak
+        (n <= p || (@atomicreplace s.peak p => n).success) && break
+    end
+    order = @atomic s.issued += 1
+    # Later requests of a round return sooner: results must land by request.
+    sleep(0.002 * max(0, s.limit - (order % s.limit)))
+    @atomic s.inflight -= 1
+    return s.data[(off + 1):(off + len)]
+end
+# Sources that violate the contract in each way the reader must refuse.
+struct _ShortSource <: Arrow.AbstractArrowSource
+    data::Vector{UInt8}
+end
+Arrow.sourcelength(s::_ShortSource) = length(s.data)
+Arrow.readrange(s::_ShortSource, off, len) = s.data[(off + 1):(off + max(0, 
len - 1))]
+struct _LongSource <: Arrow.AbstractArrowSource
+    data::Vector{UInt8}
+end
+Arrow.sourcelength(s::_LongSource) = length(s.data)
+Arrow.readrange(s::_LongSource, off, len) = vcat(s.data[(off + 1):(off + 
len)], 0x00)
+struct _WrongTypeSource <: Arrow.AbstractArrowSource
+    data::Vector{UInt8}
+end
+Arrow.sourcelength(s::_WrongTypeSource) = length(s.data)
+Arrow.readrange(s::_WrongTypeSource, off, len) = String(s.data[(off + 1):(off 
+ len)])
+struct _BadLengthSource <: Arrow.AbstractArrowSource
+    reported::Integer
+end
+Arrow.sourcelength(s::_BadLengthSource) = s.reported
+Arrow.readrange(s::_BadLengthSource, off, len) = zeros(UInt8, len)
 
 const MIXED = (
     ints=Int64[1, 2, 3, 4],
@@ -172,6 +227,64 @@ end
         @test t.b == ["v1501", "v1502", "v1503"]
         # The first batch is skipped entirely and column :a is never fetched.
         @test fetched[] < length(fb) ÷ 2
+
+        # Request rounds through the public path over an object larger than
+        # the tail window: a pushable scan is the tail, the surviving batch's
+        # metadata, then the selected buffers (three rounds; here one request
+        # each with coalescing), the tail read exactly once; ranges inside the
+        # cached tail window are served from it without a request.
+        wide = [string("v", i, "-", repeat("x", 60)) for i = 1:2000]
+        wio = IOBuffer()
+        Arrow.write(
+            wio,
+            Tables.partitioner([
+                (a=collect(Int64, 1:1000), b=wide[1:1000]),
+                (a=collect(Int64, 1001:2000), b=wide[1001:2000]),
+            ]),
+        )
+        wb = take!(wio)
+        @test length(wb) > 65536
+        log = _LoggingSource(wb, NTuple{2,Int64}[])
+        t2 = Arrow.Table(log; scan=Tables.Scan(select=(:a,), 
filter=Tables.col(:a) < 10))
+        @test t2.a == collect(1:9)
+        tailreq = (Int64(length(wb) - 65536), Int64(65536))
+        @test count(==(tailreq), log.requests) == 1
+        @test length(log.requests) == 3
+        # An object no larger than the tail window is entirely in hand after
+        # the tail read: every planned range is served from it.
+        small = _LoggingSource(fb, NTuple{2,Int64}[])
+        t3 = Arrow.Table(small; scan=Tables.Scan(select=(:a,), limit=3, 
offset=1500))
+        @test t3.a == [1501, 1502, 1503]
+        @test small.requests == [(Int64(0), Int64(length(fb)))]
+        # No scan (and any unpushable scan): the object is read whole — the
+        # cached tail plus the prefix, two requests, no planning.
+        empty!(log.requests)
+        tw = Arrow.Table(log)
+        @test tw.a == 1:2000 && length(log.requests) == 2
+        @test sum(last, log.requests) == length(wb)
+        empty!(log.requests)
+        tz = Arrow.Table(log; scan=Tables.Scan(select=()))
+        @test length(tz) == 2000 && length(log.requests) == 2
+
+        # Concurrent reads: bounded by the source's limit, results placed by
+        # request even though later requests complete first.
+        cs = _ConcurrentSource(fb, 3)
+        cf = Arrow.SourceFile(cs; tailbytes=1024, coalesce_gap=0)
+        tc = Arrow.Table(cf; scan=Tables.Scan(select=(:a, :b)))
+        @test tc.a == 1:2000 && tc.b == [string("v", i) for i = 1:2000]
+        @test 1 < (@atomic cs.peak) <= 3
+
+        # Contract violations fail closed with ValidationError, never a
+        # wrong table or a stray error type.
+        for bad in (_ShortSource(fb), _LongSource(fb), _WrongTypeSource(fb))
+            @test_throws Arrow.AC.ValidationError Arrow.Table(
+                bad;
+                scan=Tables.Scan(select=(:a,)),
+            )
+        end
+        for reported in (-1, Int128(typemax(Int64)) + 1, 
Int128(typemin(Int64)) - 1)
+            @test_throws Arrow.AC.ValidationError 
Arrow.Table(_BadLengthSource(reported))
+        end
     end
 
     @testset "mmap path and close!" begin
diff --git a/test/scan_battery.jl b/test/scan_battery.jl
index 21e0576..e7de2a5 100644
--- a/test/scan_battery.jl
+++ b/test/scan_battery.jl
@@ -603,14 +603,18 @@ function _ranged_main(filebytes::Vector{UInt8}, 
af::ArrowFile, full)
 
     # Coalescing: an infinite gap merges every body range into one request;
     # a zero gap issues more, smaller requests; both agree with the truth.
+    # (A small tail window, so the ranges are planned rather than served
+    # from the cached tail.)
     logbig, srcbig = countingsource(filebytes)
     gotbig = Tables.scan(
-        SourceFile(srcbig; coalesce_gap=typemax(Int32)),
+        SourceFile(srcbig; tailbytes=256, coalesce_gap=typemax(Int32)),
         Tables.Scan(select=(:ints, :strs)),
     )
     logzero, srczero = countingsource(filebytes)
-    gotzero =
-        Tables.scan(SourceFile(srczero; coalesce_gap=0), 
Tables.Scan(select=(:ints, :strs)))
+    gotzero = Tables.scan(
+        SourceFile(srczero; tailbytes=256, coalesce_gap=0),
+        Tables.Scan(select=(:ints, :strs)),
+    )
     want = Tables.scan(full, Tables.Scan(select=(:ints, :strs)))
     @assert _tables_equal(gotbig, want) && _tables_equal(gotzero, want)
     @assert logbig.requests < logzero.requests

Reply via email to