Class RowRanges
It began as the bounded scan source's and now serves both source directions and both halves of
the table layer — the scan source's builder, planner, split state and split reader, the Change
Streams partition model, the table source's range parsing, filter pushdown, point lookups and
decode-failure guards, and the table sink's empty-mutation refusal — which is why it sits at the
module root rather than under one of them (ADR-0055): 26 importers in the main tree, across ten
packages. It lives in one place because every one of those would otherwise have to get the bound
types right, and would each get them wrong differently. Row keys are compared as
unsigned bytes, which is the order Bigtable stores them in; the natural ordering of
ByteString is not that order, and a signed comparison sorts every key whose first byte is
above 0x7F before every key whose first byte is below it.
Turning bytes into text: which form, and why there is more than one
A row key, a qualifier and a cell value are all arbitrary bytes, and this module turns them
into text four different ways. That is not drift: the reader chooses the form, and
choosing by package or by habit is how the wrong one gets used. Before rendering a byte string,
ask who reads the result. This is about rendering for something to read and not about
decoding a stored value back into what it was — a serializer's readString is neither
governed nor contradicted by it.
- A person, in a log line or an exception message →
format(ByteString)orformat(ByteStringRange). Printable ASCII stays itself so a text key is recognisable, and every other byte — plus the three that carry structure — becomes\xNN. The rendering is injective, so an operator can tell two of them apart (ADR-0080). - A pattern the user wrote → Base64, canonical padded RFC 4648.
BigtableChangeStreamMutationFiltermatches user regexes againstfamily:qualifierBase64. A pattern needs a form the user can write, which an escape sequence is not. The row-key options take Base64 too, but only whenscan.row-key-encodingasks for it — its default isUTF8— so this arm is about the filter identifier, and an option's own encoding is whatever that option declares. Note it runs the other way from the three below, which are all about output; where the two meet,RowRangeParseris the precedent, and it names the entry number rather than echoing the value back at all. - Anyone, when the value is text by construction →
toStringUtf8(). A qualifier built from a DDL field name is valid UTF-8 and is the identifier the reader must match against their own DDL; escaping it would render a non-ASCII column as hex, and unlike the family name printed beside it. - A user's own code, which is likely to log the object → keep it out of that object's own
rendering. A row's key and cell values are that row's data:
FailedMutationprints the failed mutation's size, andBigtableChangeStreamMutationhas notoStringat all. An exception message is the deliberate exception, having one chance to name the offending row and no accessors — so this arm bounds atoStringand not a whole object graph: aFailedMutationwhosegetCause()is a serialization failure may carry that message, escaped key and all, into any handler that logs the cause — a message doing its job through this arm's object, not a leak to close. May, because it is per message: of this connector's own refusals only the empty-mutation one names the key, and aFailedMutationcan equally wrap whatever a user's own serializer threw.getRowKey()is null for all of them.
toStringUtf8() on a value that is not text by construction is always wrong.
Decoding invalid UTF-8 substitutes U+FFFD rather than failing, so 0xFE and 0xFF
arrive as one character — the value is exposed and destroyed in the same breath.
Three facts about the vendor's Range.ByteStringRange, measured against google-cloud-bigtable
2.80.0 on 2026-08-09 and relied on below:
- Ranges are mutable.
startClosed/endOpenand their siblings assign to the receiver and return it, despite javadoc reading "Creates a new Range". Every range this connector hands out or stores is therefore built bycopyOf(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange), and no range the user supplied is ever mutated. clone()is not public API. It isprotectedon the package-private superclass, so a copy has to be rebuilt from the four accessors — which is whatcopyOf(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange)does.- An empty key on any bound is normalised to
UNBOUNDEDby the four setters, and not byByteStringRange.create.startClosed(EMPTY),startOpen(EMPTY),endOpen(EMPTY)andendClosed(EMPTY)all produce an unbounded side, butcreate(EMPTY, k)produces a closed start at the empty key. The two spellings are notequaland do not render alike, so a range that arrives in the second spelling has to be converted before anything here compares it — which is whatcopyOf(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange)does, and why the change-stream code copies every range the service hands it. It is also whytruncateStartOpen(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange, com.google.protobuf.ByteString)has to special-case the empty key on output, where silently widening a range to the whole table would make a restored split re-read everything it had already emitted.
-
Method Summary
Modifier and TypeMethodDescriptionstatic List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange>Merges ranges that overlap or run into one another, and returns them in key order.static intcompareEnds(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Orders two ranges by where they end; an unbounded end comes last.static intcompareKeys(com.google.protobuf.ByteString left, com.google.protobuf.ByteString right) Compares two row keys in Bigtable's own order.static intcompareStarts(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Orders two ranges by where they begin; an unbounded start comes first.static booleancontains(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString key) Returns whether a row key belongs to a range.static List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange>Returns independent copies of every range in a list.static com.google.cloud.bigtable.data.v2.models.Range.ByteStringRangecopyOf(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns an independent, normalised copy of a range.static booleancuts(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString key) Returns whether a sampled row key cuts a range into two non-empty pieces.static Stringformat(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Renders a range the way a log reader needs to see it.static Stringformat(com.google.protobuf.ByteString key) Renders one row key underformat(ByteStringRange)'s escaping, for a caller holding a key rather than a range.static List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange>intersect(List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> left, List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> right) Intersects two unions of row-key ranges.static booleanisEmpty(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range provably holds no row key at all.static booleanisUnboundedEnd(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range continues past every row key.static booleanisUnboundedStart(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range begins before every row key.static booleansameEnd(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Returns whether two ranges end at exactly the same point.static booleansameStart(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Returns whether two ranges begin at exactly the same point.static com.google.cloud.bigtable.data.v2.models.Range.ByteStringRangetruncateStartOpen(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString lastEmittedKey) Returns the work a split has left after successfully deserializing a row, as a range starting just past it.
-
Method Details
-
compareKeys
public static int compareKeys(com.google.protobuf.ByteString left, com.google.protobuf.ByteString right) Compares two row keys in Bigtable's own order.- Parameters:
left- the first keyright- the second key- Returns:
- a negative number, zero or a positive number as
leftsorts before, at or afterright
-
copyOf
public static com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange copyOf(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns an independent, normalised copy of a range.Every range that crosses into this connector — from a builder setter, from the planner, from a deserialised split, from the Change Streams service — goes through here, for two reasons. A
Range.ByteStringRangeis mutable, so shared references would let a caller change a plan after it was made. And it is the connector's one normalisation point: rebuilding through the four setters folds an empty key on a bounded side intoUNBOUNDED, which is the spelling everything else here assumes. A range built byByteStringRange.create— every partition and continuation token the service returns — uses the other spelling, and the two are not equal to one another.- Parameters:
range- the range to copy- Returns:
- a normalised range denoting the same rows, sharing no mutable state with it
-
copyAll
public static List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> copyAll(List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> ranges) Returns independent copies of every range in a list.- Parameters:
ranges- the ranges to copy- Returns:
- copies, in the same order, sharing no mutable state with the originals
-
isEmpty
public static boolean isEmpty(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range provably holds no row key at all.Four shapes qualify, the last of which is easy to write by accident and impossible to spot: a start at or after the end; a start equal to a non-inclusive end; and
startOpen(k)paired withendOpen(k + 0x00), where the only key the bounds admit is the one they both exclude.A user-configured empty range is rejected by the builder, because a range that reads nothing under a green job is indistinguishable from a job with nothing to read. A truncated range is a different matter and is normal — see
truncateStartOpen(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange, com.google.protobuf.ByteString).- Parameters:
range- the range to test- Returns:
- true when no row key lies inside it
-
cuts
public static boolean cuts(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString key) Returns whether a sampled row key cuts a range into two non-empty pieces.A split point
kmeans "rows belowkbelong to the section before it, rows fromkonwards to the section after it", so cutting atkis worthwhile exactly when both sides would hold something. Both start bound types answer the same way — cutting at a key equal to the start yields an empty left-hand piece whether the start includes that key or not — while the end bounds differ, because a cut at an inclusive end leaves a right-hand piece holding exactly that one row.- Parameters:
range- the range being cutkey- the candidate split point- Returns:
- true when the key lies strictly inside the range
-
contains
public static boolean contains(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString key) Returns whether a row key belongs to a range.This is deliberately separate from
cuts(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange, com.google.protobuf.ByteString): a key on a closed start belongs to the range but cannot cut a non-empty left-hand piece from it. Point lookups need membership, while split planning needs the stricter cut relation.- Parameters:
range- the range to testkey- the row key- Returns:
- true when the range contains the key
-
truncateStartOpen
public static com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange truncateStartOpen(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range, com.google.protobuf.ByteString lastEmittedKey) Returns the work a split has left after successfully deserializing a row, as a range starting just past it.The end bound is carried over untouched and the original start bound is discarded, which is safe because it sits strictly below the processed key and so constrains nothing. An exclusive start is what makes a restore resume rather than replay, and it is also what the client's own resumption strategy uses when it reconnects a broken stream mid-range.
The result may be empty — a range ending
endClosed(e)whose rowewas successfully deserialized has nothing left, even if it produced no output — and that is a normal end-of-split state, not an error. The split reader finishes such a split without opening a stream, so an inverted range is never sent to the service.The empty-key case is not hypothetical enough to leave out: real Bigtable rejects an empty row key, but the emulator accepts one, and
startOpen(EMPTY)is silently turned into an unbounded start by the SDK — which would widen the split back to the whole table and replay it forever. Progress past the empty key is expressed as an inclusive start at its successor instead.- Parameters:
range- the range the split was assignedlastEmittedKey- the key of the last successfully deserialized row- Returns:
- the remaining range
-
coalesce
public static List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> coalesce(List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> ranges) Merges ranges that overlap or run into one another, and returns them in key order.Overlapping ranges are easy to configure by accident —
prefix("user")besideprefix("user1")is enough — and left alone they are not merely wasteful: the overlapping rows land in two different splits, which two different subtasks read, so a single successful run emits them twice. Deduplication inside one request does not reach across splits, so it has to happen here.Two ranges that merely touch are merged when the key between them belongs to one of them, and left apart when it belongs to neither:
endOpen(k)besidestartOpen(k)excludeskdeliberately, and merging would put a row back that the user removed.- Parameters:
ranges- the ranges to merge, in any order- Returns:
- the merged ranges, sorted by start
-
intersect
public static List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> intersect(List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> left, List<com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange> right) Intersects two unions of row-key ranges.The inputs may overlap and arrive in any order. Each side is coalesced first, then the two sorted lists are walked once. Empty intersections are omitted; the result is therefore an empty list when the two unions share no row key.
- Parameters:
left- the first range unionright- the second range union- Returns:
- independent, coalesced ranges present in both unions
-
format
Renders a range the way a log reader needs to see it.Range.ByteStringRangeinheritsObject.toString(), so a range in a log line is otherwise an identity hash. Printable ASCII is shown as itself and every other byte as\xNN, so a key that is not text stays readable and a key that is text stays recognisable.Three printable bytes are shown escaped rather than as themselves, because each carries structure here:
\introduces an escape,*is the sentinel for an absent bound, and,separates the two bounds. A key holding one of them would otherwise make two different ranges render as one string —[a, b, c)is both "froma, btoc" and "fromatob, c" — and these strings are what an operator reads to tell two ranges apart in a warning. The rendering is therefore injective, and a test asserts that as a property.That is a readability property, not a contract: nothing decides identity from a rendering, and nothing should. Range identity is
ByteStringRange.equals.- Parameters:
range- the range to render- Returns:
- a rendering such as
[row-1, row-9)or(\x00ff, *]
-
format
Renders one row key underformat(ByteStringRange)'s escaping, for a caller holding a key rather than a range.Escaping only — no sentinel, and the empty key renders as the empty string. Every caller that names a row in a message quotes the value (
'%s'), where an empty rendering reads as''and needs no marker. A caller that does not quote, and for which an empty key carries a meaning, supplies its own:RowKeySamplemarks it*, because there it is the service's "end of table" rather than a key. Deciding that here would impose one caller's meaning on the rest, which is the whole reason this method does not.Same caveat as the range form: a rendering is what a person reads in a log, and nothing decides identity from one.
- Parameters:
key- the key to render- Returns:
- a rendering such as
row-1or\x00\xff; empty for the empty key
-
compareStarts
public static int compareStarts(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Orders two ranges by where they begin; an unbounded start comes first.Like
compareKeys(ByteString, ByteString), and unlike the range predicates above, this does not null-check. It is called once per comparison inside a sort or aTreeSet, over ranges the caller has already accepted.- Parameters:
left- the first rangeright- the second range- Returns:
- a negative number, zero or a positive number as
leftbegins before, at or afterright
-
compareEnds
public static int compareEnds(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Orders two ranges by where they end; an unbounded end comes last.- Parameters:
left- the first rangeright- the second range- Returns:
- a negative number, zero or a positive number as
leftends before, at or afterright
-
sameStart
public static boolean sameStart(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Returns whether two ranges begin at exactly the same point.Two unbounded starts are the same start. Otherwise the bound type has to match as well as the key, because
startClosed(k)andstartOpen(k)disagree about the one rowk. This is the equalitycompareStarts(ByteStringRange, ByteStringRange)induces, written out directly so that a caller asking "is this the same edge?" does not have to read a comparator's result against zero.- Parameters:
left- the first rangeright- the second range- Returns:
- true when both begin at the same key with the same bound type, or both are unbounded
-
sameEnd
public static boolean sameEnd(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange left, com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange right) Returns whether two ranges end at exactly the same point.- Parameters:
left- the first rangeright- the second range- Returns:
- true when both end at the same key with the same bound type, or both are unbounded
-
isUnboundedStart
public static boolean isUnboundedStart(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range begins before every row key.The bound type alone answers this, which is correct only for a normalised range. Copies of this helper elsewhere in the module used to add
|| range.getStart().isEmpty(), and that disjunct was not decoration: it absorbed the spellingByteStringRange.createproduces, in which an absent bound is a bounded one at the empty key. The copies are gone and the disjunct with them, so every caller owes a range that has been throughcopyOf(ByteStringRange)— see the third measured fact above.- Parameters:
range- the range to test- Returns:
- true when the range has no lower bound
-
isUnboundedEnd
public static boolean isUnboundedEnd(com.google.cloud.bigtable.data.v2.models.Range.ByteStringRange range) Returns whether a range continues past every row key.- Parameters:
range- the range to test- Returns:
- true when the range has no upper bound
-