Repository navigation
Conversation
This comment has been minimized.
This comment has been minimized.
| // The producer's null count only covers the slice. Without a validity bitmap the null count | ||
| // must be 0 for the buffers to load, otherwise it is unknown. | ||
| boolean hasBitmap = snapshot.n_buffers > 0 && MemoryUtil.getLong(snapshot.buffers) != NULL; | ||
| snapshot.null_count = hasBitmap ? -1 : 0; |
There was a problem hiding this comment.
Producers commonly omit the validity bitmap for columns without nulls. With null_count = 0, BitVectorHelper.loadValidityBuffer then allocates and fills an all-ones bitmap for offset + length rows.
For byte-aligned offsets the target keeps a slice of that buffer, so the whole bitmap stays allocated as long as the target lives.
For example, importing 0 rows at offset 8,000,000 from a non-null int column allocates about 1 MB of bitmap and keeps it after the temporary vector is closed. That is the main use case of this PR, since a sliced RecordBatch puts the offset on every column.
When a large batch is streamed in slices, the cost of each import grows with its offset.
The synthesized bitmap should only cover the length rows that end up in the target.
There was a problem hiding this comment.
Fixed in e110114. The importer now advances the buffer pointers past the leading rows that fill whole bytes of a bitmap, so the temporary vector starts at most 7 rows before the slice. A bitmap synthesized for it covers at most length + 7 rows. The imported buffers shrink the same way, so the target no longer keeps a wrap of every row before the offset either.
Children whose rows line up with the parent's rows (struct, sparse union, fixed-size list) get the same skip. They are also trimmed to the slice, because a sliced struct exports them at full length. Run-end encoded arrays keep the split-only path, since their children are not indexed by row.
testImportSliceWithoutValidityBitmapAllocatesOnlyTheSlice and testImportStructSliceWithoutValidityBitmapsAllocatesOnlyTheSlice import 10 rows at offsets around 65,536 from arrays without bitmaps, and check that the importing allocator holds less than 1 KB. Before the change it held about 260 KB.
| unslicedImporter.recursionLevel = recursionLevel; | ||
| unslicedImporter.underlyingAllocation = underlyingAllocation; | ||
| unslicedImporter.importUnsliced(snapshot); | ||
| unslicedVector.makeTransferPair(vector).splitAndTransfer(offset, length); |
There was a problem hiding this comment.
This relies on makeTransferPair(vector) + splitAndTransfer working for every vector type, and it doesn't for extension vectors. ExtensionTypeVector.makeTransferPair passes the extension target straight to the storage vector, which casts it to its own class:
java.lang.ClassCastException: class org.apache.arrow.vector.extension.OpaqueVector cannot be cast to class org.apache.arrow.vector.VarBinaryVector
UuidVector (the one in the tests) and JsonVector work only because they override makeTransferPair. OpaqueVector and any user-defined ExtensionTypeVector that doesn't override it fail, both at the top level and as a child of a sliced struct or list. A user-defined vector that keeps the default getField() fails as well: getField() then returns the storage field, so the temporary vector is created as the plain storage vector, and that vector can't transfer into the extension target either.
A sliced RecordBatch puts the offset on every column, so any batch with a custom extension column hits this.
Could we fix it in ExtensionTypeVector (unwrap an extension target to its underlying vector, and make sure the temporary vector has the extension type) and add tests with a custom extension type that overrides neither getField() nor makeTransferPair()?
The alternative is to keep rejecting offsets for these vectors with a clear error for now.
There was a problem hiding this comment.
ExtensionTypeVector.makeTransferPairnow transfers into the underlying vector of an extension vector target, andgetTo()still returns the extension vector. That coversOpaqueVectorand any other vector that does not override it, at any depth.- The importer creates the temporary vector from the imported field when it has one, which
importVector,importVectorSchemaRootandimportIntoVectorSchemaRootdo. That field has the extension type even when the vector'sgetField()reports only its storage type, so the temporary vector has the same classes as the target. - At the top level the importer transfers between the storage vectors, so
importIntoVectorinto such a vector works as well.
One case is left: importIntoVector into a caller's vector that nests such an extension vector below the top level. No field reports that vector's type there, so the import now throws an UnsupportedOperationException that names the vector. Before, the struct's transfer pair silently replaced the child: the target's extension vector became a plain VarCharVector.
The tests use a PlainVector that overrides neither getField() nor makeTransferPair(). They cover it at the top level, in a struct, in a list, in a sliced record batch, and through importIntoVector. OpaqueVector is covered at the top level, in a struct and in a list. TestExtensionType tests the transfer pair itself with LocationVector.
| unslicedImporter.recursionLevel = recursionLevel; | ||
| unslicedImporter.underlyingAllocation = underlyingAllocation; | ||
| unslicedImporter.importUnsliced(snapshot); | ||
| unslicedVector.makeTransferPair(vector).splitAndTransfer(offset, length); |
There was a problem hiding this comment.
Also, I believe dense unions have two problems in DenseUnionVector.TransferImpl.splitAndTransfer, which this PR now exposes to C Data imports:
typeCounts/typeStartsare sized by the number of children (nextTypeId) but indexed by the raw type code read from the type buffer. A dense union with type codes[5, 10]and offset 1 fails withArrayIndexOutOfBoundException: Index 10 out of bounds for length 2. The same array imports fine with offset 0.- It assumes each child's offsets in the window are contiguous: it transfers
countchild values starting at the first offset it sees and renumbers them0..count-1. The spec only requires the offsets to increase, so gaps are valid. With one child holding100..107, row offsets[0, 2, 7]and a slice of 2 rows at offset 1, the result is102, 103instead of102, 107, with no error.
The second one is wrong data with no error, where main rejects the same input today. The tests only use type codes 0 and 1 with contiguous offsets.
Could we either fix the split (map type code to child index, and rebuild the child offsets instead of assuming they are contiguous), or keep rejecting offsets for dense unions for now?
There was a problem hiding this comment.
Fixed both in c8083d5, in DenseUnionVector.TransferImpl:
- The split maps each type id to the position of its child through
typeMapFields.createTransferPairs()andtransfer()had the same mix-up, which put the children of a new target under their positions rather than their type ids, so those are fixed as well. - The split checks whether the values a window selects from each child are contiguous. Contiguous values are still split without copying. Values with gaps are copied with
copyValueSafeand renumbered from 0.
Copying keeps the value count of each child equal to the number of rows that select it. DenseUnionVector.setValueCount() enforces that, and a parent struct or list calls it after its own split, so keeping the gaps would have been undone there.
With your example the slice now returns 102 and 107. TestDenseUnionVector covers type ids [5, 10], gaps at the top level and inside a struct, and a transfer pair that creates its target. RoundtripTest imports both cases at every offset.
The C Data Interface importer ignored, and later rejected, the offset of an ArrowArray struct, so a sliced record batch exported from C++ could not be imported. Java vectors cannot represent an offset, so the importer now loads the leading offset + length values into a temporary vector and transfers the requested window into the target with splitAndTransfer. Buffers that can be sliced are shared; offset buffers and unaligned validity bitmaps are copied. Closes apache#88
Co-authored-by: JB Onofré <[email protected]>
ExtensionTypeVector.makeTransferPair passed an extension vector target to the transfer pair of its underlying vector, which casts the target to its own class. Transfers between extension vectors that do not override makeTransferPair, such as OpaqueVector, failed with a ClassCastException. Transfer into the underlying vector of an extension vector target instead.
DenseUnionVector's transfer pair indexed its per-child state by type id, although it is sized and ordered by child. Splitting a dense union whose type ids are not 0 to n - 1 failed with an ArrayIndexOutOfBoundsException, and transfers placed the target's children under the wrong type ids. The split also assumed that the values a window selects from a child are contiguous. The offsets of a child only have to increase, so for offsets with gaps the split returned the wrong values. Values with gaps are now copied, while contiguous values are still split.
A sliced import loaded every row before the slice into a temporary vector, so the validity bitmap synthesized for an array without one, and the imported buffers, grew with the offset and stayed alive with the target. Skip the leading rows that fill whole bytes of a bitmap by advancing the buffer pointers, and import only the rows of the slice from the children of structs, sparse unions and fixed-size lists. Create the temporary vector from the imported field, which has the extension types of vectors whose getField() reports their storage type, and transfer between storage vectors at the top level. Importing into an existing vector whose nested extension vectors do not report their type fails with a clear error instead of replacing them.
e110114 to
112a0ff
Compare
## What's Changed When a test JVM crashes in the RC workflow's Binaries job, the log says only `Error occurred in starting fork, check output in log`, `Corrupted channel by directly writing to native stream in forked JVM N` and `Aborted (core dumped)`. The details stay on the runner. HotSpot writes `hs_err_pid<pid>.log` to the module directory, and surefire saves the crash banner the JVM printed to stdout in `target/surefire-reports/*.dumpstream`. The job uploads neither. For example, the Binaries job on #1419 failed twice this way ([attempt 1](https://github.com/apache/arrow-java/actions/runs/41233658912/job/124634336074), [attempt 2](https://github.com/apache/arrow-java/actions/runs/41233658912/job/124888186799)). All 46 arrow-dataset tests passed, then the JVM that had run 35 of them aborted, and nothing in the log shows where it crashed. This adds a step that uploads those files as a `binaries-jvm-crash-files` artifact when the job fails. It uses the same pinned `actions/upload-artifact` as the job's other upload steps. `if-no-files-found: ignore` keeps the step quiet when a failure isn't a crash. I checked the patterns against a local arrow-dataset crash, which left `dataset/hs_err_pid<pid>.log` and `dataset/target/surefire-reports/<timestamp>-jvmRun<N>.dumpstream`. actionlint reports nothing new in `rc.yml`. The step runs only when the job fails, so this PR's own CI run won't exercise it.
| } | ||
|
|
||
| @Test | ||
| public void testImportViewVarCharWithNonZeroOffset() { |
There was a problem hiding this comment.
Many types now use the new offset code, but they have no test with an offset. These types are missing:
- ListView and LargeListView
- ViewVarBinary, VarBinary, LargeVarChar, and LargeVarBinary
- Decimal and Decimal256
- FixedSizeBinary, Float16, and timestamp with time zone
Can we run each of these through assertSlicedImportsMatchSource.
There was a problem hiding this comment.
Added in 0dcebc1, each through assertSlicedImportsMatchSource: ListView, LargeListView, ViewVarBinary, VarBinary, LargeVarChar, LargeVarBinary, Decimal, Decimal256, FixedSizeBinary, Float16, and timestamp with time zone (microseconds, Asia/Tokyo). They all passed without changes to the importer. The helper now compares binary values with assertArrayEquals, since getObject() returns a byte[]. validateFull supports neither list views nor float16, so the helper skips it for them, as it already did for maps and run-end encoded vectors.
| } | ||
|
|
||
| @Test | ||
| public void testImportStructWithNonZeroOffset() { |
There was a problem hiding this comment.
There is no test for deeply nested types with an offset. Maybe add some tests with a struct that contains a map field and a list-of-struct field. Include both nullable and non-nullable children. Also add a list where both the list and its child array have their own offsets
There was a problem hiding this comment.
Added in 0dcebc1. testImportStructOfMapAndListOfStructsWithNonZeroOffset imports, at every offset, a struct with a nullable map and a non-nullable list of structs whose x is nullable and y is not. testImportListWithChildOffsetWithNonZeroOffset slices a list at offset 1 and its child at offset 2, and checks the lists against hand-computed values.
| * Imports a slice of {@code source}, exported with the type of {@code field}, and compares it | ||
| * with the same range of the source. | ||
| */ | ||
| private void assertSlicedImportMatchesSource( |
There was a problem hiding this comment.
This helper does not compare the null count of the imported vector with the source. The importer ignores the producer's null count and works it out again. So the test should check that the result is correct
There was a problem hiding this comment.
Added in 0dcebc1: the helper now checks the imported vector's null count against the number of null rows in the slice of the source, for every test that uses it. Unions and run-end encoded vectors have no validity bitmap of their own and always report a null count of 0, so for them it expects 0.
| || vector.getField().getType().getTypeID() == ArrowType.ArrowTypeID.Null, | ||
| "ArrowArray struct has non-zero offset (%s), which is not supported", | ||
| snapshot.offset); | ||
| checkState(snapshot.offset >= 0, "ArrowArray struct has negative offset (%s)", snapshot.offset); |
There was a problem hiding this comment.
The new error for a negative offset has no test. There is also no test for offset plus length being larger than an int can hold.
There was a problem hiding this comment.
Added in 0dcebc1. testImportArrayWithNegativeOffsetFails covers the negative offset, and the importer now rejects a negative length the same way (testImportArrayWithNegativeLengthFails).
Offset plus length beyond an int is fine for most types: the importer skips the whole bytes of rows before the slice by advancing the buffer pointers, so the temporary vector holds fewer than 8 rows before the slice. testImportSliceWithOffsetBeyondIntRange imports 5 rows at offset 2^31 + 3, with the buffer pointers moved back by the rows that are skipped. Run-end encoded arrays are the exception, because their run ends count from the start of the array, so their rows cannot be skipped. Such an import used to fail with an ArithmeticException from Math.addExact, and with bounds checking off checkedCastToInt could truncate the offset. It now checks the row count first and fails with a message that gives the offset, the length and the rows needed (testImportRunEndEncodedSliceBeyondIntRangeFails).
| } | ||
|
|
||
| @Test | ||
| public void testWithDictionaryAndOffsetsPastTheFirstByte() throws Exception { |
There was a problem hiding this comment.
These tests only put an offset on the indices. Can we add a test where the dictionary values array also has an offset. Also add non-ASCII strings to setTestStrings and to the view test, such as "日本語", "é", and an emoji?
There was a problem hiding this comment.
Added in 0dcebc1. testWithDictionaryValuesWithNonZeroOffset slices the values of the dictionary at offsets 1 and 9, so that the indices refer to the values from the offset, and its values include é, 日本語 and 😀. setTestStrings and the view tests now have those characters too, in strings both within and beyond the 12 bytes that a view holds inline.
| snapshot.length = Math.addExact(offset, length); | ||
| snapshot.null_count = unknownNullCount(snapshot); | ||
|
|
||
| Field unslicedField = field != null ? field : vector.getField(); |
There was a problem hiding this comment.
When field is null (the public Data.importIntoVector(allocator, array, vector, provider)), the temporary vector is built from vector.getField(). For a dense union, that doesn't reproduce the target. DenseUnionVector.getField() walks typeFields, which is indexed by type id, so it returns the children sorted by type id. The C array and the target keep the producer's order.
With type ids [10, 5] (children a, b), the temporary vector has children [b, a]. importUnsliced loads C child 0 (a) into the temporary type-5 vector, and createTransferPairs() then rewrites the target's typeMapFields in the temporary vector's order. If both children are IntVector, checkTransferable passes and rows read values from the wrong child, with no error. If the classes differ, it throws a misleading "its field create a ..." error.
The same array imports correctly with offset 0, and through Data.importVector, which passes the field from the C schema. So only the sliced import without a field is affected. All the dense union tests go through importVector, and testImportDenseUnionWithTypeIdsWithNonZeroOffset uses ascending ids with children of different types, so none of them catch this.
Field field = new Field("u",
FieldType.nullable(new ArrowType.Union(UnionMode.Dense, new int[] {10, 5})),
Arrays.asList(
Field.nullable("a", MinorType.INT.getType()),
Field.nullable("b", MinorType.INT.getType())));
// source rows: a=100, b=201, a=102, b=203
Data.exportVector(allocator, source, null, array);
slice(array, 1, 3);
try (FieldVector target = field.createVector(allocator)) {
Data.importIntoVector(allocator, array, target, null);
// expected [201, 102, 203]
}Could we build the temporary field in the target's child order (children from getChildrenFromFields(), type ids from typeMapFields), or make DenseUnionVector.getField() keep the child order?
There was a problem hiding this comment.
Fixed in 90e7afe with the second option: DenseUnionVector.getField() now lists the children in their order, each with its type id, instead of sorting them by type id. Exports had the same mismatch: Data.exportVector with a schema takes the schema from getField() but the array's children from getChildrenFromFields(). createTransferPairs() now also sets the target's nextTypeId, which stayed at 0 for a target created by a transfer pair, so that target's getField() lists its children too.
testImportIntoDenseUnionWithDescendingTypeIdsWithNonZeroOffset (0dcebc1) is your example, imported with importIntoVector and no field. Before the fix its first row came back as 100. testImportDenseUnionWithDescendingTypeIdsWithNonZeroOffset imports every slice through a schema exported from getField(), and TestDenseUnionVector.testGetFieldKeepsChildOrder checks the field of the vector and of a transfer target.
| snapshot.null_count = unknownNullCount(snapshot); | ||
|
|
||
| Field unslicedField = field != null ? field : vector.getField(); | ||
| try (FieldVector unslicedVector = unslicedField.createVector(allocator)) { |
There was a problem hiding this comment.
Field.createVector builds a struct with the default CONFLICT_REPLACE policy, so duplicate child names collapse into one. importIntoVectorSchemaRoot uses StructVector.emptyWithDuplicates on purpose for this case (see testVectorSchemaRootWithDuplicatedFieldNames), but the sliced path loses that. With columns [a, a, b] and a non-zero offset, the temporary struct has 2 children, and importUnsliced fails with ArrowArray struct has 3 children (expected 2). The same array imports fine with offset 0.
Keeping the duplicates in the temporary vector is not enough on its own. StructTransferPair maps children by name (from.getChild(child)), so both a columns would be routed to the first one.
One option is to create the temporary struct with the target's conflict policy and transfer struct children by position. Another is to skip the temporary vector for structs: the importer already pushes the offset into struct children, so only the struct's own validity bitmap would need shifting.
There was a problem hiding this comment.
Fixed in 0dcebc1 with the second option: a struct with a non-zero offset is now imported into the target itself. Its offset is pushed into its children, each of which is imported into the child at the same position, and only the struct's own validity bitmap is sliced, through a temporary struct without children. A transfer pair into the target would have called the target's setValueCount(), which allocates and zeroes the buffers of every child.
testImportVectorSchemaRootWithDuplicatedFieldNamesWithNonZeroOffset imports columns [a, a, b] at offsets 3, 8 and 13, with different values in the two a columns. Before the fix it failed in checkTransferable, because the collapsed temporary struct [a, b] put a VarCharVector against the target's second a.
This also removes the case my earlier reply left unsupported: an extension vector that does not report its type, as a child of the caller's struct, is now imported like a top-level vector, so testImportIntoStructWithPlainExtensionChildWithNonZeroOffset checks the values instead of the exception. Below a list, map or union it still fails with the same error.
| for (int i = startIndex; i < startIndex + length; i++) { | ||
| byte typeId = typeBuffer.getByte(i); | ||
| if (typeId >= 0 && !contiguous[childIndexes[typeId]]) { | ||
| internalTransferPairs[childIndexes[typeId]].copyValueSafe( |
There was a problem hiding this comment.
The fallback for children whose offsets have gaps copies values with copyValueSafe, which not every child type supports:
RunEndEncodedVectordoesn't overridecopyFrom, so it hitsBaseValueVector.copyFromand throws anUnsupportedOperationExceptionwith no message.LargeListVector.copyFromthrows "LargeListViewVector does not support copyFrom operation yet".
So splitAndTransfer fails for a dense union with a run-end encoded or large list view child whose offsets in the window are not consecutive, and so does a sliced C import of one. The exception is thrown after the contiguous children were already transferred, so the target is left partly filled.
setValueCount() recomputes the child value counts from the type buffer, so I agree the target can't keep the gaps and copying is the way to go.
Could we check up front that every child that needs copying supports it, and otherwise throw an error that names the child type?
There was a problem hiding this comment.
Fixed in 90e7afe. The values of children with gaps are now copied before any child is transferred. If a copy throws UnsupportedOperationException, the target is cleared and the split throws an UnsupportedOperationException that names the union, the child and its class, with the original as the cause. The copy itself is the check, rather than a list of classes: a check on the child's class would miss a struct with a run-end encoded field, whose copy fails the same way.
testSplitAndTransferWithOffsetGapsInRunEndEncodedChild and testSplitAndTransferWithOffsetGapsInLargeListViewChild check the message, and that the target has no rows and its int child no values. Before the fix the int child had already been transferred.
DenseUnionVector.getField() listed the children in the order of their type ids, while getChildrenFromFields() and exported arrays keep the order of the field. Code that pairs the two by position, such as an exported schema or the C Data importer's temporary vector, mixed up the children of a union whose type ids are not ascending. List the children in their order with the type id of each, and give a target created by a transfer pair the type count of its source. When a split has to copy the values of children whose offsets have gaps, copy them before transferring the other children, and clear the target if a child does not support copying, so that the error names the child instead of leaving the target partly filled.
Transferring a struct from a temporary vector matches its children by name, which lost children with the same name, such as the columns of a VectorSchemaRoot with duplicate field names. Import a sliced struct into the target itself: push its offset into its children and slice only its own validity bitmap. Reject a negative length, and an import whose temporary vector would need more rows than a vector can hold, which only run-end encoded arrays can reach. Test the remaining types, nested types, list children with an offset of their own, sliced dictionaries, null counts and strings that are not ASCII.
parthchandra
left a comment
There was a problem hiding this comment.
There is still no test (in c/src/test/python/integration_tests.py) that slices data in pyarrow and imports it into Java, and that is how the original bug happened. All the Java tests fake a slice by editing the offset on an array that Java exported.
| typeCounts[typeId] += 1; | ||
| if (typeStarts[typeId] == -1) { | ||
| typeStarts[typeId] = offsetBuffer.getInt((long) i * OFFSET_WIDTH); | ||
| int child = childIndexes[typeId]; |
There was a problem hiding this comment.
A type id that does not belong to the union still crashes with ArrayIndexOutOfBoundsException at childCounts[-1]. This only happens when the producer sends bad data. It does not return wrong data or leak memory. It should still throw a clear error that names the bad type id, and it needs a test.
What's Changed
The C Data Interface importer ignored the
offsetof anArrowArraystruct, and since #1263 rejected it outright. Exporting a slicedRecordBatchfrom C++ puts the slice offset on every child array, so such batches could not be imported into Java.ArrayImporternow supports a non-zero offset. Java vectors cannot represent an offset, so the importer loads the rows up to the end of the slice into a temporary vector and transfers the slice into the target withsplitAndTransfer. Leading rows that fill whole bytes of a bitmap are skipped by advancing the buffer pointers, so the temporary vector, and any validity bitmap synthesized for it, starts fewer than 8 rows before the slice. Children indexed by the rows of their parent (sparse union, fixed-size list) get the same skip and are trimmed to the slice. A struct is imported into the target itself instead, because transferring a struct matches its children by name, which loses children with the same name: its offset is pushed into its children, and only its own validity bitmap is sliced. Run-end encoded arrays are sliced by the split alone, so they fail with a clear error when the rows up to the end of the slice are more than a vector can hold. Fixed-width data buffers and byte-aligned validity bitmaps are shared with the C memory, while offset buffers, unaligned validity bitmaps and view data are copied.The null count reported by a producer only covers the slice, so the temporary import passes an unknown null count (-1) when a validity bitmap is present and 0 when the producer omitted it.
The temporary vector is created from the imported field, so it has the extension types of vectors whose
getField()reports only their storage type, and the top-level transfer happens between storage vectors. Importing into an existing vector fails with a clear error if an extension vector nested in a list, map or union does not report its type.Bugs in the vectors this relies on are fixed:
ExtensionTypeVector.makeTransferPairfailed with aClassCastExceptionfor an extension vector target, unless the subclass overrode it asUuidVectorandJsonVectordo andOpaqueVectordoes not.DenseUnionVectorsplit indexed its per-child state by type id, which failed for type ids other than 0 to n - 1. It also returned wrong values when the offsets of a child had gaps. Such values are now copied before anything is transferred, so a child that does not support copying, such as a run-end encoded vector, fails the split with an error that names it instead of leaving the target partly filled.DenseUnionVector.getField()listed the children in the order of their type ids rather than in their own order. Code that pairs the children of the field with those of the vector by position, such as an exported schema or the importer's temporary vector, mixed up the children of a union whose type ids are not ascending.Tests import 20-row vectors at every offset for int, bit, decimal, decimal256, float16, timestamp with time zone, fixed-size binary, varchar, binary, large varchar, large binary, string and binary views, list, large list, list view, large list view, fixed-size list, map, struct (including a struct of a map and a list of structs), sparse and dense union (including type ids that are not ascending), run-end encoded, extension types (UUID, opaque, and one that overrides neither
getField()normakeTransferPair(), at the top level and nested) and dictionary encoded arrays, and compare the values and null counts. Others cover a list whose child has an offset of its own, sliced dictionary values, aVectorSchemaRootwith duplicate field names, a negative offset or length, and a slice whose offset is beyond the range of an int. They also cover the original report's scenario of aVectorSchemaRootwhose children carry the slice offset. Two tests check that importing 10 rows at an offset of 65,536 does not hold memory for the rows before the offset.The documentation now describes offset support and its copying caveat instead of stating that offsets are unsupported.
Closes #88.