Using Java Spliterator for custom data source splitting
A Java Spliterator defines how Java traverses and partitions a source of records. Its trySplit method creates independent portions for parallel processing, useful for databases, files, message queues, paginated APIs, and specialised in-memory collections. It keeps source mechanics separate from stream business logic.
A custom implementation must ensure every element is produced once and every split covers a non-overlapping part of the remaining source. Weak boundaries can duplicate records, lose ordering, or create tiny tasks that cost more to schedule than they save.
Those concerns are practical in Australia. A service with users in Sydney and Melbourne may use shared cloud compute, while customers in regional Western Australia or Queensland can encounter greater network latency. Local teams often talk about making a pipeline “parallelise properly”.
How the splitting contract works
Spliterator<T> exposes four key operations: tryAdvance consumes the next element, trySplit creates another spliterator, estimateSize reports the remaining count, and characteristics describes what callers may assume. Its late-binding traversal keeps source-specific mechanics out of the stream pipeline.
Characteristics such as SIZED, SUBSIZED, ORDERED, IMMUTABLE, NONNULL, DISTINCT, and SORTED are promises. Accurate sizes improve work allocation, while ORDERED preserves encounter order for operations such as findFirst. Incorrect traits can cause subtle parallel-stream bugs.
If a source cannot be divided safely, trySplit should return null; traversal continues sequentially. This matters for one database cursor, which normally cannot be scanned by two consumers. Spliterators.spliteratorUnknownSize(iterator, 0) is useful for an unsized iterator, but it does not create independent partitions.
Implementing a range-based spliterator
For a list with indexed access, a range implementation is straightforward. This version divides the remaining range at its midpoint and advertises exact sizes. Use a copied, immutable list so concurrent traversal cannot observe changes.
final class RangeSpliterator<T> implements Spliterator<T> {
private final List<T> values;
private final int traits;
private int from, to;
RangeSpliterator(List<T> values, int from, int to, int traits) {
this.values = values;
this.from = from;
this.to = to;
this.traits = traits;
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (from == to) return false;
action.accept(values.get(from++));
return true;
}
@Override
public Spliterator<T> trySplit() {
if (from == to) return null;
int middle = from + ((to - from) >>> 1);
if (middle == from) return null;
RangeSpliterator<T> right =
new RangeSpliterator<>(values, middle, to, traits);
to = middle;
return right;
}
@Override
public long estimateSize() {
return to - from;
}
@Override
public int characteristics() {
return traits;
}
}
Create it with List.copyOf(records) and traits such as ORDERED | SIZED | SUBSIZED | IMMUTABLE, then pass it to StreamSupport.stream(spliterator, true). This works because the list supports random access; an HTTP response or cursor requires a different partitioning strategy.
Every returned partition follows the same contract. An exception should normally fail the stream instead of silently skipping data, leaving retries and error policy to the surrounding pipeline.
Describing source characteristics accurately
Source design determines which characteristics are honest. A fixed file with a known byte count may be SIZED, but a compressed or remote response might support only an estimate. If duplicates are allowed, do not advertise DISTINCT; if timestamps define order, preserve that rule when splitting.
Weather data illustrates the distinction. The Bureau of Meteorology publishes observations across Australian states, and records may use Australia/Sydney, Australia/Brisbane, or other IANA zones. A weather data pipeline can keep timestamp normalisation separate from partitioning, so parallel filtering does not change the chronological contract.
Declare IMMUTABLE only when the source cannot change during traversal. An array-backed snapshot can qualify; a list being appended by another thread cannot. This matters when one batch combines a BOM feed, an order system, and a customer database.
Splitting external and legacy data sources
External sources need capabilities beyond trySplit. A JDBC ResultSet is best consumed sequentially unless the database can issue independent range or keyset queries. File splitters can divide byte offsets, but boundaries must land on complete records. Paginated APIs can expose page tokens as partitions when those tokens are independently fetchable.
Retries and partial failures need deliberate handling. Blindly retrying a failed partition can duplicate successful records, so downstream processing should use an idempotency key or transactional checkpoint. This suits Australian billing systems, where amounts often use AUD and may include GST, while customer records can use identifiers such as an NMI.
A product sold nationally may run in a Sydney or Melbourne cloud region while serving customers near Perth, Adelaide, or Brisbane. Measure throughput and memory against that geography, cap concurrent requests, and keep prefetching bounded rather than allowing pages or buffers to accumulate without limit.
Recommendations for production use
Before putting a custom source into production, use these checks to keep splitting predictable and safe:
- Split only sources with clear, non-overlapping boundaries; return
nullwhen no safe split exists. - Report accurate sizes and declare only characteristics the source can guarantee.
- Keep partition creation inexpensive; avoid network calls inside
trySplitunless genuinely required. - Prevent concurrent mutation, or isolate an immutable snapshot before traversal begins.
- Compare sequential and parallel results for ordering, duplicates, omissions, and exceptions.
- Bound memory, prefetching, and parallelism instead of creating unbounded pages or buffers.
Test with empty, single-element, odd-sized, and failed partitions, not only a large balanced batch. Check that estimateSize remains sensible when the source grows or becomes unavailable.
With those safeguards, a custom spliterator turns an awkward source into a predictable stream. The result is clearer control over workload shape, resource limits, and behaviour for teams maintaining Java services in Australia’s mixed cloud and regional market.