Experiment 089: Deeply-immutable ResultSet for zero-copy isolate transfer

Date: 2026-04-21

Status: Rejected (blocked upstream — re-check when DI typed-data factory ships)

Problem

Every read crosses an isolate boundary. Below the byte-size sacrifice

threshold (exp 039) we pay a SendPort.send copy; above it we pay an

Isolate.exit + reader respawn. The ~0.05 ms copy on sub-256 KB results

scales linearly with result size.

Hypothesis

Dart's @pragma('vm:deeply-immutable') lets an instance of an annotated

class pass between isolates in the same isolate group as a pointer

handoff — zero-copy on SendPort.send. Our reader pool is one isolate

group (workers spawned from the main isolate). If ResultSet, Row,

and their backing storage can be marked deeply immutable, every read

becomes zero-copy regardless of size, and the sacrifice-threshold

machinery (exp 039) retires.

Investigation

1. SDK status

2. Constraints on what can be marked (from the design doc)

A class annotated @pragma('vm:deeply-immutable') must:

The closed list of deeply-immutable types today:

Explicitly NOT on the list:List<T>, Map<K,V>, Set<T>, Uint8List, Int64List, any typed-data list, any Iterable. List.unmodifiable produces a shallow-immutable list (different VM bit) which is not the same thing and currently does not participate in zero-copy transfer in the same way.

SDK issue #50068 ("API to create deeply immutable typed data") is open and unresolved: the VM supports deeply-immutable typed data internally (via an embedder API), but no public Dart factory exists to produce one.

3. How our current shape conforms

lib/src/row.dart:

 final class ResultSet with ListMixin<Row> { final List<Object?> _values;   // BLOCKS: List is not DI final RowSchema _schema;        // RowSchema has List<String> + Map<String,int> — both BLOCK final int _rowCount; } final class Row with MapMixin<String, Object?> { final List<Object?> _values;   // BLOCKS final RowSchema _schema;        // BLOCKS final int _offset; } 

lib/src/query_decoder.dart:

 final class RawQueryResult { final List<Object?> values;    // BLOCKS final RowSchema schema;         // BLOCKS final int rowCount; final int estimatedBytes; } 

Cell payload types produced by decodeQuery include Uint8List for BLOB columns (line ~269). Uint8List is not deeply immutable, so even a hypothetical "immutable List<Object?>" would fail deep immutability the moment a blob row appears.

The worker message tuple sent via SendPort.send / Isolate.exit in lib/src/reader/read_worker.dart is a Record containing the ResultSet (or a nested record with it). Records themselves are not in the deeply-immutable set either — the design doc doesn't carve out record types.

4. Ecosystem survey

Decision

Rejected — blocked upstream. Two independent hard blockers:

  1. No deeply-immutable list type.ResultSet and RawQueryResult

are centered on List<Object?> and RowSchema.names is

List<String>. Without a DI list, nothing above them can be marked.

Replacing the backing list with a chain of DI records (one DI class

per value, linked) would explode object count, lose flat-list cache

locality, and almost certainly regress the shape exp 082 just

showed is near-optimal.

  1. No deeply-immutable Uint8List. BLOB cells are Uint8List.

Even if we solved blocker 1, blobs would block. SDK #50068 is the

upstream tracking issue.

Blocker 1 is the structural one; blocker 2 is a subset of it.

Re-check when either:

factory ships (track SDK #50068), or

broader zero-copy send path for shallow-immutable graphs (track

SDK #56841).

Until then, the ~0.05 ms memcpy cost for sub-256 KB results isn't

worth a restructuring that would fight the language. Exp 082 already

showed the current shape wins at 10 k rows on both SendPort.send

and Isolate.exit. The sacrifice-threshold machinery (exp 039) stays.

Low-cost follow-ups worth doing now

If blocker 1 ever lifts — minimal refactor sketch

Breadth estimate: ~4 files, ~150 LoC, one new cell-storage abstraction, full test suite should pass unchanged because the Map/List facades don't change.

 @pragma('vm:deeply-immutable') final class ImmutableRowSchema { final ImmutableList<String> names;              // needs DI List final ImmutableMap<String, int> indexByName;    // needs DI Map ImmutableRowSchema(this.names, this.indexByName); } @pragma('vm:deeply-immutable') final class ImmutableResultSet { final ImmutableList<Object?> values;            // needs DI List<Object?> final ImmutableRowSchema schema; final int rowCount; final int estimatedBytes; ImmutableResultSet(this.values, this.schema, this.rowCount, this.estimatedBytes); } 

Files that would touch: lib/src/row.dart, lib/src/query_decoder.dart,

lib/src/reader/read_worker.dart, lib/src/writer/writer_worker.dart.

Blob cells would need an ImmutableBytes wrapper once #50068 ships.

selectBytes is unaffected — it already transports a single Uint8List

via the raw bytes path.