@zio.dev/zio-blocks 0.0.33 → 0.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adr/2026-07-18-data-migration.md +123 -0
- package/guides/async-getting-started.md +687 -0
- package/guides/compile-time-resource-safety-with-scope.md +21 -16
- package/guides/getting-started-with-mux.md +1395 -0
- package/guides/query-dsl-extending.md +161 -102
- package/guides/query-dsl-fluent-builder.md +217 -157
- package/guides/query-dsl-reified-optics.md +12 -10
- package/guides/query-dsl-sql.md +640 -165
- package/guides/sql-checked-interpolation.md +173 -0
- package/guides/sql-transactions.md +286 -0
- package/guides/telemetry-guide.md +1130 -0
- package/guides/zio-schema-migration.md +29 -22
- package/index.md +248 -389
- package/package.json +1 -1
- package/plans/config-follow-up-prs.md +188 -0
- package/plans/config-pr-assessment-roadmap.md +310 -0
- package/reference/MuxDataFlow.jsx +250 -0
- package/reference/async.md +1499 -0
- package/reference/chunk.md +3533 -308
- package/reference/codegen/case-class.md +436 -0
- package/reference/codegen/emitter-config.md +383 -0
- package/reference/codegen/examples.md +664 -0
- package/reference/codegen/field.md +316 -0
- package/reference/codegen/index.md +317 -0
- package/reference/codegen/scala-emitter.md +392 -0
- package/reference/codegen/scala-file.md +276 -0
- package/reference/codegen/sealed-trait.md +408 -0
- package/reference/codegen/type-definition.md +340 -0
- package/reference/codegen/type-ref.md +201 -0
- package/reference/combinators.md +347 -117
- package/reference/config/config-decoder.md +460 -0
- package/reference/config/config-source.md +489 -0
- package/reference/config/errors.md +278 -0
- package/reference/config/flags.md +369 -0
- package/reference/config/formats.md +314 -0
- package/reference/config/index.md +304 -0
- package/reference/config/rollout.md +336 -0
- package/reference/context.md +9 -52
- package/reference/data-migration.md +269 -0
- package/reference/datastar/attributes.md +302 -0
- package/reference/datastar/events.md +234 -0
- package/reference/datastar/index.md +256 -0
- package/reference/datastar/signals.md +230 -0
- package/reference/datastar/sse.md +295 -0
- package/reference/datastar.md +346 -0
- package/reference/docs.md +1461 -345
- package/reference/endpoint/auth-type.md +146 -0
- package/reference/endpoint/bulk-creation.md +96 -0
- package/reference/endpoint/endpoint.md +297 -0
- package/reference/endpoint/http-codec.md +249 -0
- package/reference/endpoint/index.md +745 -0
- package/reference/endpoint/path-codec.md +225 -0
- package/reference/endpoint/route-pattern.md +194 -0
- package/reference/endpoint/route-tree.md +111 -0
- package/reference/endpoint/segment-codec.md +199 -0
- package/reference/html.md +1424 -0
- package/reference/htmx/attribute-values.md +359 -0
- package/reference/htmx/hx-encoding.md +111 -0
- package/reference/htmx/hx-params.md +204 -0
- package/reference/htmx/hx-swap.md +276 -0
- package/reference/htmx/hx-sync.md +251 -0
- package/reference/htmx/hx-target.md +314 -0
- package/reference/htmx/hx-trigger.md +457 -0
- package/reference/htmx/hx-url-update.md +239 -0
- package/reference/htmx/index.md +807 -0
- package/reference/htmx/response-headers.md +240 -0
- package/reference/http-model/headers.md +735 -0
- package/reference/http-model/index.md +49 -0
- package/reference/http-model/model.md +1517 -0
- package/reference/http-model/schema-codecs.md +522 -0
- package/reference/http-model/schema.md +750 -0
- package/reference/http-model/server-sent-event.md +341 -0
- package/reference/jwt.md +195 -0
- package/reference/maybe.md +943 -0
- package/reference/media-type.md +2 -2
- package/reference/mux.md +254 -0
- package/reference/mux.mdx +828 -0
- package/reference/openapi.md +1351 -0
- package/reference/projection.md +654 -0
- package/reference/resource-management/defer-handle.md +1 -1
- package/reference/resource-management/resource.md +31 -98
- package/reference/resource-management/scope.md +28 -220
- package/reference/resource-management/wire.md +5 -55
- package/reference/ringbuffer/MpmcDiagram.jsx +717 -0
- package/reference/ringbuffer/MpscDiagram.jsx +618 -0
- package/reference/ringbuffer/SpmcDiagram.jsx +680 -0
- package/reference/ringbuffer/SpscDiagram.jsx +677 -0
- package/reference/ringbuffer/advanced.mdx +109 -0
- package/reference/ringbuffer/index.mdx +145 -0
- package/reference/ringbuffer/mpmc.mdx +185 -0
- package/reference/ringbuffer/mpsc.mdx +164 -0
- package/reference/ringbuffer/spmc.mdx +108 -0
- package/reference/ringbuffer/spsc.mdx +416 -0
- package/reference/{allows.md → schema/allows.md} +4 -100
- package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
- package/reference/{binding.md → schema/binding.md} +3 -4
- package/reference/schema/built-in-codecs/avro.md +451 -0
- package/reference/schema/built-in-codecs/bson.md +510 -0
- package/reference/schema/built-in-codecs/csv.md +564 -0
- package/reference/schema/built-in-codecs/index.md +77 -0
- package/reference/schema/built-in-codecs/json/index.md +295 -0
- package/reference/schema/built-in-codecs/json/json-config.md +217 -0
- package/reference/{json-patch.md → schema/built-in-codecs/json/json-patch.md} +5 -5
- package/reference/{json-schema.md → schema/built-in-codecs/json/json-schema.md} +14 -47
- package/reference/schema/built-in-codecs/json/json-selection.md +322 -0
- package/reference/{json.md → schema/built-in-codecs/json/json.md} +32 -64
- package/reference/schema/built-in-codecs/messagepack.md +508 -0
- package/reference/schema/built-in-codecs/thrift.md +433 -0
- package/reference/schema/built-in-codecs/toon.md +1078 -0
- package/reference/{xml.md → schema/built-in-codecs/xml.md} +13 -9
- package/reference/schema/built-in-codecs/yaml.md +552 -0
- package/reference/{codec.md → schema/codec.md} +11 -11
- package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +196 -5
- package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
- package/reference/schema/format.md +92 -0
- package/reference/schema/index.md +52 -0
- package/reference/schema/migration.md +297 -0
- package/reference/{modifier.md → schema/modifier.md} +58 -7
- package/reference/{optics.md → schema/optics.md} +2 -2
- package/reference/{patch.md → schema/patch.md} +1 -1
- package/{path-interpolator.md → reference/schema/path-interpolator.md} +167 -72
- package/reference/schema/reflect-transformer.md +140 -0
- package/reference/{schema-evolution → schema/schema-evolution}/as.md +8 -8
- package/reference/{schema-evolution → schema/schema-evolution}/index.md +2 -2
- package/reference/{schema-evolution → schema/schema-evolution}/into.md +8 -8
- package/reference/{schema-expr.md → schema/schema-expr.md} +110 -175
- package/reference/schema/schema-search.md +263 -0
- package/reference/{schema.md → schema/schema.md} +22 -2
- package/reference/{structural-types.md → schema/structural-types.md} +1 -1
- package/reference/{type-class-derivation.md → schema/type-class-derivation.md} +63 -1
- package/reference/smithy.md +1032 -0
- package/reference/sql/db-codec-deriver.md +71 -0
- package/reference/sql/db-codec.md +687 -0
- package/reference/sql/db-con.md +271 -0
- package/reference/sql/db-connection.md +153 -0
- package/reference/sql/db-param-writer.md +77 -0
- package/reference/sql/db-param.md +66 -0
- package/reference/sql/db-result-reader.md +148 -0
- package/reference/sql/db-tx.md +114 -0
- package/reference/sql/db-value.md +41 -0
- package/reference/sql/ddl.md +85 -0
- package/reference/sql/frag.md +288 -0
- package/reference/sql/index.md +341 -0
- package/reference/sql/repo.md +600 -0
- package/reference/sql/sql-dialect.md +73 -0
- package/reference/sql/sql-logger.md +62 -0
- package/reference/sql/sql-name-mapper.md +70 -0
- package/reference/sql/table-metadata.md +134 -0
- package/reference/sql/table.md +448 -0
- package/reference/sql/transactor-zio.md +399 -0
- package/reference/sql/transactor.md +363 -0
- package/reference/sql-zio.md +112 -0
- package/reference/streams/core/index.md +32 -0
- package/reference/streams/core/pipeline.md +854 -0
- package/reference/streams/core/sink.md +1404 -0
- package/reference/streams/core/stream.md +3236 -0
- package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
- package/reference/streams/execution-and-compatibility/index.md +35 -0
- package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
- package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
- package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
- package/reference/streams/index.md +726 -0
- package/reference/streams/primitives/index.md +30 -0
- package/reference/streams/primitives/reader.md +1992 -0
- package/reference/streams/primitives/writer.md +1201 -0
- package/reference/telemetry/common/any-value.md +90 -0
- package/reference/telemetry/common/attribute-key.md +87 -0
- package/reference/telemetry/common/attributes.md +118 -0
- package/reference/telemetry/common/index.md +39 -0
- package/reference/telemetry/common/instrumentation-scope.md +24 -0
- package/reference/telemetry/common/resource.md +34 -0
- package/reference/telemetry/index.md +311 -0
- package/reference/telemetry/logging/index.md +197 -0
- package/reference/telemetry/logging/log-enrichment.md +72 -0
- package/reference/telemetry/logging/log-formatter.md +100 -0
- package/reference/telemetry/logging/log-record-processor.md +56 -0
- package/reference/telemetry/logging/log-record.md +44 -0
- package/reference/telemetry/logging/log-writer.md +64 -0
- package/reference/telemetry/logging/logger-provider.md +142 -0
- package/reference/telemetry/logging/logger.md +83 -0
- package/reference/telemetry/logging/severity.md +62 -0
- package/reference/telemetry/metrics/index.md +150 -0
- package/reference/telemetry/metrics/instruments.md +183 -0
- package/reference/telemetry/metrics/labeled-instruments.md +74 -0
- package/reference/telemetry/metrics/meter-provider.md +76 -0
- package/reference/telemetry/metrics/meter.md +98 -0
- package/reference/telemetry/metrics/metric-data.md +57 -0
- package/reference/telemetry/otel/custom-exporter.md +216 -0
- package/reference/telemetry/otel/index.md +212 -0
- package/reference/telemetry/tracing/index.md +155 -0
- package/reference/telemetry/tracing/sampler.md +89 -0
- package/reference/telemetry/tracing/span-builder.md +57 -0
- package/reference/telemetry/tracing/span-context.md +39 -0
- package/reference/telemetry/tracing/span-data.md +32 -0
- package/reference/telemetry/tracing/span-kind.md +55 -0
- package/reference/telemetry/tracing/span-processor.md +53 -0
- package/reference/telemetry/tracing/span-status.md +47 -0
- package/reference/telemetry/tracing/span.md +117 -0
- package/reference/telemetry/tracing/tracer-provider.md +91 -0
- package/reference/telemetry/tracing/tracer.md +52 -0
- package/reference/typeid.md +5 -83
- package/sidebars.js +376 -43
- package/undocumented-report.md +528 -270
- package/reference/formats.md +0 -694
- package/reference/http-model.md +0 -1716
- package/reference/streams.md +0 -989
- package/ringbuffer.md +0 -249
- /package/reference/{json-differ.md → schema/built-in-codecs/json/json-differ.md} +0 -0
- /package/reference/{dynamic-value.md → schema/dynamic-value.md} +0 -0
- /package/reference/{lazy.md → schema/lazy.md} +0 -0
- /package/reference/{reflect.md → schema/reflect.md} +0 -0
- /package/reference/{registers.md → schema/registers.md} +0 -0
- /package/reference/{schema-error.md → schema/schema-error.md} +0 -0
- /package/reference/{syntax.md → schema/syntax.md} +0 -0
- /package/reference/{validation.md → schema/validation.md} +0 -0
|
@@ -0,0 +1,1992 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: reader
|
|
3
|
+
title: "Reader"
|
|
4
|
+
sidebar_label: "Reader"
|
|
5
|
+
description: "The pull-based source behind ZIO Blocks streams: the SyncReader and AsyncReader kinds, the pull protocol, and the custom reader contracts."
|
|
6
|
+
keywords:
|
|
7
|
+
- "Pull-Based Streaming"
|
|
8
|
+
- "Reader Kinds"
|
|
9
|
+
- "Asynchronous Reading"
|
|
10
|
+
- "Sentinel Protocol"
|
|
11
|
+
- "Reader"
|
|
12
|
+
- "AsyncNioReaders"
|
|
13
|
+
- "ReadableStreamReaders"
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
`Reader[+Elem]` is the **pull-based source that powers ZIO Blocks streams**. When you call a terminal operation like `stream.run(sink)`, the stream compiles into a `Reader`, which yields values one at a time on demand until closed.
|
|
17
|
+
|
|
18
|
+
`Reader` has two library-provided kinds, `Reader.SyncReader[Elem]` and `Reader.AsyncReader[Elem]`. A synchronous reader's `read` and `close` return directly; an asynchronous reader's pull and lifecycle methods return `Async`. Most users never interact with either kind directly, but understanding them clarifies how streams work internally.
|
|
19
|
+
|
|
20
|
+
The compilation and execution flow:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Stream[E, A] ──(compile)──> Reader[A]
|
|
24
|
+
│
|
|
25
|
+
└─(drain via Sink)──> Either[E, Z]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`Reader`:
|
|
29
|
+
- Is lazy and pull-based — `Stream` transformations don't run until `read()` is called, running in constant space one element at a time
|
|
30
|
+
- Is a single-consumer cursor — do not share a `SyncReader` between threads or overlap operations on an `AsyncReader`
|
|
31
|
+
- Uses a sentinel protocol where callers specify the end-of-stream value; all eight JVM primitives have exact physical pull methods: `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`. The `Long` and `Double` lanes are the exception: no sentinel is safe there, so they detect end of stream by the count returned from `readLongs` / `readDoubles`.
|
|
32
|
+
- Dispatches on `Reader#jvmType`, which describes the reader's physical representation and therefore the exact pull method it supports, not merely the static or logical element type
|
|
33
|
+
- Is the compilation target of `Stream` — when a stream runs, it becomes a `Reader`
|
|
34
|
+
- Transfers lifecycle responsibility explicitly: terminals and bracketed APIs close their owned reader, while callers of `startAsync` own the returned reader and must await `close()`
|
|
35
|
+
- Supports composition by chaining readers through transformations without materializing intermediate data
|
|
36
|
+
|
|
37
|
+
Here is the core `Reader` interface with the most essential methods:
|
|
38
|
+
|
|
39
|
+
```scala
|
|
40
|
+
abstract class Reader[+Elem]
|
|
41
|
+
|
|
42
|
+
abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
|
|
43
|
+
def read[A >: Elem](sentinel: A): A
|
|
44
|
+
def readAll[A >: Elem](): Chunk[A]
|
|
45
|
+
def readN[A >: Elem](n: Int): Chunk[A]
|
|
46
|
+
def readUpToN[A >: Elem](n: Int): Chunk[A]
|
|
47
|
+
def isClosed: Boolean
|
|
48
|
+
def readable(): Boolean
|
|
49
|
+
def close(): Unit
|
|
50
|
+
def toAsync: Reader.AsyncReader[Elem]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
54
|
+
def read[A >: Elem](sentinel: A): Async[A]
|
|
55
|
+
def readAll[A >: Elem](): Async[Chunk[A]]
|
|
56
|
+
def readN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
57
|
+
def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
58
|
+
def isClosed: Async[Boolean]
|
|
59
|
+
def readable(): Async[Boolean]
|
|
60
|
+
def close(): Async[Unit]
|
|
61
|
+
// JVM only: def toSync: Reader.SyncReader[Elem]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The root contains only kind-independent composition and metadata (`++`, `concat`, `concatAsync`, `withReleaseAsync`, and `jvmType`); it cannot be pulled, queried, or closed directly. Those operations belong to one of the two reader kinds, described in [The reader union](#the-reader-union). Every primitive, bulk, lifecycle, and pushdown method on `AsyncReader` has the same parameters as its `SyncReader` counterpart but returns its result in `Async`; [Asynchronous reading](#asynchronous-reading) is the full member list. The eight physical primitive methods are `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`; a primitive `jvmType` is a contract that the corresponding method works, even when covariance has widened the reader's static element type.
|
|
66
|
+
|
|
67
|
+
An `AsyncReader` permits one active operation at a time, and closing it is the owner's responsibility. `SyncReader#toAsync` and the JVM-only `AsyncReader#toSync` move a reader between the two kinds without copying it.
|
|
68
|
+
|
|
69
|
+
## Quick Showcase
|
|
70
|
+
|
|
71
|
+
Here's how to create and drain a `Reader`:
|
|
72
|
+
|
|
73
|
+
```scala
|
|
74
|
+
import zio.blocks.streams.io.Reader
|
|
75
|
+
import zio.blocks.chunk.Chunk
|
|
76
|
+
import scala.collection.mutable.Buffer
|
|
77
|
+
|
|
78
|
+
val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
|
|
79
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@56d06418
|
|
80
|
+
val collected = Buffer[Int]()
|
|
81
|
+
// collected: Buffer[Int] = ArrayBuffer(1, 2, 3, 4, 5)
|
|
82
|
+
|
|
83
|
+
// Pull elements until sentinel
|
|
84
|
+
def drainAll(): Unit = {
|
|
85
|
+
val elem = r.read(-1)
|
|
86
|
+
if (elem != -1) {
|
|
87
|
+
collected += elem
|
|
88
|
+
drainAll()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
drainAll()
|
|
92
|
+
|
|
93
|
+
println(s"Collected: $collected")
|
|
94
|
+
// Collected: ArrayBuffer(1, 2, 3, 4, 5)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Motivation
|
|
98
|
+
|
|
99
|
+
Imagine you're processing a massive CSV file—millions of rows of customer data. Your first instinct is to load it all into memory as a `List[Row]`, transform it, filter it, and then write the results. This works fine for small files, but one day someone feeds you a 50GB dataset and your application crashes with `OutOfMemoryError`. You've hit the fundamental problem of eager evaluation: **you must load everything before you can do anything**, and if the data is bigger than available memory, you're stuck.
|
|
100
|
+
|
|
101
|
+
Even if you manage to fit the data in memory, you've paid the startup cost upfront. If your pipeline only needs the first 100 rows to produce a result, you've wasted time and energy materializing the other millions. And if something fails partway through—a database connection drops, a file is corrupted—you've already consumed resources and may have inconsistencies to clean up.
|
|
102
|
+
|
|
103
|
+
The streaming intuition is different: instead of pulling all data at once, what if the consumer asked the producer "give me the next element?" one at a time? This way, you never hold more than one element in memory, you only do work on elements you actually use, and you can stop immediately when you have enough.
|
|
104
|
+
|
|
105
|
+
`Reader` embodies this pull-based philosophy. Rather than materializing a `List`, a `Stream` compiles down to a `Reader`—a stateful object that produces one element each time you call `read()`. The consumer (a `Sink`) drives the pace: it calls `read()` when ready, and the `Reader` computes and returns the next value. When the stream is exhausted, `Reader` returns a sentinel—a special value you provide—signaling "no more data." No exceptions, no null, no boxing overhead.
|
|
106
|
+
|
|
107
|
+
`Reader` shines when you're processing large, unbounded, or expensive-to-produce data sources: database result sets, network streams, log files, sensor data, or any pipeline where memory or time efficiency matters. Instead of hoping your data fits in memory, you pay a constant, predictable cost per element.
|
|
108
|
+
|
|
109
|
+
## The Reader Union
|
|
110
|
+
|
|
111
|
+
`Reader[+Elem]` is the root of two kinds. It declares composition and one piece of metadata, and nothing that pulls, queries, or closes:
|
|
112
|
+
|
|
113
|
+
```scala
|
|
114
|
+
abstract class Reader[+Elem] {
|
|
115
|
+
def ++[Elem2 >: Elem](next: => Reader[Elem2]): Reader[Elem2]
|
|
116
|
+
def concat[Elem2 >: Elem](next: () => Reader[Elem2]): Reader[Elem2]
|
|
117
|
+
def concatAsync[Elem2 >: Elem](next: () => Async[Reader[Elem2]]): Reader.AsyncReader[Elem2]
|
|
118
|
+
def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
|
|
119
|
+
def jvmType: JvmType = JvmType.AnyRef
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A value typed `Reader[A]` can be concatenated and can report its physical lane, and that is all. To read from it you must know its kind:
|
|
124
|
+
|
|
125
|
+
- `Reader.SyncReader[Elem]` holds the direct pull API — `read`, `readAll`, `readN`, `readUpToN`, the eight primitive pulls, the array transfers, `skip`, the pushdown operations, `isClosed`, `readable()`, and `close()`, each returning its result immediately.
|
|
126
|
+
- `Reader.AsyncReader[Elem]` mirrors that surface method for method, with every result wrapped in `Async`.
|
|
127
|
+
|
|
128
|
+
The two kinds line up one to one, so a signature written against one translates mechanically to the other:
|
|
129
|
+
|
|
130
|
+
| Member | `SyncReader` result | `AsyncReader` result |
|
|
131
|
+
|--------------------------------------------|---------------------|----------------------|
|
|
132
|
+
| `read(sentinel)` | `A` | `Async[A]` |
|
|
133
|
+
| `readAll()` | `Chunk[A]` | `Async[Chunk[A]]` |
|
|
134
|
+
| `readN(n)`, `readUpToN(n)` | `Chunk[A]` | `Async[Chunk[A]]` |
|
|
135
|
+
| `readInt(_sentinel)` | `Long` | `Async[Long]` |
|
|
136
|
+
| `readBytes(dest, offset, length)` | `Int` | `Async[Int]` |
|
|
137
|
+
| `isClosed` | `Boolean` | `Async[Boolean]` |
|
|
138
|
+
| `readable()` | `Boolean` | `Async[Boolean]` |
|
|
139
|
+
| `close()` | `Unit` | `Async[Unit]` |
|
|
140
|
+
| `skip(n)` | `Unit` | `Async[Unit]` |
|
|
141
|
+
| `reset()` | `Unit` | `Async[Unit]` |
|
|
142
|
+
| `setLimit(n)`, `setRepeat()`, `setSkip(n)` | `Boolean` | `Async[Boolean]` |
|
|
143
|
+
|
|
144
|
+
Which kind a stream materializes as is decided once, when the graph compiles: a fully synchronous graph produces a `SyncReader`, and a graph with any asynchronous node produces an `AsyncReader`. See [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md#one-stream-type-two-execution-modes) for that decision.
|
|
145
|
+
|
|
146
|
+
One caveat about the root: `Reader` is declared `abstract class Reader[+Elem]`, not `sealed`. Every reader the library hands you is a `SyncReader` or an `AsyncReader`, and code may rely on that in practice — what it cannot rely on is the compiler proving a `match` over the two kinds exhaustive, so write such a match with a fallback case.
|
|
147
|
+
|
|
148
|
+
### Mixed-kind Composition
|
|
149
|
+
|
|
150
|
+
Concatenation keeps both kinds usable through the same `++` and `concat` names. `SyncReader` adds a pair of overloads that are narrowed to synchronous arguments and disambiguated from the inherited ones by a `DummyImplicit` parameter:
|
|
151
|
+
|
|
152
|
+
```scala
|
|
153
|
+
abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
|
|
154
|
+
final def ++[Elem2 >: Elem](next: => SyncReader[Elem2])(implicit dummy: DummyImplicit): SyncReader[Elem2]
|
|
155
|
+
final def concat[Elem2 >: Elem](next: () => SyncReader[Elem2])(implicit dummy: DummyImplicit): SyncReader[Elem2]
|
|
156
|
+
|
|
157
|
+
final override def ++[Elem2 >: Elem](next: => Reader[Elem2]): AsyncReader[Elem2]
|
|
158
|
+
final override def concat[Elem2 >: Elem](next: () => Reader[Elem2]): AsyncReader[Elem2]
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The rule that falls out is simple: **synchronous plus synchronous stays synchronous; every other combination widens to `AsyncReader`**. When the widening overload is chosen, the synchronous side is adapted with `toAsync` and the pair is concatenated on the asynchronous path.
|
|
163
|
+
|
|
164
|
+
```scala
|
|
165
|
+
import zio.blocks.streams.io.Reader
|
|
166
|
+
import zio.blocks.chunk.Chunk
|
|
167
|
+
|
|
168
|
+
val sync: Reader.SyncReader[Int] = Reader.fromChunk(Chunk(1, 2))
|
|
169
|
+
val async: Reader.AsyncReader[Int] = Reader.fromChunk(Chunk(3, 4)).toAsync
|
|
170
|
+
|
|
171
|
+
val syncSync: Reader.SyncReader[Int] = sync ++ Reader.fromChunk(Chunk(5, 6))
|
|
172
|
+
val syncAsync: Reader.AsyncReader[Int] = sync ++ async
|
|
173
|
+
val asyncSync: Reader.AsyncReader[Int] = async ++ Reader.fromChunk(Chunk(7, 8))
|
|
174
|
+
val asyncAsync: Reader.AsyncReader[Int] = async ++ async
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Because the overloads are selected on the *static* type of the argument, a value already widened to `Reader[Int]` picks the widening overload even when it happens to hold a `SyncReader` at runtime. Keep the narrow type if you want to stay on the synchronous path.
|
|
178
|
+
|
|
179
|
+
`concatAsync` and `withReleaseAsync` are declared on the root and always produce an `AsyncReader`, whichever kind they are called on — the first because the next reader arrives inside an `Async`, the second because the release action does:
|
|
180
|
+
|
|
181
|
+
```scala
|
|
182
|
+
abstract class Reader[+Elem] {
|
|
183
|
+
def concatAsync[Elem2 >: Elem](next: () => Async[Reader[Elem2]]): Reader.AsyncReader[Elem2]
|
|
184
|
+
def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Converting Between Kinds
|
|
189
|
+
|
|
190
|
+
Two adapters move a reader across the split:
|
|
191
|
+
|
|
192
|
+
```scala
|
|
193
|
+
abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
|
|
194
|
+
final def toAsync: Reader.AsyncReader[Elem]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
198
|
+
final def toSync: Reader.SyncReader[Elem] // JVM only
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`toAsync` is available on every platform. `toSync` is supplied by a JVM-only platform trait; on Scala.js that trait is empty, so the method does not exist and shared code cannot call it. [Platform Differences](../execution-and-compatibility/platform-differences.md#availability-matrix) has the full capability split.
|
|
203
|
+
|
|
204
|
+
Both adapters are lifecycle-preserving views rather than copies. The adapter wraps the original reader, so the two sides share one position and one lifecycle: closing either one closes the underlying source, and consuming through both interleaves pulls on the same cursor. Pick one view and drive the reader through it.
|
|
205
|
+
|
|
206
|
+
Both also unwrap a round trip instead of stacking. Calling `toAsync` on a reader that is itself the synchronous view of an `AsyncReader` returns that original asynchronous reader, and `toSync` on a synchronous reader's asynchronous view returns the original synchronous one. Converting back and forth therefore costs nothing and never builds a tower of adapters.
|
|
207
|
+
|
|
208
|
+
`toAsync` is the adapter to reach for when a helper is written against `Reader.AsyncReader` — the kind that compiles on both platforms — and the reader at the call site happens to be synchronous:
|
|
209
|
+
|
|
210
|
+
```scala
|
|
211
|
+
import zio.blocks.async._
|
|
212
|
+
import zio.blocks.streams.io.Reader
|
|
213
|
+
|
|
214
|
+
def firstByte(reader: Reader.AsyncReader[Byte]): Async[Int] = reader.readByte()
|
|
215
|
+
|
|
216
|
+
def firstByteOfSync(reader: Reader.SyncReader[Byte]): Async[Int] = firstByte(reader.toAsync)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
What it does not do is make blocking work non-blocking. Driving the view still runs the synchronous reader's pulls on the driving thread.
|
|
220
|
+
|
|
221
|
+
`toSync` runs the other way. Use it at a JVM edge — an `InputStream`-shaped API, a legacy protocol loop — and not in code that cross-builds:
|
|
222
|
+
|
|
223
|
+
```scala
|
|
224
|
+
import zio.blocks.streams.io.Reader
|
|
225
|
+
|
|
226
|
+
def firstByteBlocking(reader: Reader.AsyncReader[Byte]): Int = {
|
|
227
|
+
val sync = reader.toSync
|
|
228
|
+
try sync.readByte()
|
|
229
|
+
finally sync.close()
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Unlike `toAsync`, it has hazards that belong at the call site:
|
|
234
|
+
|
|
235
|
+
:::warning[`toSync` blocks, serializes, and interrupts]
|
|
236
|
+
Every pull blocks the calling thread until the asynchronous work settles. Only one pull runs at a time: a second thread entering the view waits until the first pull completes, so the view is a serialization point, not a way to share a reader. Calling `close()` from another thread interrupts the thread parked in a pull — that pull then returns its closed value (`-1`, the sentinel, or an empty chunk) instead of the value it was waiting for. Closing the view also closes the underlying asynchronous reader. After a close, the control operations — `reset()`, `setLimit`, `setRepeat`, `setSkip`, and `skip` — throw `IOException("Reader is closed")`.
|
|
237
|
+
:::
|
|
238
|
+
|
|
239
|
+
The rule to carry away is that `toSync` belongs at JVM edges, not in shared code.
|
|
240
|
+
|
|
241
|
+
## Construction
|
|
242
|
+
|
|
243
|
+
Several ways to create a `Reader`, from predefined singletons to collections and I/O sources:
|
|
244
|
+
|
|
245
|
+
Every companion constructor states its kind in its return type, so you never have to guess which engine a hand-built reader will drive. Factories such as `closed`, `fromChunk`, `fromIterable`, `fromRange`, `single`, `repeat`, and `unfold` are declared to return `SyncReader` — `def fromRange(range: Range): SyncReader[Int]`, and so on. `unfoldAsync` is the one native asynchronous constructor and returns an `AsyncReader`. `repeated` is overloaded three ways and preserves whether its input is synchronous or asynchronous. Composition also preserves asynchronous work: `concatAsync` lazily acquires the next reader and `withReleaseAsync` awaits asynchronous cleanup. Asynchronous children are supported throughout the reader graph.
|
|
246
|
+
|
|
247
|
+
### Creating Predefined Readers
|
|
248
|
+
|
|
249
|
+
`Reader.closed` — An already-closed reader that emits no elements. Useful as a base case or for empty streams:
|
|
250
|
+
|
|
251
|
+
```scala
|
|
252
|
+
object Reader {
|
|
253
|
+
def closed: Reader.SyncReader[Nothing]
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Here's how to create and use a closed reader:
|
|
258
|
+
|
|
259
|
+
```scala
|
|
260
|
+
import zio.blocks.streams.io.Reader
|
|
261
|
+
|
|
262
|
+
val r = Reader.closed
|
|
263
|
+
// r: SyncReader[Nothing] = zio.blocks.streams.io.Reader$ClosedReader$@4dad5488
|
|
264
|
+
println(r.isClosed) // true
|
|
265
|
+
// true
|
|
266
|
+
println(r.read(-1)) // -1 (the sentinel)
|
|
267
|
+
// -1
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### From Collections
|
|
271
|
+
|
|
272
|
+
`Reader.fromChunk` — Creates a reader backed by a [`Chunk`](../../chunk.md). Dispatches on the element type to use specialized, unboxed reads for primitives:
|
|
273
|
+
|
|
274
|
+
```scala
|
|
275
|
+
object Reader {
|
|
276
|
+
def fromChunk[A](chunk: Chunk[A])(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Create a reader from a chunk and drain its elements:
|
|
281
|
+
|
|
282
|
+
```scala
|
|
283
|
+
import zio.blocks.streams.io.Reader
|
|
284
|
+
import zio.blocks.chunk.Chunk
|
|
285
|
+
|
|
286
|
+
val chunk = Chunk(10, 20, 30)
|
|
287
|
+
// chunk: Chunk[Int] = IndexedSeq(10, 20, 30)
|
|
288
|
+
val r = Reader.fromChunk(chunk)
|
|
289
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@31de1289
|
|
290
|
+
|
|
291
|
+
def drain(): Unit = {
|
|
292
|
+
val v = r.read(-1)
|
|
293
|
+
if (v != -1) {
|
|
294
|
+
println(v)
|
|
295
|
+
drain()
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
drain()
|
|
299
|
+
// 10
|
|
300
|
+
// 20
|
|
301
|
+
// 30
|
|
302
|
+
// Output: 10, 20, 30
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`Reader.fromIterable` — Creates a reader from any `Iterable`. Works with lists, sets, vectors, and other collections:
|
|
306
|
+
|
|
307
|
+
```scala
|
|
308
|
+
object Reader {
|
|
309
|
+
def fromIterable[A](it: Iterable[A])(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Create a reader from a list and consume its elements:
|
|
314
|
+
|
|
315
|
+
```scala
|
|
316
|
+
import zio.blocks.streams.io.Reader
|
|
317
|
+
|
|
318
|
+
val list = List("a", "b", "c")
|
|
319
|
+
// list: List[String] = List("a", "b", "c")
|
|
320
|
+
val r = Reader.fromIterable(list)
|
|
321
|
+
// r: SyncReader[String] = zio.blocks.streams.io.Reader$FromIterable@555aefcc
|
|
322
|
+
|
|
323
|
+
def drain(): Unit = {
|
|
324
|
+
val v = r.read(null)
|
|
325
|
+
if (v != null) {
|
|
326
|
+
println(v)
|
|
327
|
+
drain()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
drain()
|
|
331
|
+
// a
|
|
332
|
+
// b
|
|
333
|
+
// c
|
|
334
|
+
// Output: a, b, c
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`Reader.fromRange` — Creates a reader from a Scala `Range`. Optimized for integer ranges without allocation:
|
|
338
|
+
|
|
339
|
+
```scala
|
|
340
|
+
object Reader {
|
|
341
|
+
def fromRange(range: Range): Reader.SyncReader[Int]
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Create a reader from a range and drain the integers:
|
|
346
|
+
|
|
347
|
+
```scala
|
|
348
|
+
import zio.blocks.streams.io.Reader
|
|
349
|
+
|
|
350
|
+
val r = Reader.fromRange(1 to 5)
|
|
351
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromRange@57d0e296
|
|
352
|
+
|
|
353
|
+
def drain(): Unit = {
|
|
354
|
+
val v = r.read(-1)
|
|
355
|
+
if (v != -1) {
|
|
356
|
+
println(v)
|
|
357
|
+
drain()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
drain()
|
|
361
|
+
// 1
|
|
362
|
+
// 2
|
|
363
|
+
// 3
|
|
364
|
+
// 4
|
|
365
|
+
// 5
|
|
366
|
+
// Output: 1, 2, 3, 4, 5
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### From I/O
|
|
370
|
+
|
|
371
|
+
`Reader.fromInputStream` — Wraps a `java.io.InputStream` as a `SyncReader[Byte]`. `readByte()` exposes the unsigned `0`–`255` view and reserves `-1` exclusively for EOF; ordinary element pulls retain `Byte` values:
|
|
372
|
+
|
|
373
|
+
```scala
|
|
374
|
+
object Reader {
|
|
375
|
+
def fromInputStream(is: InputStream): Reader.SyncReader[Byte]
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`Reader.fromReader` — Wraps a `java.io.Reader` as a `SyncReader[Char]` for character-based I/O:
|
|
380
|
+
|
|
381
|
+
```scala
|
|
382
|
+
object Reader {
|
|
383
|
+
def fromReader(r: java.io.Reader): Reader.SyncReader[Char]
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`NioReaders` is the `java.nio` counterpart, and it is synchronous throughout. It has no asynchronous twins, and the reason is in the type it wraps: `ReadableByteChannel#read` blocks the calling thread. No wrapper can make it non-blocking, so presenting its result as an `AsyncReader` would have promised something the channel cannot deliver. The two objects split by capability — `AsyncNioReaders` for channels that implement the JDK's asynchronous read protocol, `NioReaders` for the blocking ones and for `ByteBuffer`s.
|
|
388
|
+
|
|
389
|
+
Every `NioReaders` factory states its kind in its return type:
|
|
390
|
+
|
|
391
|
+
```scala
|
|
392
|
+
object NioReaders {
|
|
393
|
+
def fromByteBuffer(buf: ByteBuffer): Reader.SyncReader[Byte]
|
|
394
|
+
def fromByteBufferDouble(buf: ByteBuffer): Reader.SyncReader[Double]
|
|
395
|
+
def fromByteBufferFloat(buf: ByteBuffer): Reader.SyncReader[Float]
|
|
396
|
+
def fromByteBufferInt(buf: ByteBuffer): Reader.SyncReader[Int]
|
|
397
|
+
def fromByteBufferLong(buf: ByteBuffer): Reader.SyncReader[Long]
|
|
398
|
+
def fromChannel(ch: ReadableByteChannel, bufSize: Int = 8192): Reader.SyncReader[Byte]
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
These factories return `Reader.SyncReader[Byte]` rather than the root `Reader[Byte]`, which is what makes pulling and closing available on the result: those members belong to the reader kinds, not to the root type. Two details of this object are worth noting at a call site: its channel factory names the buffer parameter `bufSize`, where the asynchronous one names it `bufferSize`, and there is no public unmanaged channel variant here — `NioReaders.fromChannel` always owns the channel it wraps.
|
|
403
|
+
|
|
404
|
+
File bytes are reachable only through this synchronous side: a `java.nio.channels.FileChannel` is a `ReadableByteChannel`, so `NioReaders.fromChannel` accepts it. That reader blocks, and `SyncReader#toAsync` does not change it — the resulting asynchronous reader still blocks the thread that drives it.
|
|
405
|
+
|
|
406
|
+
### From Native Asynchronous Sources
|
|
407
|
+
|
|
408
|
+
Each platform ships a small set of factories that turn a native asynchronous byte source into a `Reader.AsyncReader[Byte]`. On the JVM that source is a `java.nio.channels.AsynchronousByteChannel`; on Scala.js it is a Web Streams API `ReadableStream`. Once wrapped, the result is an ordinary asynchronous reader: pull from it by hand, or hand it to `Stream.fromReader` and run the pipeline with a `*Async` terminal.
|
|
409
|
+
|
|
410
|
+
Six factories exist across the two platforms, all of them returning `Reader.AsyncReader[Byte]` on the `JvmType.Byte` lane. They differ in what they wrap and in who owns the native source once the reader closes:
|
|
411
|
+
|
|
412
|
+
| Factory | Platform | Native source | On reader `close()` |
|
|
413
|
+
|-----------------------------------------------------|----------|-----------------------------|--------------------------------------------|
|
|
414
|
+
| `AsyncNioReaders.fromChannel` | JVM | `AsynchronousByteChannel` | Closes the channel |
|
|
415
|
+
| `AsyncNioReaders.fromChannelUnmanaged` | JVM | `AsynchronousByteChannel` | Leaves the channel open |
|
|
416
|
+
| `AsyncNioReaders.fromSocket` | JVM | `AsynchronousSocketChannel` | Closes the socket |
|
|
417
|
+
| `AsyncNioReaders.fromSocketUnmanaged` | JVM | `AsynchronousSocketChannel` | Leaves the socket open |
|
|
418
|
+
| `ReadableStreamReaders.fromReadableStream` | Scala.js | `ReadableStream` | Cancels the stream, then releases the lock |
|
|
419
|
+
| `ReadableStreamReaders.fromReadableStreamUnmanaged` | Scala.js | `ReadableStream` | Releases the lock, never cancels |
|
|
420
|
+
|
|
421
|
+
The two adapters follow the same rules, so a cross-platform consumer sees the same behaviour from either one:
|
|
422
|
+
|
|
423
|
+
| Situation | JVM `AsyncNioReaders` | Scala.js `ReadableStreamReaders` |
|
|
424
|
+
|-----------------------------|------------------------------------------------|--------------------------------------------|
|
|
425
|
+
| A delivery carries no bytes | The buffer is cleared and the read resubmitted | The chunk is skipped and `read()` reissued |
|
|
426
|
+
| End of stream | A negative completion count | `done = true` on the read result |
|
|
427
|
+
| A source failure | `IOException`, trusted | A rejected promise, trusted |
|
|
428
|
+
| Read buffer size | `bufferSize`, default 8192 | Not configurable |
|
|
429
|
+
|
|
430
|
+
Neither module is a general native-I/O layer. `AsyncNioReaders` reads from channels that implement the JDK's asynchronous read protocol, and `ReadableStreamReaders` reads from a browser or Node byte stream. Everything else — files on the JVM, non-byte sources on Scala.js — is outside what these factories accept.
|
|
431
|
+
|
|
432
|
+
#### JVM: `AsyncNioReaders`
|
|
433
|
+
|
|
434
|
+
`AsyncNioReaders` is the JVM factory object for genuinely non-blocking reads. Its four factories divide along two axes: the type of the native source, and whether the reader owns it.
|
|
435
|
+
|
|
436
|
+
Both channel factories wrap an `AsynchronousByteChannel` and take the read buffer size as a defaulted second parameter:
|
|
437
|
+
|
|
438
|
+
```scala
|
|
439
|
+
object AsyncNioReaders {
|
|
440
|
+
def fromChannel(channel: AsynchronousByteChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
|
|
441
|
+
def fromChannelUnmanaged(channel: AsynchronousByteChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`bufferSize` is the capacity of the single `ByteBuffer` the reader refills from the channel, and it is validated eagerly: a value of zero or less throws `IllegalArgumentException` with the message `requirement failed: bufferSize must be positive` from the factory call itself, not from the first pull.
|
|
446
|
+
|
|
447
|
+
To lift a channel into a stream and run it with a cross-platform terminal:
|
|
448
|
+
|
|
449
|
+
```scala
|
|
450
|
+
import zio.blocks.async._
|
|
451
|
+
import zio.blocks.chunk.Chunk
|
|
452
|
+
import zio.blocks.streams._
|
|
453
|
+
|
|
454
|
+
import java.nio.channels.AsynchronousByteChannel
|
|
455
|
+
|
|
456
|
+
def collect(channel: AsynchronousByteChannel): Async[Either[Nothing, Chunk[Byte]]] =
|
|
457
|
+
Stream.fromReader[Nothing, Byte](AsyncNioReaders.fromChannel(channel, bufferSize = 4096)).runCollectAsync
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Driving the reader by hand works the same way, and is what you want when the protocol is framed rather than streamed:
|
|
461
|
+
|
|
462
|
+
```scala
|
|
463
|
+
import zio.blocks.async._
|
|
464
|
+
import zio.blocks.chunk.Chunk
|
|
465
|
+
import zio.blocks.streams.AsyncNioReaders
|
|
466
|
+
import zio.blocks.streams.io.Reader
|
|
467
|
+
|
|
468
|
+
import java.nio.channels.AsynchronousByteChannel
|
|
469
|
+
|
|
470
|
+
def header(channel: AsynchronousByteChannel): Async[Chunk[Byte]] = {
|
|
471
|
+
val reader: Reader.AsyncReader[Byte] = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 512)
|
|
472
|
+
reader.readN[Byte](16).flatMap(bytes => reader.close().map(_ => bytes))
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
The socket pair narrows the parameter type to `AsynchronousSocketChannel`, which is the `AsynchronousByteChannel` most callers actually hold:
|
|
477
|
+
|
|
478
|
+
```scala
|
|
479
|
+
object AsyncNioReaders {
|
|
480
|
+
def fromSocket(socket: AsynchronousSocketChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
|
|
481
|
+
def fromSocketUnmanaged(socket: AsynchronousSocketChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
There is no behavioural difference to learn: `AsyncNioReaders.fromSocket` delegates to `AsyncNioReaders.fromChannel` and `AsyncNioReaders.fromSocketUnmanaged` to `AsyncNioReaders.fromChannelUnmanaged`, with the same buffer and the same ownership rule. They exist so a socket-shaped call site reads as one.
|
|
486
|
+
|
|
487
|
+
#### Managed Versus Unmanaged Ownership
|
|
488
|
+
|
|
489
|
+
Ownership is the whole of the difference between the two variants, and it is decided when you pick the factory, not later.
|
|
490
|
+
|
|
491
|
+
A managed reader — `AsyncNioReaders.fromChannel` or `AsyncNioReaders.fromSocket` — closes the underlying channel when the reader closes, and only if the channel is still open. If that channel close fails, the failure surfaces from the reader's own `close()` rather than being swallowed. An unmanaged reader releases the reader and nothing else: the channel stays open for whoever owns it, and a reader close is invisible to the rest of the program apart from the read it cancels.
|
|
492
|
+
|
|
493
|
+
Closing either kind cancels a read that is still in flight. The reader marks itself closed, cancels the underlying channel operation, and settles the pending pull with its end-of-stream answer — `readByte()` returns `-1`, `read(sentinel)` returns the sentinel — so a consumer parked on a pull is released rather than left waiting for a channel that will never answer.
|
|
494
|
+
|
|
495
|
+
`close()` is idempotent. The first caller performs the work; every later caller awaits the same memoized outcome, and the channel is closed at most once.
|
|
496
|
+
|
|
497
|
+
All three facts are observable rather than asserted. The example below is a runnable file in the JVM-only `streams-examples` module of the [zio-blocks repository](https://github.com/zio/zio-blocks). It drives a scripted `AsynchronousByteChannel` — one that counts its own `close()` calls and parks a read it cannot serve — through both ownership modes, so the managed close, the untouched unmanaged channel, the memoized second close, and the cancelled pending read are all printed:
|
|
498
|
+
|
|
499
|
+
```scala title="streams-examples/src/main/scala/nio/AsyncChannelReaderExample.scala"
|
|
500
|
+
package nio
|
|
501
|
+
|
|
502
|
+
import zio.blocks.async._
|
|
503
|
+
import zio.blocks.chunk.Chunk
|
|
504
|
+
import zio.blocks.streams.AsyncNioReaders
|
|
505
|
+
|
|
506
|
+
import java.nio.ByteBuffer
|
|
507
|
+
import java.nio.channels.{AsynchronousByteChannel, AsynchronousCloseException, CompletionHandler}
|
|
508
|
+
import java.nio.charset.StandardCharsets.UTF_8
|
|
509
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
510
|
+
import java.util.concurrent.{CompletableFuture, CountDownLatch, Future}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Managed and unmanaged readers over an `AsynchronousByteChannel` (JVM only).
|
|
514
|
+
*
|
|
515
|
+
* `AsyncNioReaders.fromChannel` takes ownership of the channel and closes it
|
|
516
|
+
* when the reader closes. `AsyncNioReaders.fromChannelUnmanaged` releases the
|
|
517
|
+
* reader and leaves the channel alive for its owner. Both memoize `close()`,
|
|
518
|
+
* and both settle a read that is still in flight when the reader closes.
|
|
519
|
+
*
|
|
520
|
+
* The channel below is scripted rather than networked: it counts its own
|
|
521
|
+
* `close()` calls and parks a read that has nothing left to deliver, so
|
|
522
|
+
* ownership, idempotent close, and the cancelled pending read are all
|
|
523
|
+
* observable without a socket.
|
|
524
|
+
*/
|
|
525
|
+
object AsyncChannelReaderExample {
|
|
526
|
+
def main(args: Array[String]): Unit = {
|
|
527
|
+
managedClosesTheChannel()
|
|
528
|
+
unmanagedLeavesTheChannelOpen()
|
|
529
|
+
closeCancelsAPendingRead()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Managed ownership: the reader closes the channel, and only once. */
|
|
533
|
+
private def managedClosesTheChannel(): Unit = {
|
|
534
|
+
val channel = new ScriptedChannel("async".getBytes(UTF_8))
|
|
535
|
+
val reader = AsyncNioReaders.fromChannel(channel, bufferSize = 16)
|
|
536
|
+
|
|
537
|
+
val bytes: Chunk[Byte] = reader.readN[Byte](5).block
|
|
538
|
+
reader.close().block
|
|
539
|
+
reader.close().block // memoized: the channel is not closed a second time
|
|
540
|
+
|
|
541
|
+
println(
|
|
542
|
+
s"managed -> read=${text(bytes)}, channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Unmanaged ownership: the channel outlives the reader untouched. */
|
|
547
|
+
private def unmanagedLeavesTheChannelOpen(): Unit = {
|
|
548
|
+
val channel = new ScriptedChannel("async".getBytes(UTF_8))
|
|
549
|
+
val reader = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 16)
|
|
550
|
+
|
|
551
|
+
val bytes: Chunk[Byte] = reader.readN[Byte](5).block
|
|
552
|
+
reader.close().block
|
|
553
|
+
reader.close().block
|
|
554
|
+
|
|
555
|
+
println(
|
|
556
|
+
s"unmanaged -> read=${text(bytes)}, channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Closing an unmanaged reader cancels the read the channel is still holding
|
|
562
|
+
* and hands the consumer the end-of-stream answer instead.
|
|
563
|
+
*/
|
|
564
|
+
private def closeCancelsAPendingRead(): Unit = {
|
|
565
|
+
val channel = new ScriptedChannel("hi".getBytes(UTF_8))
|
|
566
|
+
val reader = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 16)
|
|
567
|
+
|
|
568
|
+
val delivered: Chunk[Byte] = reader.readN[Byte](2).block
|
|
569
|
+
val pending = reader.readByte().start
|
|
570
|
+
|
|
571
|
+
// Wait until the channel is genuinely holding a read, so the close races an
|
|
572
|
+
// in-flight operation rather than an unstarted one.
|
|
573
|
+
channel.readParked.await()
|
|
574
|
+
reader.close().block
|
|
575
|
+
|
|
576
|
+
println(
|
|
577
|
+
s"pending -> read=${text(delivered)}, cancelledRead=${pending.block}, " +
|
|
578
|
+
s"channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private def text(bytes: Chunk[Byte]): String = new String(bytes.toArray, UTF_8)
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A channel that delivers `payload` once and then parks every further read
|
|
586
|
+
* until it is closed, the way a live socket waits between packets.
|
|
587
|
+
*/
|
|
588
|
+
private final class ScriptedChannel(payload: Array[Byte]) extends AsynchronousByteChannel {
|
|
589
|
+
val closeCount: AtomicInteger = new AtomicInteger
|
|
590
|
+
val readParked: CountDownLatch = new CountDownLatch(1)
|
|
591
|
+
|
|
592
|
+
private val lock = new AnyRef
|
|
593
|
+
private var position = 0
|
|
594
|
+
private var open = true
|
|
595
|
+
private var parked: Throwable => Unit = null
|
|
596
|
+
|
|
597
|
+
def read[A](dst: ByteBuffer, attachment: A, handler: CompletionHandler[Integer, ? >: A]): Unit = {
|
|
598
|
+
val served = lock.synchronized {
|
|
599
|
+
val outcome = serve(dst)
|
|
600
|
+
if (outcome.isEmpty) parked = cause => handler.failed(cause, attachment)
|
|
601
|
+
outcome
|
|
602
|
+
}
|
|
603
|
+
served match {
|
|
604
|
+
case Some(count) => handler.completed(count, attachment)
|
|
605
|
+
case None => readParked.countDown()
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
def read(dst: ByteBuffer): Future[Integer] = {
|
|
610
|
+
val future = new CompletableFuture[Integer]
|
|
611
|
+
val served = lock.synchronized {
|
|
612
|
+
val outcome = serve(dst)
|
|
613
|
+
if (outcome.isEmpty) parked = cause => { future.completeExceptionally(cause); () }
|
|
614
|
+
outcome
|
|
615
|
+
}
|
|
616
|
+
served match {
|
|
617
|
+
case Some(count) => future.complete(count)
|
|
618
|
+
case None => readParked.countDown()
|
|
619
|
+
}
|
|
620
|
+
future
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
def write[A](src: ByteBuffer, attachment: A, handler: CompletionHandler[Integer, ? >: A]): Unit =
|
|
624
|
+
handler.failed(new UnsupportedOperationException("scripted channel is read-only"), attachment)
|
|
625
|
+
|
|
626
|
+
def write(src: ByteBuffer): Future[Integer] = {
|
|
627
|
+
val future = new CompletableFuture[Integer]
|
|
628
|
+
future.completeExceptionally(new UnsupportedOperationException("scripted channel is read-only"))
|
|
629
|
+
future
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
def isOpen: Boolean = lock.synchronized(open)
|
|
633
|
+
|
|
634
|
+
def close(): Unit = {
|
|
635
|
+
val release = lock.synchronized {
|
|
636
|
+
closeCount.incrementAndGet()
|
|
637
|
+
open = false
|
|
638
|
+
val current = parked
|
|
639
|
+
parked = null
|
|
640
|
+
current
|
|
641
|
+
}
|
|
642
|
+
if (release ne null) release(new AsynchronousCloseException)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private def serve(dst: ByteBuffer): Option[Int] = {
|
|
646
|
+
val remaining = payload.length - position
|
|
647
|
+
if (remaining <= 0) None
|
|
648
|
+
else {
|
|
649
|
+
val count = math.min(remaining, dst.remaining)
|
|
650
|
+
dst.put(payload, position, count)
|
|
651
|
+
position += count
|
|
652
|
+
Some(count)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
([source](https://github.com/zio/zio-blocks/blob/main/streams-examples/src/main/scala/nio/AsyncChannelReaderExample.scala))
|
|
660
|
+
|
|
661
|
+
Run it with:
|
|
662
|
+
|
|
663
|
+
```bash
|
|
664
|
+
sbt "streams-examples/runMain nio.AsyncChannelReaderExample"
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
It prints:
|
|
668
|
+
|
|
669
|
+
```
|
|
670
|
+
managed -> read=async, channelOpen=false, closes=1
|
|
671
|
+
unmanaged -> read=async, channelOpen=true, closes=0
|
|
672
|
+
pending -> read=hi, cancelledRead=-1, channelOpen=true, closes=0
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
#### JVM Invariants
|
|
676
|
+
|
|
677
|
+
Four properties hold for every reader the `AsyncNioReaders` factories produce, and each one is a rule a hand-written channel wrapper commonly gets wrong.
|
|
678
|
+
|
|
679
|
+
1. **A zero-byte completion is not end of stream.** When the channel completes a read having transferred nothing, the adapter clears its buffer and resubmits the read; only a negative completion count ends the stream. A channel that yields `0` under backpressure therefore stalls the pull, it does not truncate the stream.
|
|
680
|
+
2. **`IOException`s are trusted source failures.** A failure reported by the channel is wrapped as a source failure and surfaces in the typed error channel of the stream built from the reader, not as a defect, and every later pull replays it rather than pretending the source recovered.
|
|
681
|
+
3. **Pulls are inert until driven.** Every read method returns a deferred `Async`; building `reader.readByte()` submits nothing to the channel, and the read is issued when the effect is driven. `readable()` follows the same rule — it reports whether bytes are already buffered and never initiates I/O to find out.
|
|
682
|
+
4. **One operation may be in flight at a time.** A second pull started while another is active fails with `IllegalStateException`; [One Active Operation at a Time](#one-active-operation-at-a-time) covers the rule and the way to sequence pulls instead.
|
|
683
|
+
|
|
684
|
+
#### JVM Limitations
|
|
685
|
+
|
|
686
|
+
The asynchronous NIO surface is exactly the four factories above, and file input is not among them.
|
|
687
|
+
|
|
688
|
+
:::warning[`AsynchronousFileChannel` is not supported]
|
|
689
|
+
No factory accepts an `AsynchronousFileChannel`, and it is not an `AsynchronousByteChannel`, so it cannot be passed to `AsyncNioReaders.fromChannel` either. There is no asynchronous file reader in this module.
|
|
690
|
+
:::
|
|
691
|
+
|
|
692
|
+
File bytes go through the synchronous `NioReaders.fromChannel` instead, as [From I/O](#from-io) describes.
|
|
693
|
+
|
|
694
|
+
#### Scala.js: `ReadableStreamReaders`
|
|
695
|
+
|
|
696
|
+
`ReadableStreamReaders` is the Scala.js counterpart, wrapping the byte-reading side of the Web Streams API. The module declares minimal `@js.native` facades for `ReadableStream`, its reader, and a read result, so using it does not pull a DOM library into your build.
|
|
697
|
+
|
|
698
|
+
Two factories mirror the managed and unmanaged pair on the JVM:
|
|
699
|
+
|
|
700
|
+
```scala
|
|
701
|
+
object ReadableStreamReaders {
|
|
702
|
+
def fromReadableStream(stream: ReadableStream): Reader.AsyncReader[Byte]
|
|
703
|
+
def fromReadableStreamUnmanaged(stream: ReadableStream): Reader.AsyncReader[Byte]
|
|
704
|
+
}
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Both take the *stream*, not a reader. Each factory calls `stream.getReader()` itself and keeps the acquired reader for its own lifetime, which is what makes the lock release on close well defined. Acquiring a reader yourself and passing it in is not part of the API.
|
|
708
|
+
|
|
709
|
+
The managed factory owns the acquired reader: closing it cancels the JavaScript stream, awaits the read that was in flight, and then releases the lock. The unmanaged factory releases the lock and never cancels, so the underlying stream remains usable by the code that created it. As on the JVM, either kind settles a pending pull with end of stream instead of leaving it outstanding, and drops whatever it had buffered.
|
|
710
|
+
|
|
711
|
+
#### Scala.js Invariants
|
|
712
|
+
|
|
713
|
+
The JVM adapter's rules about deferral and exclusivity hold here too — pulls are inert until driven, and one operation may be in flight at a time. The four properties below restate the adapter's end-of-stream, buffering and failure behaviour in the terms of the Web Streams `read()` promise and its `done`/`value` result, which is where this adapter is easiest to get wrong.
|
|
714
|
+
|
|
715
|
+
1. **An empty chunk is skipped, never treated as end of stream.** A result with `done = false` and a zero-length value causes the adapter to reissue `read()`; only `done = true` ends the stream.
|
|
716
|
+
2. **Buffered bytes are preserved across pulls.** A chunk delivered by the stream is consumed byte by byte from the adapter's own index, so a pull that the buffer can satisfy issues no `read()` at all, and a partially consumed chunk survives until it is drained.
|
|
717
|
+
3. **End of stream is observed without prefetch.** The adapter never calls `read()` merely to discover whether the stream has finished; it learns that from the read that a pull actually needed.
|
|
718
|
+
4. **A rejected promise is a trusted source failure.** The rejection is wrapped as a source failure, surfaces in the typed error channel, and is replayed by every later pull.
|
|
719
|
+
|
|
720
|
+
#### Scala.js Limitations
|
|
721
|
+
|
|
722
|
+
Two of the JVM adapter's affordances have no Scala.js equivalent, and one of them cannot be worked around from user code.
|
|
723
|
+
|
|
724
|
+
:::warning[Byte-only, no BYOB, no buffer size]
|
|
725
|
+
Both factories produce `Reader.AsyncReader[Byte]` on the `JvmType.Byte` lane and read `Uint8Array` chunks; there is no factory for another element type. Neither factory offers BYOB support — `getReader()` is called with no arguments, so the adapter never acquires a bring-your-own-buffer reader and cannot read into a caller-supplied `ArrayBuffer`. Neither takes a buffer-size parameter: chunk sizes are whatever the underlying stream produces.
|
|
726
|
+
:::
|
|
727
|
+
|
|
728
|
+
### Single Element
|
|
729
|
+
|
|
730
|
+
`Reader.single` — Creates a reader that emits exactly one element, then closes. Primitive types use specialized variants for zero-boxing:
|
|
731
|
+
|
|
732
|
+
```scala
|
|
733
|
+
object Reader {
|
|
734
|
+
def single[A](value: A)(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
|
|
735
|
+
def singleInt(value: Int): Reader.SyncReader[Int]
|
|
736
|
+
def singleLong(value: Long): Reader.SyncReader[Long]
|
|
737
|
+
def singleFloat(value: Float): Reader.SyncReader[Float]
|
|
738
|
+
def singleDouble(value: Double): Reader.SyncReader[Double]
|
|
739
|
+
def singleChar(value: Char): Reader.SyncReader[Char]
|
|
740
|
+
def singleShort(value: Short): Reader.SyncReader[Short]
|
|
741
|
+
def singleByte(value: Byte): Reader.SyncReader[Byte]
|
|
742
|
+
def singleBoolean(value: Boolean): Reader.SyncReader[Boolean]
|
|
743
|
+
}
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
When you use `Reader.single`, behavior differs between reference types and primitives. The `JvmType.Infer[A]` implicit parameter enables compile-time type detection, automatically selecting the appropriate implementation (specialized primitive or reference-type generic).
|
|
747
|
+
|
|
748
|
+
For reference types like String, `Reader.single("hello")` stores the element directly and tracks a taken/not-taken flag internally. You read via the generic `SyncReader#read[A](sentinel)` method, passing your own sentinel value. On the first call, you get your string; on subsequent calls, you receive the sentinel you provided, allowing you to detect stream closure.
|
|
749
|
+
|
|
750
|
+
For primitive types, `Reader.single(42)` could naively box the integer, but the library avoids this penalty entirely via `SingletonPrim`—a zero-boxing specialization that stores the primitive unboxed in memory. The `JvmType.Infer` implicit detects this at compile time and routes you through specialized factory methods (`Reader.singleInt`, `Reader.singleLong`, etc.) and specialized read methods (`Reader#readInt`, `Reader#readLong`, etc.). Both storage and retrieval stay unboxed, maintaining zero-copy efficiency.
|
|
751
|
+
|
|
752
|
+
`Reader.singleByte` returns `SyncReader[Byte]` and reports `JvmType.Byte`. Its physical scalar pull is `readByte(): Int`, which returns the unsigned byte value `0`–`255` or `-1` at EOF.
|
|
753
|
+
|
|
754
|
+
Create and read from a single-element reference-type reader with a custom sentinel:
|
|
755
|
+
|
|
756
|
+
```scala
|
|
757
|
+
import zio.blocks.streams.io.Reader
|
|
758
|
+
|
|
759
|
+
val r = Reader.single("hello")
|
|
760
|
+
// r: SyncReader[String] = zio.blocks.streams.io.Reader$SingletonGeneric@2d6a8112
|
|
761
|
+
val sentinel = "END"
|
|
762
|
+
// sentinel: String = "END"
|
|
763
|
+
println(r.read(sentinel)) // hello
|
|
764
|
+
// hello
|
|
765
|
+
println(r.read(sentinel)) // END (sentinel, reader is closed)
|
|
766
|
+
// END
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
For primitive types, use the specialized factory and read methods. `SyncReader#readInt` takes a `Long` sentinel and returns `Long`; `AsyncReader#readInt` takes the same sentinel and returns `Async[Long]`:
|
|
770
|
+
|
|
771
|
+
```scala
|
|
772
|
+
import zio.blocks.streams.io.Reader
|
|
773
|
+
|
|
774
|
+
val r = Reader.singleInt(100)
|
|
775
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@7e37e60c
|
|
776
|
+
val sentinel = Long.MinValue
|
|
777
|
+
// sentinel: Long = -9223372036854775808L
|
|
778
|
+
val v1 = r.readInt(sentinel)
|
|
779
|
+
// v1: Long = 100L
|
|
780
|
+
println(v1) // 100
|
|
781
|
+
// 100
|
|
782
|
+
val v2 = r.readInt(sentinel)
|
|
783
|
+
// v2: Long = -9223372036854775808L
|
|
784
|
+
println(v2) // -9223372036854775808 (sentinel, reader is closed)
|
|
785
|
+
// -9223372036854775808
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### Infinite & Repeating
|
|
789
|
+
|
|
790
|
+
`Reader.repeat` — Creates an infinite reader that always emits the same value:
|
|
791
|
+
|
|
792
|
+
```scala
|
|
793
|
+
object Reader {
|
|
794
|
+
def repeat[A](a: A)(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Create an infinite reader that repeatedly emits the same value:
|
|
799
|
+
|
|
800
|
+
```scala
|
|
801
|
+
import zio.blocks.streams.io.Reader
|
|
802
|
+
|
|
803
|
+
val r = Reader.repeat(1)
|
|
804
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@60b1fa62
|
|
805
|
+
|
|
806
|
+
def drainN(n: Int): Unit = {
|
|
807
|
+
if (n > 0) {
|
|
808
|
+
val v = r.read(-1)
|
|
809
|
+
println(v)
|
|
810
|
+
drainN(n - 1)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
drainN(3)
|
|
814
|
+
// 1
|
|
815
|
+
// 1
|
|
816
|
+
// 1
|
|
817
|
+
// Output: 1, 1, 1
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
`Reader.repeated` — Restarts an inner reader each time it closes cleanly. Used by `Stream.repeated` to create indefinitely repeating streams:
|
|
821
|
+
|
|
822
|
+
```scala
|
|
823
|
+
object Reader {
|
|
824
|
+
def repeated[A](inner: SyncReader[A]): SyncReader[A]
|
|
825
|
+
def repeated[A](inner: AsyncReader[A]): AsyncReader[A]
|
|
826
|
+
def repeated[A](inner: Reader[A]): Reader[A]
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
### Unfold (State Machine)
|
|
831
|
+
|
|
832
|
+
`Reader.unfold` — Creates a reader by unfolding state with a function. Returns `None` to signal completion, or `Some((elem, nextState))` to emit an element and advance state:
|
|
833
|
+
|
|
834
|
+
```scala
|
|
835
|
+
object Reader {
|
|
836
|
+
def unfold[S, A](s: S)(f: S => Option[(A, S)])(implicit jt: JvmType.Infer[A]): SyncReader[A]
|
|
837
|
+
}
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
Create a reader that unfolds state incrementally until completion:
|
|
841
|
+
|
|
842
|
+
```scala
|
|
843
|
+
import zio.blocks.streams.io.Reader
|
|
844
|
+
|
|
845
|
+
val r = Reader.unfold(1) { s =>
|
|
846
|
+
if (s > 3) None else Some((s, s + 1))
|
|
847
|
+
}
|
|
848
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$Unfold@57b73038
|
|
849
|
+
|
|
850
|
+
def drain(): Unit = {
|
|
851
|
+
val v = r.read(-1)
|
|
852
|
+
if (v != -1) {
|
|
853
|
+
println(v)
|
|
854
|
+
drain()
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
drain()
|
|
858
|
+
// 1
|
|
859
|
+
// 2
|
|
860
|
+
// 3
|
|
861
|
+
// Output: 1, 2, 3
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### `Reader.unfoldAsync`
|
|
865
|
+
|
|
866
|
+
`Reader.unfoldAsync` is the only native asynchronous constructor in the companion. It has the same shape as `unfold`, with the step function returning its `Option` inside an `Async`, and it produces an `AsyncReader`:
|
|
867
|
+
|
|
868
|
+
```scala
|
|
869
|
+
object Reader {
|
|
870
|
+
def unfoldAsync[S, A](s: S)(f: S => Async[Option[(A, S)]])(implicit jt: JvmType.Infer[A]): AsyncReader[A]
|
|
871
|
+
}
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Use it when producing the next element is itself asynchronous — a network round trip, a callback-based API, a timer. Everything downstream of it compiles on the asynchronous path.
|
|
875
|
+
|
|
876
|
+
```scala
|
|
877
|
+
import zio.blocks.streams.io.Reader
|
|
878
|
+
import zio.blocks.chunk.Chunk
|
|
879
|
+
import zio.blocks.async._
|
|
880
|
+
|
|
881
|
+
val ticks: Reader.AsyncReader[Int] =
|
|
882
|
+
Reader.unfoldAsync(1) { s =>
|
|
883
|
+
Async.succeed(if (s > 3) None else Some((s, s + 1)))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
val drained: Async[Chunk[Int]] = ticks.readAll()
|
|
887
|
+
val closed: Async[Unit] = ticks.close()
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
The state callback is lazy and generation-aware: exactly one callback may be in flight, and the next state is committed only when that callback succeeds while its reader generation is still current. A callback that completes after a `reset` or a `close` therefore cannot advance state that no longer exists.
|
|
891
|
+
|
|
892
|
+
## Core Operations
|
|
893
|
+
|
|
894
|
+
These methods form the primary interface for consuming elements and querying reader state:
|
|
895
|
+
|
|
896
|
+
### Pulling Elements
|
|
897
|
+
|
|
898
|
+
`read` pulls the next element, or produces `sentinel` if the reader is closed and empty. This is the fundamental operation. The synchronous and asynchronous signatures are distinct:
|
|
899
|
+
|
|
900
|
+
```scala
|
|
901
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
902
|
+
def read[A >: Elem](sentinel: A): A
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
906
|
+
def read[A >: Elem](sentinel: A): Async[A]
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
The sentinel value is caller-chosen and should never appear as a real element. For reference types, `null` is convenient. For primitives, use a value outside the domain (e.g., `-1` for unsigned bytes, `Long.MinValue` for `Int`):
|
|
911
|
+
|
|
912
|
+
```scala
|
|
913
|
+
import zio.blocks.streams.io.Reader
|
|
914
|
+
import zio.blocks.chunk.Chunk
|
|
915
|
+
|
|
916
|
+
val r = Reader.fromChunk(Chunk(10, 20))
|
|
917
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@39ee3e03
|
|
918
|
+
val v1 = r.read(-1) // 10
|
|
919
|
+
// v1: Int = 10
|
|
920
|
+
val v2 = r.read(-1) // 20
|
|
921
|
+
// v2: Int = 20
|
|
922
|
+
val v3 = r.read(-1) // -1 (sentinel, reader is closed)
|
|
923
|
+
// v3: Int = -1
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Primitive Specialization
|
|
927
|
+
|
|
928
|
+
For primitive types, specialized methods avoid boxing by widening the return type.
|
|
929
|
+
|
|
930
|
+
`Reader#readInt` — Sentinel-return `Int` pull. Returns the element widened to `Long`, or `sentinel` when closed. The sentinel must lie outside `[Int.MinValue, Int.MaxValue]` (typically `Long.MinValue`):
|
|
931
|
+
|
|
932
|
+
```scala
|
|
933
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
934
|
+
def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Long
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
938
|
+
def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Async[Long]
|
|
939
|
+
}
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Why widen to `Long`? If `Reader#readInt` returned `Int`, you couldn't distinguish a real element from the sentinel—both would fit in the int range. By widening to `Long`, the sentinel (e.g., `Long.MinValue`) lies outside the possible int domain, allowing reliable end-of-stream detection. Cast the result back to `Int` if needed: `r.readInt(Long.MinValue).toInt`.
|
|
943
|
+
|
|
944
|
+
`Reader#readLong` — Sentinel-return `Long` pull. This low-level scalar method cannot distinguish EOF from a real element equal to the caller's sentinel:
|
|
945
|
+
|
|
946
|
+
```scala
|
|
947
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
948
|
+
def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Long
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
952
|
+
def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Async[Long]
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
The scalar API necessarily permits a collision with the caller's sentinel. Collision-free internal pulls preserve the complete `Long` domain by calling `readLongs` with a length-one array and using its returned count (`-1` for EOF, `1` for data) as status. Custom full-domain loops should use the same pattern.
|
|
957
|
+
|
|
958
|
+
`Reader#readFloat` — Sentinel-return `Float` pull. Returns the element widened to `Double`, or `sentinel` when closed:
|
|
959
|
+
|
|
960
|
+
```scala
|
|
961
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
962
|
+
def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Double
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
966
|
+
def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Async[Double]
|
|
967
|
+
}
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
Like `Reader#readInt`, widening to `Double` allows the sentinel to lie safely outside the float domain. A float value will always fit in the lower precision bits of the double result, and the sentinel (typically `Double.MaxValue`) occupies the upper range. This ensures you can reliably distinguish real float elements from end-of-stream. Cast back to `Float` if needed: `r.readFloat(Double.MaxValue).toFloat`.
|
|
971
|
+
|
|
972
|
+
`Reader#readDouble` — Sentinel-return `Double` pull. Returns the element, or `sentinel` when closed. No `Double` bit pattern can be reserved as a collision-free sentinel, so the parameter is kept for symmetry with the other sentinel-taking pulls and for callers who can prove that a particular value lies outside their own data domain:
|
|
973
|
+
|
|
974
|
+
```scala
|
|
975
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
976
|
+
def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Double
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
980
|
+
def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Async[Double]
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Like scalar `readLong`, scalar `readDouble` cannot reserve a collision-free value (and NaN comparisons add another trap). Collision-free internal pulls call `readDoubles` with a length-one array and use its returned count as EOF/data status, preserving infinities, every NaN payload, and either zero. Custom full-domain loops should use the same pattern.
|
|
985
|
+
|
|
986
|
+
These specialized methods are the hot path for primitive streams — they avoid allocation and boxing entirely:
|
|
987
|
+
|
|
988
|
+
```scala
|
|
989
|
+
import zio.blocks.streams.io.Reader
|
|
990
|
+
import zio.blocks.chunk.Chunk
|
|
991
|
+
|
|
992
|
+
val r = Reader.fromChunk(Chunk(10, 20, 30))
|
|
993
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@137fc020
|
|
994
|
+
val sentinel = Long.MinValue
|
|
995
|
+
// sentinel: Long = -9223372036854775808L
|
|
996
|
+
|
|
997
|
+
val v = r.readInt(sentinel)
|
|
998
|
+
// v: Long = 10L
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### Byte-Level Reading
|
|
1002
|
+
|
|
1003
|
+
`Reader#readByte` — Reads a single byte (0–255), widened to `Int`. Returns `-1` when the reader is closed. Dispatches on `Reader#jvmType` for zero-boxing when the reader is specialized:
|
|
1004
|
+
|
|
1005
|
+
```scala
|
|
1006
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1007
|
+
def readByte(): Int
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1011
|
+
def readByte(): Async[Int]
|
|
1012
|
+
}
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
Read bytes one at a time from a reader until end-of-stream:
|
|
1016
|
+
|
|
1017
|
+
```scala
|
|
1018
|
+
import zio.blocks.streams.io.Reader
|
|
1019
|
+
import java.io.ByteArrayInputStream
|
|
1020
|
+
|
|
1021
|
+
val bytes = Array[Byte](72, 101, 108, 108, 111) // Hello in ASCII bytes
|
|
1022
|
+
// bytes: Array[Byte] = Array(72, 101, 108, 108, 111)
|
|
1023
|
+
val is = new ByteArrayInputStream(bytes)
|
|
1024
|
+
// is: ByteArrayInputStream = java.io.ByteArrayInputStream@640950b
|
|
1025
|
+
val r = Reader.fromInputStream(is)
|
|
1026
|
+
// r: SyncReader[Byte] = zio.blocks.streams.io.Reader$InputStreamReader@76c1d83a
|
|
1027
|
+
|
|
1028
|
+
def drainBytes(): Unit = {
|
|
1029
|
+
val b = r.readByte()
|
|
1030
|
+
if (b != -1) {
|
|
1031
|
+
println(s"Byte: $b (${b.toChar})")
|
|
1032
|
+
drainBytes()
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
drainBytes()
|
|
1036
|
+
// Byte: 72 (H)
|
|
1037
|
+
// Byte: 101 (e)
|
|
1038
|
+
// Byte: 108 (l)
|
|
1039
|
+
// Byte: 108 (l)
|
|
1040
|
+
// Byte: 111 (o)
|
|
1041
|
+
// Output:
|
|
1042
|
+
// Byte: 72 (H)
|
|
1043
|
+
// Byte: 101 (e)
|
|
1044
|
+
// Byte: 108 (l)
|
|
1045
|
+
// Byte: 108 (l)
|
|
1046
|
+
// Byte: 111 (o)
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
`Reader#readBytes` — Bulk byte read into a caller-supplied buffer, mirroring `java.io.InputStream#read(byte[], int, int)`. The behavior is:
|
|
1050
|
+
|
|
1051
|
+
- A `SyncReader` blocks until at least 1 byte is available; an `AsyncReader` represents that wait in `Async`.
|
|
1052
|
+
- Returns the number of bytes read (`1 <= r <= len`).
|
|
1053
|
+
- Returns `-1` when closed and empty.
|
|
1054
|
+
- Returns `0` immediately when `len == 0`.
|
|
1055
|
+
|
|
1056
|
+
The method signature is:
|
|
1057
|
+
|
|
1058
|
+
```scala
|
|
1059
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1060
|
+
def readBytes(buf: Array[Byte], offset: Int, len: Int)(implicit ev: Elem <:< Byte): Int
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1064
|
+
def readBytes(dest: Array[Byte], offset: Int, length: Int)(implicit ev: Elem <:< Byte): Async[Int]
|
|
1065
|
+
}
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
Read multiple bytes into a buffer in bulk with a loop pattern:
|
|
1069
|
+
|
|
1070
|
+
```scala
|
|
1071
|
+
import zio.blocks.streams.io.Reader
|
|
1072
|
+
import java.io.ByteArrayInputStream
|
|
1073
|
+
|
|
1074
|
+
val bytes = Array[Byte](72, 101, 108, 108, 111) // The word Hello
|
|
1075
|
+
// bytes: Array[Byte] = Array(72, 101, 108, 108, 111)
|
|
1076
|
+
val is = new ByteArrayInputStream(bytes)
|
|
1077
|
+
// is: ByteArrayInputStream = java.io.ByteArrayInputStream@7b76b6d9
|
|
1078
|
+
val r = Reader.fromInputStream(is)
|
|
1079
|
+
// r: SyncReader[Byte] = zio.blocks.streams.io.Reader$InputStreamReader@43a74358
|
|
1080
|
+
|
|
1081
|
+
val buffer = new Array[Byte](3)
|
|
1082
|
+
// buffer: Array[Byte] = Array(108, 111, 108)
|
|
1083
|
+
|
|
1084
|
+
def drainBulk(): Unit = {
|
|
1085
|
+
val bytesRead = r.readBytes(buffer, 0, 3)
|
|
1086
|
+
if (bytesRead > 0) {
|
|
1087
|
+
val chunk = buffer.take(bytesRead).map(_.toChar).mkString
|
|
1088
|
+
println(s"Read $bytesRead bytes: $chunk")
|
|
1089
|
+
drainBulk()
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
drainBulk()
|
|
1093
|
+
// Read 3 bytes: Hel
|
|
1094
|
+
// Read 2 bytes: lo
|
|
1095
|
+
// Output:
|
|
1096
|
+
// Read 3 bytes: Hel
|
|
1097
|
+
// Read 2 bytes: lo
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
### Character and Numeric Specialization
|
|
1101
|
+
|
|
1102
|
+
`Reader#readChar` — Sentinel-return `Char` pull. Returns the element widened to `Int`, or `sentinel` when closed. Requires evidence that `Elem <:< Char`:
|
|
1103
|
+
|
|
1104
|
+
```scala
|
|
1105
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1106
|
+
def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Int
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1110
|
+
def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Async[Int]
|
|
1111
|
+
}
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
`Reader#readShort` — Sentinel-return `Short` pull. Returns the element widened to `Int`, or `sentinel` when closed:
|
|
1115
|
+
|
|
1116
|
+
```scala
|
|
1117
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1118
|
+
def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Int
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1122
|
+
def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Async[Int]
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
`Reader#readBoolean` — Sentinel-return `Boolean` pull. Returns `1` for `true`, `0` for `false`, or `sentinel` when closed. The sentinel must lie outside `[0, 1]` (typically `-1`):
|
|
1127
|
+
|
|
1128
|
+
```scala
|
|
1129
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1130
|
+
def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Int
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1134
|
+
def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Async[Int]
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
### Bulk Operations
|
|
1139
|
+
|
|
1140
|
+
`Reader#readAll` — Drains the entire reader into a `Chunk`. Dispatches on `Reader#jvmType` for zero-boxing on primitive readers:
|
|
1141
|
+
|
|
1142
|
+
```scala
|
|
1143
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1144
|
+
def readAll[A >: Elem](): Chunk[A]
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1148
|
+
def readAll[A >: Elem](): Async[Chunk[A]]
|
|
1149
|
+
}
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
The result is a new chunk containing all remaining elements:
|
|
1153
|
+
|
|
1154
|
+
```scala
|
|
1155
|
+
import zio.blocks.streams.io.Reader
|
|
1156
|
+
import zio.blocks.chunk.Chunk
|
|
1157
|
+
|
|
1158
|
+
val r = Reader.fromChunk(Chunk(10, 20, 30))
|
|
1159
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@27eaf89c
|
|
1160
|
+
val all = r.readAll()
|
|
1161
|
+
// all: Chunk[Int] = IndexedSeq(10, 20, 30)
|
|
1162
|
+
println(all) // Chunk(10,20,30)
|
|
1163
|
+
// Chunk(10,20,30)
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
`Reader#readN` and `Reader#readUpToN` — Bounded drains. `readN` gathers up to `n` elements, returning early only when the reader is exhausted; `readUpToN` gathers at most `n` elements and stops as soon as the next element is not already available, so it never waits for a slow producer to fill the request. Both produce an empty chunk when `n <= 0` or the reader is at end-of-stream:
|
|
1167
|
+
|
|
1168
|
+
```scala
|
|
1169
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1170
|
+
def readN[A >: Elem](n: Int): Chunk[A]
|
|
1171
|
+
def readUpToN[A >: Elem](n: Int): Chunk[A]
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1175
|
+
def readN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
1176
|
+
def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
1177
|
+
}
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
:::caution[Bound `n` yourself]
|
|
1181
|
+
`n` is a request, and some readers size a buffer from it before knowing how much data will arrive. The JVM channel-backed byte reader allocates `new Array[Byte](n)` up front in `readUpToN`, and the only guards on that allocation are `n <= 0` and an already-closed reader — there is no upper bound. Passing `Int.MaxValue` therefore asks for a 2 GB array rather than "whatever is ready". Choose a bound that reflects how much you are prepared to hold in memory, such as a page or buffer size.
|
|
1182
|
+
:::
|
|
1183
|
+
|
|
1184
|
+
`Reader#skip` — Eagerly discards the first `n` elements. Dispatches on `Reader#jvmType` for zero-boxing when possible:
|
|
1185
|
+
|
|
1186
|
+
```scala
|
|
1187
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1188
|
+
def skip(n: Long): Unit
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1192
|
+
def skip(n: Long): Async[Unit]
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
### State Queries
|
|
1197
|
+
|
|
1198
|
+
`isClosed` reports whether the reader is closed. Its result is monotone: once `true`, it never becomes `false`:
|
|
1199
|
+
|
|
1200
|
+
```scala
|
|
1201
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1202
|
+
def isClosed: Boolean
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1206
|
+
def isClosed: Async[Boolean]
|
|
1207
|
+
}
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
`readable` reports whether the next `read()` would produce a value (not the sentinel). On `AsyncReader` the answer itself is asynchronous. Buffered readers can override it for an accurate, non-consuming probe:
|
|
1211
|
+
|
|
1212
|
+
```scala
|
|
1213
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1214
|
+
def readable(): Boolean
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1218
|
+
def readable(): Async[Boolean]
|
|
1219
|
+
}
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
Use `readable()` to check if elements are available before calling `read()`:
|
|
1223
|
+
|
|
1224
|
+
```scala
|
|
1225
|
+
import zio.blocks.streams.io.Reader
|
|
1226
|
+
import zio.blocks.chunk.Chunk
|
|
1227
|
+
|
|
1228
|
+
val r = Reader.fromChunk(Chunk(1, 2))
|
|
1229
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@51d94e24
|
|
1230
|
+
println(r.readable()) // true
|
|
1231
|
+
// true
|
|
1232
|
+
r.read(-1)
|
|
1233
|
+
// res39: Int = 1
|
|
1234
|
+
println(r.readable()) // true
|
|
1235
|
+
// true
|
|
1236
|
+
r.read(-1)
|
|
1237
|
+
// res41: Int = 2
|
|
1238
|
+
println(r.readable()) // false
|
|
1239
|
+
// false
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
## Asynchronous Reading
|
|
1243
|
+
|
|
1244
|
+
`Reader.AsyncReader[Elem]` is the kind a stream materializes as whenever its graph contains an asynchronous node. It is not a second API: it is the surface described above with every result moved inside `Async`. What follows is that member list, and the handful of behaviours that are specific to the asynchronous kind.
|
|
1245
|
+
|
|
1246
|
+
A custom asynchronous reader supplies four members. Everything else on the class has a working default built on top of them:
|
|
1247
|
+
|
|
1248
|
+
```scala
|
|
1249
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
1250
|
+
def read[A >: Elem](sentinel: A): Async[A]
|
|
1251
|
+
def readable(): Async[Boolean]
|
|
1252
|
+
def isClosed: Async[Boolean]
|
|
1253
|
+
def close(): Async[Unit]
|
|
1254
|
+
}
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
That is enough to build a reader the whole stream machinery can drive:
|
|
1258
|
+
|
|
1259
|
+
```scala
|
|
1260
|
+
import zio.blocks.streams.io.Reader
|
|
1261
|
+
import zio.blocks.async._
|
|
1262
|
+
|
|
1263
|
+
final class OneShot(value: Int) extends Reader.AsyncReader[Int] {
|
|
1264
|
+
private var delivered = false
|
|
1265
|
+
private var closed = false
|
|
1266
|
+
|
|
1267
|
+
def read[A >: Int](sentinel: A): Async[A] =
|
|
1268
|
+
if (closed || delivered) Async.succeed(sentinel)
|
|
1269
|
+
else { delivered = true; Async.succeed(value) }
|
|
1270
|
+
|
|
1271
|
+
def readable(): Async[Boolean] = Async.succeed(!closed && !delivered)
|
|
1272
|
+
def isClosed: Async[Boolean] = Async.succeed(closed)
|
|
1273
|
+
def close(): Async[Unit] = Async.succeed { closed = true }
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
The three bulk reads are derived from `read` and the reader's lane, so an implementation gets them for free and overrides them only to exploit a cheaper native path:
|
|
1278
|
+
|
|
1279
|
+
```scala
|
|
1280
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
1281
|
+
def readAll[A >: Elem](): Async[Chunk[A]]
|
|
1282
|
+
def readN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
1283
|
+
def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
|
|
1284
|
+
}
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
`readAll()` is `readN(Int.MaxValue)`, `readN` gathers until it has `n` elements or hits end-of-stream, and `readUpToN` additionally stops as soon as the next element is not already available. All three yield to the scheduler after a fixed budget of consecutive pulls, so a fast in-memory reader cannot monopolize the calling thread.
|
|
1288
|
+
|
|
1289
|
+
The eight primitive pulls mirror the synchronous lane exactly, including the widened carriers — a `Char`, `Short`, `Boolean`, or `Byte` lane returns its value in an `Int`, an `Int` lane in a `Long`, and a `Float` lane in a `Double` — with the result inside `Async`:
|
|
1290
|
+
|
|
1291
|
+
```scala
|
|
1292
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
1293
|
+
def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Async[Int]
|
|
1294
|
+
def readByte(): Async[Int]
|
|
1295
|
+
def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Async[Int]
|
|
1296
|
+
def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Async[Int]
|
|
1297
|
+
def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Async[Long]
|
|
1298
|
+
def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Async[Long]
|
|
1299
|
+
def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Async[Double]
|
|
1300
|
+
def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Async[Double]
|
|
1301
|
+
}
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
Calling one of these on a reader whose `jvmType` is a different primitive lane does not throw at the call site: it returns a failed `Async` carrying an `UnsupportedOperationException` that names both lanes. A reader on the `AnyRef` lane, by contrast, satisfies every one of them by pulling boxed and converting.
|
|
1305
|
+
|
|
1306
|
+
Five bulk array transfers fill a caller-supplied array and report how many elements were written, or `-1` when the reader was already at end-of-stream:
|
|
1307
|
+
|
|
1308
|
+
```scala
|
|
1309
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
1310
|
+
def readBytes(dest: Array[Byte], offset: Int, length: Int)(implicit ev: Elem <:< Byte): Async[Int]
|
|
1311
|
+
def readInts(dest: Array[Int], offset: Int, length: Int)(implicit ev: Elem <:< Int): Async[Int]
|
|
1312
|
+
def readLongs(dest: Array[Long], offset: Int, length: Int)(implicit ev: Elem <:< Long): Async[Int]
|
|
1313
|
+
def readFloats(dest: Array[Float], offset: Int, length: Int)(implicit ev: Elem <:< Float): Async[Int]
|
|
1314
|
+
def readDoubles(dest: Array[Double], offset: Int, length: Int)(implicit ev: Elem <:< Double): Async[Int]
|
|
1315
|
+
}
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
A transfer also stops short of `length` when the next element is not already available, so a partial count is a normal result rather than a sign of end-of-stream. An out-of-range `offset` or `length` surfaces as a failed `Async`, not a thrown exception.
|
|
1319
|
+
|
|
1320
|
+
The five control operations complete the mirror:
|
|
1321
|
+
|
|
1322
|
+
```scala
|
|
1323
|
+
abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
|
|
1324
|
+
def skip(n: Long): Async[Unit]
|
|
1325
|
+
def reset(): Async[Unit]
|
|
1326
|
+
def setLimit(n: Long): Async[Boolean]
|
|
1327
|
+
def setRepeat(): Async[Boolean]
|
|
1328
|
+
def setSkip(n: Long): Async[Boolean]
|
|
1329
|
+
}
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
`skip` has a real default that discards elements through the reader's own lane. The pushdown operations do not: on the base class `reset()` fails with an `UnsupportedOperationException`, and `setLimit`, `setRepeat`, and `setSkip` each succeed with `false`. Those defaults are the honest answer for a reader that cannot rewind or bound itself natively, and callers already handle them — a `false` simply means the interpreter wraps the reader instead of pushing the operation down. Override them only when your reader can genuinely do the work in O(1).
|
|
1333
|
+
|
|
1334
|
+
### One Active Operation at a Time
|
|
1335
|
+
|
|
1336
|
+
An `AsyncReader` is a single-consumer cursor with one position and one lifecycle. **At most one operation may be in flight at a time.** Await the `Async` returned by a pull, a transfer, a control operation, or `close()` before beginning the next one.
|
|
1337
|
+
|
|
1338
|
+
For an implementor this is a contract you may rely on and must not weaken: your `read` will not be re-entered while a previous `read` is still pending, so internal position and buffer state need no defence against overlap. It is also a contract you inherit — a reader you wrap gets the same guarantee only if you preserve it, so never fan a single downstream pull out into concurrent pulls on your source.
|
|
1339
|
+
|
|
1340
|
+
Readers are not thread-safe either. Driving one reader from two threads without external synchronization is outside the contract, and the result is not specified. [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md#one-active-operation-per-reader) states the same rule from the consumer's side.
|
|
1341
|
+
|
|
1342
|
+
### Close Ownership
|
|
1343
|
+
|
|
1344
|
+
Every asynchronous reader has exactly one owner, and the owner is responsible for awaiting `close()`. Which side holds it is never ambiguous, because the entry point that handed you the reader decides: terminals own and close the reader they compile, `Stream#startAsync` transfers ownership to you, and `Stream#useReaderAsync` retains it. [Manual Pull and Ownership](../execution-and-compatibility/async-execution.md#manual-pull-and-ownership) gives each case in full from the consumer's side, including what a forgotten `close()` costs. The rest of this section is what ownership means for the reader itself.
|
|
1345
|
+
|
|
1346
|
+
`close()` is itself an asynchronous operation: it participates in the one-active-operation rule, it cancels or joins work already in flight, and its result must be awaited rather than discarded. Library readers tolerate a repeated close, but the owner should still close exactly once.
|
|
1347
|
+
|
|
1348
|
+
For an implementor, `close()` is where release actions and underlying resources are surfaced. A failure during cleanup is reported through the returned `Async` rather than swallowed, so do not let a failing release leave the reader believing it is still open.
|
|
1349
|
+
|
|
1350
|
+
```scala
|
|
1351
|
+
import zio.blocks.streams._
|
|
1352
|
+
import zio.blocks.streams.io.Reader
|
|
1353
|
+
import zio.blocks.chunk.Chunk
|
|
1354
|
+
import zio.blocks.async._
|
|
1355
|
+
|
|
1356
|
+
// Ownership retained by the library: the reader is closed on every outcome.
|
|
1357
|
+
val firstFive: Async[Chunk[Int]] =
|
|
1358
|
+
Stream.range(1, 100).useReaderAsync { (r: Reader.AsyncReader[Int]) =>
|
|
1359
|
+
r.readN(5)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Ownership transferred to the caller: closing is now your job.
|
|
1363
|
+
val owned: Async[Chunk[Int]] =
|
|
1364
|
+
Stream.range(1, 100).startAsync.flatMap { r =>
|
|
1365
|
+
r.readN(5).flatMap(chunk => r.close().map(_ => chunk))
|
|
1366
|
+
}
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
## Composition
|
|
1370
|
+
|
|
1371
|
+
Combine multiple readers to build more complex sources:
|
|
1372
|
+
|
|
1373
|
+
### Concatenation
|
|
1374
|
+
|
|
1375
|
+
`Reader#concat` — Concatenates this reader with `next`. When this reader is exhausted, it is closed and elements are pulled from `next` (evaluated lazily). Optimized for left-associative chains:
|
|
1376
|
+
|
|
1377
|
+
```scala
|
|
1378
|
+
abstract class Reader[+Elem] {
|
|
1379
|
+
def concat[Elem2 >: Elem](next: () => Reader[Elem2]): Reader[Elem2]
|
|
1380
|
+
}
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
`Reader#++` — Alias for `Reader#concat`. Syntactic sugar for composing readers:
|
|
1384
|
+
|
|
1385
|
+
```scala
|
|
1386
|
+
abstract class Reader[+Elem] {
|
|
1387
|
+
def ++[Elem2 >: Elem](next: => Reader[Elem2]): Reader[Elem2]
|
|
1388
|
+
}
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
Here is how concatenation chains multiple readers together:
|
|
1392
|
+
|
|
1393
|
+
```scala
|
|
1394
|
+
import zio.blocks.streams.io.Reader
|
|
1395
|
+
import zio.blocks.chunk.Chunk
|
|
1396
|
+
|
|
1397
|
+
val r1 = Reader.fromChunk(Chunk(1, 2))
|
|
1398
|
+
// r1: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@71d55ad4
|
|
1399
|
+
val r2 = Reader.fromChunk(Chunk(3, 4))
|
|
1400
|
+
// r2: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@135b9e72
|
|
1401
|
+
val combined = r1 ++ r2
|
|
1402
|
+
// combined: SyncReader[Int] = zio.blocks.streams.io.Reader$ConcatReader@4d6fb94b
|
|
1403
|
+
|
|
1404
|
+
def drain(): Unit = {
|
|
1405
|
+
val v = combined.read(-1)
|
|
1406
|
+
if (v != -1) {
|
|
1407
|
+
println(v)
|
|
1408
|
+
drain()
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
drain()
|
|
1412
|
+
// 1
|
|
1413
|
+
// 2
|
|
1414
|
+
// 3
|
|
1415
|
+
// 4
|
|
1416
|
+
// Output: 1, 2, 3, 4
|
|
1417
|
+
```
|
|
1418
|
+
|
|
1419
|
+
These are the root's declarations, which answer with a `Reader[Elem2]`. `SyncReader` narrows them with a second pair of overloads so that concatenating two synchronous readers gives back a `SyncReader`; see [Mixed-kind composition](#mixed-kind-composition) for which combination produces which kind.
|
|
1420
|
+
|
|
1421
|
+
**Optimization**: If this reader is already a `ConcatReader`, the thunk is appended to its internal array and `this` is returned (mutable append, O(1) amortized). Otherwise a new `ConcatReader` is created. This ensures that left-associative chains like `a ++ b ++ c ++ d` compile into a single flat `ConcatReader` with O(1) per-element read, rather than O(n) nested wrappers.
|
|
1422
|
+
|
|
1423
|
+
## Resource Management
|
|
1424
|
+
|
|
1425
|
+
Close readers and attach cleanup callbacks:
|
|
1426
|
+
|
|
1427
|
+
### Closing
|
|
1428
|
+
|
|
1429
|
+
`close` signals end-of-stream from the consumer side and releases any held resources. Implementations set internal closed state and wake or cancel any pending work. A synchronous owner calls it directly; an asynchronous owner must run and await the returned `Async`:
|
|
1430
|
+
|
|
1431
|
+
```scala
|
|
1432
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1433
|
+
def close(): Unit
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1437
|
+
def close(): Async[Unit]
|
|
1438
|
+
}
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
`SyncReader#withRelease` wraps a synchronous reader so that `release` runs when it closes. `withReleaseAsync`, available on the root and therefore on both kinds, returns an `AsyncReader` and awaits asynchronous cleanup:
|
|
1442
|
+
|
|
1443
|
+
```scala
|
|
1444
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1445
|
+
def withRelease(release: () => Unit): Reader.SyncReader[Elem]
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
abstract class Reader[+Elem] {
|
|
1449
|
+
def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
|
|
1450
|
+
}
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
Here is how cleanup logic is attached to a reader:
|
|
1454
|
+
|
|
1455
|
+
```scala
|
|
1456
|
+
import zio.blocks.streams.io.Reader
|
|
1457
|
+
import zio.blocks.chunk.Chunk
|
|
1458
|
+
import scala.sys.Prop
|
|
1459
|
+
|
|
1460
|
+
val cleanupRef = scala.collection.mutable.ListBuffer[String]()
|
|
1461
|
+
// cleanupRef: ListBuffer[String] = ListBuffer("cleaned")
|
|
1462
|
+
val r = Reader.fromChunk(Chunk(1, 2)).withRelease { () =>
|
|
1463
|
+
cleanupRef += "cleaned"
|
|
1464
|
+
println("Cleaned up")
|
|
1465
|
+
}
|
|
1466
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$SyncReader$$anon$3@219c510b
|
|
1467
|
+
|
|
1468
|
+
r.close()
|
|
1469
|
+
// Cleaned up
|
|
1470
|
+
println(cleanupRef.nonEmpty) // true
|
|
1471
|
+
// true
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
## Pushdown Operations
|
|
1475
|
+
|
|
1476
|
+
Readers can sometimes handle skip, limit, and repeat operations natively (O(1), zero per-element cost). These methods attempt that; if the reader cannot handle it natively, they return `false` and the caller must wrap the reader.
|
|
1477
|
+
|
|
1478
|
+
`Reader#setSkip` — Attempts to set a skip (drop) on this reader. Returns `true` if handled natively, `false` if the caller must wrap. When `true`, the next n elements are discarded before producing. After `Reader#reset()`, the skip is re-applied:
|
|
1479
|
+
|
|
1480
|
+
```scala
|
|
1481
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1482
|
+
def setSkip(n: Long): Boolean
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1486
|
+
def setSkip(n: Long): Async[Boolean]
|
|
1487
|
+
}
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
Set a skip to discard the first two elements:
|
|
1491
|
+
|
|
1492
|
+
```scala
|
|
1493
|
+
import zio.blocks.streams.io.Reader
|
|
1494
|
+
import zio.blocks.chunk.Chunk
|
|
1495
|
+
|
|
1496
|
+
val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
|
|
1497
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@3a51e8e2
|
|
1498
|
+
val handled = r.setSkip(2)
|
|
1499
|
+
// handled: Boolean = true
|
|
1500
|
+
println(s"Skip handled natively: $handled")
|
|
1501
|
+
// Skip handled natively: true
|
|
1502
|
+
|
|
1503
|
+
def drain(): Unit = {
|
|
1504
|
+
val v = r.read(-1)
|
|
1505
|
+
if (v != -1) {
|
|
1506
|
+
println(v)
|
|
1507
|
+
drain()
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
drain()
|
|
1511
|
+
// 3
|
|
1512
|
+
// 4
|
|
1513
|
+
// 5
|
|
1514
|
+
// Output:
|
|
1515
|
+
// Skip handled natively: true
|
|
1516
|
+
// 3
|
|
1517
|
+
// 4
|
|
1518
|
+
// 5
|
|
1519
|
+
```
|
|
1520
|
+
|
|
1521
|
+
`Reader#setLimit` — Attempts to set a limit on this reader so it produces at most `n` elements. Returns `true` if handled natively, `false` if the caller must wrap. After `reset()`, the limit is re-applied from the new start position:
|
|
1522
|
+
|
|
1523
|
+
```scala
|
|
1524
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1525
|
+
def setLimit(n: Long): Boolean
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1529
|
+
def setLimit(n: Long): Async[Boolean]
|
|
1530
|
+
}
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
Set a limit to produce only three elements:
|
|
1534
|
+
|
|
1535
|
+
```scala
|
|
1536
|
+
import zio.blocks.streams.io.Reader
|
|
1537
|
+
import zio.blocks.chunk.Chunk
|
|
1538
|
+
|
|
1539
|
+
val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
|
|
1540
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@39b592b4
|
|
1541
|
+
val handled = r.setLimit(3)
|
|
1542
|
+
// handled: Boolean = true
|
|
1543
|
+
println(s"Limit handled natively: $handled")
|
|
1544
|
+
// Limit handled natively: true
|
|
1545
|
+
|
|
1546
|
+
def drain(): Unit = {
|
|
1547
|
+
val v = r.read(-1)
|
|
1548
|
+
if (v != -1) {
|
|
1549
|
+
println(v)
|
|
1550
|
+
drain()
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
drain()
|
|
1554
|
+
// 1
|
|
1555
|
+
// 2
|
|
1556
|
+
// 3
|
|
1557
|
+
// Output:
|
|
1558
|
+
// Limit handled natively: true
|
|
1559
|
+
// 1
|
|
1560
|
+
// 2
|
|
1561
|
+
// 3
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
`Reader#setRepeat` — Attempts to set this reader into repeat-forever mode, so it restarts from the beginning whenever it would otherwise close. Returns `true` if handled natively, `false` if the caller must wrap:
|
|
1565
|
+
|
|
1566
|
+
```scala
|
|
1567
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1568
|
+
def setRepeat(): Boolean
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1572
|
+
def setRepeat(): Async[Boolean]
|
|
1573
|
+
}
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
Set repeat mode so a reader restarts instead of closing. Not every reader can do this natively — the chunk-, iterable- and range-backed readers cannot, and return `false`; the single-element readers can:
|
|
1577
|
+
|
|
1578
|
+
```scala
|
|
1579
|
+
import zio.blocks.streams.io.Reader
|
|
1580
|
+
|
|
1581
|
+
val r = Reader.single(1)
|
|
1582
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@1157068a
|
|
1583
|
+
val handled = r.setRepeat()
|
|
1584
|
+
// handled: Boolean = true
|
|
1585
|
+
println(s"Repeat handled natively: $handled")
|
|
1586
|
+
// Repeat handled natively: true
|
|
1587
|
+
|
|
1588
|
+
def drain(count: Int): Unit = {
|
|
1589
|
+
if (count < 4) {
|
|
1590
|
+
val v = r.read(-1)
|
|
1591
|
+
println(v)
|
|
1592
|
+
drain(count + 1)
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
drain(0)
|
|
1596
|
+
// 1
|
|
1597
|
+
// 1
|
|
1598
|
+
// 1
|
|
1599
|
+
// 1
|
|
1600
|
+
// Output:
|
|
1601
|
+
// Repeat handled natively: true
|
|
1602
|
+
// 1
|
|
1603
|
+
// 1
|
|
1604
|
+
// 1
|
|
1605
|
+
// 1
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
`Reader.fromChunk(Chunk(1, 2)).setRepeat()` returns `false` instead: chunk-backed readers do not implement repeat, so the caller wraps the reader rather than pushing the operation down.
|
|
1609
|
+
|
|
1610
|
+
`Reader#reset` — Rewinds this reader to its initial state, as if freshly constructed. After `Reader#reset()`, all elements are available again from the beginning. Not all readers support this; readers backed by one-shot resources (InputStreams, `java.io.Reader`s) throw `UnsupportedOperationException`:
|
|
1611
|
+
|
|
1612
|
+
```scala
|
|
1613
|
+
abstract class Reader.SyncReader[+Elem] {
|
|
1614
|
+
def reset(): Unit
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
abstract class Reader.AsyncReader[+Elem] {
|
|
1618
|
+
def reset(): Async[Unit]
|
|
1619
|
+
}
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
After rewinding, the reader starts from the beginning:
|
|
1623
|
+
|
|
1624
|
+
```scala
|
|
1625
|
+
import zio.blocks.streams.io.Reader
|
|
1626
|
+
import zio.blocks.chunk.Chunk
|
|
1627
|
+
|
|
1628
|
+
val r = Reader.fromChunk(Chunk(1, 2, 3))
|
|
1629
|
+
// r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@6fbde88a
|
|
1630
|
+
println(r.read(-1)) // 1
|
|
1631
|
+
// 1
|
|
1632
|
+
r.reset()
|
|
1633
|
+
println(r.read(-1)) // 1 (back to the beginning)
|
|
1634
|
+
// 1
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
## Integration with Stream
|
|
1638
|
+
|
|
1639
|
+
`Reader` is the compilation target of `Stream`. When you call a terminal operation, the stream compiles to a `Reader`, which is then consumed.
|
|
1640
|
+
|
|
1641
|
+
For cross-platform manual pulling, use caller-owned `Stream#startAsync` or bracketed `Stream#useReaderAsync`. `startAsync` transfers ownership to you, so you must await `close()` on every exit path; `useReaderAsync` retains ownership and closes automatically on success, failure, or cancellation. The JVM-only `Stream#start` returns a scoped blocking reader owned by its scope:
|
|
1642
|
+
|
|
1643
|
+
```scala
|
|
1644
|
+
import zio.blocks.streams.*
|
|
1645
|
+
import zio.blocks.streams.io.Reader
|
|
1646
|
+
import zio.blocks.scope.*
|
|
1647
|
+
|
|
1648
|
+
Scope.global.scoped { scope =>
|
|
1649
|
+
import scope.*
|
|
1650
|
+
|
|
1651
|
+
val reader: $[Reader.SyncReader[Int]] = Stream.range(1, 6).start(using scope)
|
|
1652
|
+
|
|
1653
|
+
$(reader) { r =>
|
|
1654
|
+
def drain(): Unit = {
|
|
1655
|
+
val v = r.read(-1)
|
|
1656
|
+
if (v != -1) {
|
|
1657
|
+
println(v) // prints 1, 2, 3, 4, 5
|
|
1658
|
+
drain()
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
drain()
|
|
1662
|
+
}
|
|
1663
|
+
// reader is closed automatically when scope exits
|
|
1664
|
+
}
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
:::caution
|
|
1668
|
+
Avoid holding references to a `SyncReader` obtained via `Stream#start` outside its [`Scope`](../../resource-management/scope.md). The scope guarantees cleanup; escaping the reader defeats that guarantee.
|
|
1669
|
+
:::
|
|
1670
|
+
|
|
1671
|
+
## Integration with Sink
|
|
1672
|
+
|
|
1673
|
+
`Reader` and `Sink` are dual: `Reader` is the source, and `Sink` is the consumer. A terminal compiles the stream to the reader kind required by the graph, hands that reader to the sink's drain, and retains ownership of it. On the JVM, plain terminals such as `run` use the blocking `SyncReader` path when the graph is synchronous and bridge genuine asynchronous boundaries at the final edge. Cross-platform `runAsync` drains an `AsyncReader` without blocking. Both terminal families close the owned reader on success, typed failure, defect, or cancellation.
|
|
1674
|
+
|
|
1675
|
+
The sink repeatedly pulls from its reader until end-of-stream, transforming the sequence of elements into a result of type `Z`. This kind-selected drain is an implementation detail; callers choose it through `run` or `runAsync` rather than invoking a sink drain method directly.
|
|
1676
|
+
|
|
1677
|
+
For example, `Sink.collectAll` drains all elements and returns them as a `Chunk`:
|
|
1678
|
+
|
|
1679
|
+
```scala
|
|
1680
|
+
import zio.blocks.streams._
|
|
1681
|
+
|
|
1682
|
+
val result = Stream.range(1, 10)
|
|
1683
|
+
.run(Sink.collectAll[Int])
|
|
1684
|
+
// result: Either[Nothing, Chunk[Int]] = Right(
|
|
1685
|
+
// IndexedSeq(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
|
1686
|
+
// )
|
|
1687
|
+
```
|
|
1688
|
+
|
|
1689
|
+
## Implementation Notes
|
|
1690
|
+
|
|
1691
|
+
Understand the design choices and mechanisms that power `Reader`:
|
|
1692
|
+
|
|
1693
|
+
### Sentinel Protocol
|
|
1694
|
+
|
|
1695
|
+
The `read(sentinel)` method uses a caller-chosen sentinel value to signal end-of-stream. This avoids the allocation and boxing of wrapping results in `Option` or `Either`. The sentinel must be a value that never appears as a real element.
|
|
1696
|
+
|
|
1697
|
+
The contract has three parts, and it is the same on both reader kinds:
|
|
1698
|
+
|
|
1699
|
+
1. **The caller owns the sentinel.** The reader never invents one. Pick a value that cannot occur in your data — `null` is the usual choice for reference elements.
|
|
1700
|
+
2. **The sentinel travels in the widened carrier.** A primitive pull returns the lane's widened type, not the element type, precisely so a value outside the element's domain is available to spend as the sentinel. `readInt` takes and returns `Long`; `readChar`, `readShort`, and `readBoolean` take and return `Int`; `readFloat` takes and returns `Double`. `readByte()` is the exception that proves the rule: it takes no sentinel parameter because it yields unsigned bytes in `0..255` and can reserve `-1` permanently.
|
|
1701
|
+
3. **Getting the sentinel back means exhausted, and nothing else.** It is not an error signal. Failures arrive as thrown exceptions on a `SyncReader` and as failed `Async` values on an `AsyncReader`.
|
|
1702
|
+
|
|
1703
|
+
Two lanes sit outside that arrangement. There is no `Long` value and no `Double` bit pattern left over to reserve — the carrier is the element type itself, so every candidate sentinel is also legitimate data. **The `Long` and `Double` lanes therefore use no sentinel.** `readLong` and `readDouble` still take a sentinel parameter, for symmetry with the five other sentinel-taking pulls, but nothing can safely fill it; the library never relies on it. Instead those lanes detect end-of-stream by count: a length-one `readLongs` or `readDoubles` whose returned count is negative. That is what makes those two lanes fully lossless — every `Long` value and every `Double` bit pattern stays readable as data.
|
|
1704
|
+
|
|
1705
|
+
For the per-lane end-of-stream detail, including which carrier each lane widens to, see [Zero-Boxing Streams](../execution-and-compatibility/zero-boxing.md), which owns that table.
|
|
1706
|
+
|
|
1707
|
+
### JVM Type Dispatch
|
|
1708
|
+
|
|
1709
|
+
`Reader` dispatches on `jvmType` to choose between unboxed and boxed pull paths. This is a physical contract: `JvmType.Byte`, for example, means `readByte` is supported and yields this reader's elements, even if covariance has widened its static type to `Reader[AnyVal]`. Type-preserving wrappers and widening operations preserve a known lane; only an actually unknown or mixed representation falls back to `AnyRef`. Subclasses with primitive specialization override `jvmType`:
|
|
1710
|
+
|
|
1711
|
+
```scala
|
|
1712
|
+
abstract class Reader[+Elem] {
|
|
1713
|
+
def jvmType: JvmType = JvmType.AnyRef
|
|
1714
|
+
}
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
`JvmType` has nine lanes: the eight JVM primitives — `Boolean`, `Byte`, `Char`, `Short`, `Int`, `Long`, `Float`, `Double` — and `AnyRef` for everything else. The eight primitive tags map exactly to `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`; `AnyRef` is the ninth, and it is the only lane on which all eight of those methods work, because it satisfies them by pulling boxed and converting. For example, a `SyncReader[Int]` backed by a `Chunk[Int]` reports `JvmType.Int`, so consumers may use `readInt`; a pull belonging to another lane throws `UnsupportedOperationException` unless that particular reader happens to implement it too.
|
|
1718
|
+
|
|
1719
|
+
The lane is a property of the reader, not of the kind. A `SyncReader` and the `AsyncReader` it becomes under `toAsync` report the same `jvmType`, and asynchronous readers expose the corresponding values through `Async`.
|
|
1720
|
+
|
|
1721
|
+
### Thread Safety
|
|
1722
|
+
|
|
1723
|
+
Readers are single-consumer cursors, not concurrent work queues. In particular, do not overlap pulls on an `AsyncReader`; await one operation before beginning another.
|
|
1724
|
+
|
|
1725
|
+
## Running the Examples
|
|
1726
|
+
|
|
1727
|
+
All code from this guide is available as runnable examples in the `streams-examples` module. Follow these steps to run them:
|
|
1728
|
+
|
|
1729
|
+
**Step 1** — Clone the repository and navigate to the project:
|
|
1730
|
+
|
|
1731
|
+
```bash
|
|
1732
|
+
git clone https://github.com/zio/zio-blocks.git
|
|
1733
|
+
cd zio-blocks
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
**Step 2** — Run individual examples with sbt:
|
|
1737
|
+
|
|
1738
|
+
### Basic Reader Construction
|
|
1739
|
+
|
|
1740
|
+
This example demonstrates the most common reader factories: `Reader.fromChunk`, `Reader.fromIterable`, `Reader.fromRange`, and `Reader.single`. Embed the source:
|
|
1741
|
+
|
|
1742
|
+
```scala title="streams-examples/src/main/scala/reader/ReaderBasicConstructionExample.scala"
|
|
1743
|
+
package reader
|
|
1744
|
+
|
|
1745
|
+
import zio.blocks.chunk.Chunk
|
|
1746
|
+
import zio.blocks.streams.io.Reader
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Demonstrates the most common Reader factories: fromChunk, fromIterable,
|
|
1750
|
+
* fromRange, single, and unfold. Each reader is drained manually with read() to
|
|
1751
|
+
* show how to consume elements.
|
|
1752
|
+
*/
|
|
1753
|
+
object ReaderBasicConstructionExample extends App {
|
|
1754
|
+
|
|
1755
|
+
println("=== Reader.fromChunk ===")
|
|
1756
|
+
val chunkReader = Reader.fromChunk(Chunk(10, 20, 30))
|
|
1757
|
+
var v = chunkReader.read(-1)
|
|
1758
|
+
while (v != -1) {
|
|
1759
|
+
println(s"Read: $v")
|
|
1760
|
+
v = chunkReader.read(-1)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
println("\n=== Reader.fromRange ===")
|
|
1764
|
+
val rangeReader = Reader.fromRange(1 to 3)
|
|
1765
|
+
v = rangeReader.read(-1)
|
|
1766
|
+
while (v != -1) {
|
|
1767
|
+
println(s"Read: $v")
|
|
1768
|
+
v = rangeReader.read(-1)
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
println("\n=== Reader.fromIterable ===")
|
|
1772
|
+
val listReader = Reader.fromIterable(List("a", "b", "c"))
|
|
1773
|
+
var sv = listReader.read(null: String)
|
|
1774
|
+
while (sv != null) {
|
|
1775
|
+
println(s"Read: $sv")
|
|
1776
|
+
sv = listReader.read(null: String)
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
println("\n=== Reader.single ===")
|
|
1780
|
+
val singleReader = Reader.single(42)
|
|
1781
|
+
println(s"Read: ${singleReader.read(-1)}")
|
|
1782
|
+
println(s"Read again (closed): ${singleReader.read(-1)}")
|
|
1783
|
+
|
|
1784
|
+
println("\n=== Reader.unfold ===")
|
|
1785
|
+
val unfoldReader = Reader.unfold(1) { s =>
|
|
1786
|
+
if (s > 3) None else Some((s * 10, s + 1))
|
|
1787
|
+
}
|
|
1788
|
+
v = unfoldReader.read(-1)
|
|
1789
|
+
while (v != -1) {
|
|
1790
|
+
println(s"Read: $v")
|
|
1791
|
+
v = unfoldReader.read(-1)
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
println("\n=== Reader state ===")
|
|
1795
|
+
val stateReader = Reader.fromChunk(Chunk(5, 6))
|
|
1796
|
+
println(s"readable before: ${stateReader.readable()}")
|
|
1797
|
+
stateReader.read(-1)
|
|
1798
|
+
println(s"readable after one read: ${stateReader.readable()}")
|
|
1799
|
+
stateReader.read(-1)
|
|
1800
|
+
println(s"readable after exhaustion: ${stateReader.readable()}")
|
|
1801
|
+
println(s"isClosed: ${stateReader.isClosed}")
|
|
1802
|
+
}
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
Run it with:
|
|
1806
|
+
|
|
1807
|
+
```bash
|
|
1808
|
+
sbt "streams-examples/runMain reader.ReaderBasicConstructionExample"
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
### Primitive Specialization and Bulk Operations
|
|
1812
|
+
|
|
1813
|
+
This example shows how primitive readers avoid boxing through `Reader#jvmType` dispatch, and demonstrates `Reader#readAll` and `Reader#skip` for bulk operations. Embed the source:
|
|
1814
|
+
|
|
1815
|
+
```scala title="streams-examples/src/main/scala/reader/ReaderPrimitiveSpecializationExample.scala"
|
|
1816
|
+
package reader
|
|
1817
|
+
|
|
1818
|
+
import zio.blocks.chunk.Chunk
|
|
1819
|
+
import zio.blocks.streams.io.Reader
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Demonstrates primitive specialization in readers. When a Reader is backed by
|
|
1823
|
+
* primitive types (Int, Long, Float, Double), specialized factory methods like
|
|
1824
|
+
* singleInt, singleLong, etc. avoid boxing entirely. This example also shows
|
|
1825
|
+
* readAll for bulk consumption and skip for advancing the reader.
|
|
1826
|
+
*/
|
|
1827
|
+
object ReaderPrimitiveSpecializationExample extends App {
|
|
1828
|
+
|
|
1829
|
+
println("=== singleInt (zero-boxed) ===")
|
|
1830
|
+
val intReader = Reader.singleInt(42)
|
|
1831
|
+
println(s"Read: ${intReader.read(-1)}")
|
|
1832
|
+
|
|
1833
|
+
println("\n=== singleLong (zero-boxed) ===")
|
|
1834
|
+
val longReader = Reader.singleLong(9999999999L)
|
|
1835
|
+
println(s"Read: ${longReader.read(Long.MaxValue)}")
|
|
1836
|
+
|
|
1837
|
+
println("\n=== singleFloat (zero-boxed) ===")
|
|
1838
|
+
val floatReader = Reader.singleFloat(3.14f)
|
|
1839
|
+
println(s"Read: ${floatReader.readFloat(Float.MaxValue)}")
|
|
1840
|
+
|
|
1841
|
+
println("\n=== singleDouble (zero-boxed) ===")
|
|
1842
|
+
val doubleReader = Reader.singleDouble(2.718)
|
|
1843
|
+
println(s"Read: ${doubleReader.read(Double.MaxValue)}")
|
|
1844
|
+
|
|
1845
|
+
println("\n=== readAll: bulk drain to Chunk ===")
|
|
1846
|
+
val bulkReader = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
|
|
1847
|
+
val allElements = bulkReader.readAll()
|
|
1848
|
+
println(s"All elements: $allElements")
|
|
1849
|
+
|
|
1850
|
+
println("\n=== skip: discard n elements ===")
|
|
1851
|
+
val skipReader = Reader.fromRange(10 to 15)
|
|
1852
|
+
skipReader.skip(2)
|
|
1853
|
+
// Should now read from 12 onward
|
|
1854
|
+
var v = skipReader.read(-1)
|
|
1855
|
+
val remaining = scala.collection.mutable.ArrayBuffer[Int]()
|
|
1856
|
+
while (v != -1) {
|
|
1857
|
+
remaining += v
|
|
1858
|
+
v = skipReader.read(-1)
|
|
1859
|
+
}
|
|
1860
|
+
println(s"After skipping 2: ${remaining.toList}")
|
|
1861
|
+
|
|
1862
|
+
println("\n=== reset: rewind to beginning ===")
|
|
1863
|
+
val resetReader = Reader.fromChunk(Chunk("x", "y", "z"))
|
|
1864
|
+
var elem = resetReader.read(null: String)
|
|
1865
|
+
println(s"First read: $elem")
|
|
1866
|
+
resetReader.reset()
|
|
1867
|
+
elem = resetReader.read(null: String)
|
|
1868
|
+
println(s"After reset: $elem")
|
|
1869
|
+
|
|
1870
|
+
println("\n=== readable: check if elements remain ===")
|
|
1871
|
+
val checkReader = Reader.fromChunk(Chunk(100, 200))
|
|
1872
|
+
println(s"readable before read: ${checkReader.readable()}")
|
|
1873
|
+
checkReader.read(-1)
|
|
1874
|
+
println(s"readable after one read: ${checkReader.readable()}")
|
|
1875
|
+
checkReader.read(-1)
|
|
1876
|
+
println(s"readable after exhaustion: ${checkReader.readable()}")
|
|
1877
|
+
}
|
|
1878
|
+
```
|
|
1879
|
+
|
|
1880
|
+
Run it with:
|
|
1881
|
+
|
|
1882
|
+
```bash
|
|
1883
|
+
sbt "streams-examples/runMain reader.ReaderPrimitiveSpecializationExample"
|
|
1884
|
+
```
|
|
1885
|
+
|
|
1886
|
+
### Composition and Resource Management
|
|
1887
|
+
|
|
1888
|
+
This example demonstrates reader composition with `Reader#++`, resource cleanup with `Reader#withRelease`, and integration with `Stream.start` for manual pulling. Embed the source:
|
|
1889
|
+
|
|
1890
|
+
```scala title="streams-examples/src/main/scala/reader/ReaderCompositionExample.scala"
|
|
1891
|
+
package reader
|
|
1892
|
+
|
|
1893
|
+
import zio.blocks.chunk.Chunk
|
|
1894
|
+
import zio.blocks.streams.io.Reader
|
|
1895
|
+
import zio.blocks.streams.Stream
|
|
1896
|
+
import zio.blocks.scope.Scope
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Demonstrates reader composition with ++ (concat), resource cleanup with
|
|
1900
|
+
* withRelease, and integration with Stream.start for manual element-by-element
|
|
1901
|
+
* pulling within a Scope.
|
|
1902
|
+
*/
|
|
1903
|
+
object ReaderCompositionExample extends App {
|
|
1904
|
+
|
|
1905
|
+
println("=== concat: ++ operator ===")
|
|
1906
|
+
val r1 = Reader.fromChunk(Chunk(1, 2, 3))
|
|
1907
|
+
val r2 = Reader.fromChunk(Chunk(4, 5, 6))
|
|
1908
|
+
val combined = r1 ++ r2
|
|
1909
|
+
|
|
1910
|
+
var v = combined.read(-1)
|
|
1911
|
+
val allCombined = scala.collection.mutable.ArrayBuffer[Int]()
|
|
1912
|
+
while (v != -1) {
|
|
1913
|
+
allCombined += v
|
|
1914
|
+
v = combined.read(-1)
|
|
1915
|
+
}
|
|
1916
|
+
println(s"Combined result: ${allCombined.toList}")
|
|
1917
|
+
|
|
1918
|
+
println("\n=== Multiple concat: a ++ b ++ c ===")
|
|
1919
|
+
val ra = Reader.fromChunk(Chunk("a"))
|
|
1920
|
+
val rb = Reader.fromChunk(Chunk("b"))
|
|
1921
|
+
val rc = Reader.fromChunk(Chunk("c"))
|
|
1922
|
+
val multi = ra ++ rb ++ rc
|
|
1923
|
+
|
|
1924
|
+
var sv = multi.read(null: String)
|
|
1925
|
+
val result = scala.collection.mutable.ArrayBuffer[String]()
|
|
1926
|
+
while (sv != null) {
|
|
1927
|
+
result += sv
|
|
1928
|
+
sv = multi.read(null: String)
|
|
1929
|
+
}
|
|
1930
|
+
println(s"Multiple concat: ${result.toList}")
|
|
1931
|
+
|
|
1932
|
+
println("\n=== withRelease: cleanup on close ===")
|
|
1933
|
+
var cleanupCalled = false
|
|
1934
|
+
val resourceReader = Reader.fromChunk(Chunk(10, 20)).withRelease { () =>
|
|
1935
|
+
cleanupCalled = true
|
|
1936
|
+
println(" Cleanup executed!")
|
|
1937
|
+
}
|
|
1938
|
+
var res = resourceReader.read(-1)
|
|
1939
|
+
while (res != -1) {
|
|
1940
|
+
res = resourceReader.read(-1)
|
|
1941
|
+
}
|
|
1942
|
+
resourceReader.close()
|
|
1943
|
+
println(s"Cleanup was called: $cleanupCalled")
|
|
1944
|
+
|
|
1945
|
+
println("\n=== Stream.start: manual pull with Scope ===")
|
|
1946
|
+
Scope.global.scoped { scope =>
|
|
1947
|
+
import scope.*
|
|
1948
|
+
|
|
1949
|
+
// Create a stream and open it for manual pulling
|
|
1950
|
+
val reader: scope.$[Reader.SyncReader[Int]] = Stream.range(1, 6).start(using scope)
|
|
1951
|
+
|
|
1952
|
+
$(reader) { r =>
|
|
1953
|
+
var streamV = r.read(-1)
|
|
1954
|
+
val manualResult = scala.collection.mutable.ArrayBuffer[Int]()
|
|
1955
|
+
while (streamV != -1) {
|
|
1956
|
+
manualResult += streamV
|
|
1957
|
+
streamV = r.read(-1)
|
|
1958
|
+
}
|
|
1959
|
+
println(s"Manual stream pull: ${manualResult.toList}")
|
|
1960
|
+
}
|
|
1961
|
+
// reader is automatically closed when scope exits
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
println("\n=== repeat: infinite reader ===")
|
|
1965
|
+
val infiniteReader = Reader.repeat(99)
|
|
1966
|
+
infiniteReader.setRepeat()
|
|
1967
|
+
|
|
1968
|
+
var repeatCount = 0
|
|
1969
|
+
var repV = infiniteReader.read(-1)
|
|
1970
|
+
while (repeatCount < 3) {
|
|
1971
|
+
println(s"Infinite read $repeatCount: $repV")
|
|
1972
|
+
repV = infiniteReader.read(-1)
|
|
1973
|
+
repeatCount += 1
|
|
1974
|
+
}
|
|
1975
|
+
infiniteReader.close()
|
|
1976
|
+
}
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
Run it with:
|
|
1980
|
+
|
|
1981
|
+
```bash
|
|
1982
|
+
sbt "streams-examples/runMain reader.ReaderCompositionExample"
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
## See Also
|
|
1986
|
+
|
|
1987
|
+
- [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md) — how a graph picks its engine, the `*Async` surface, and close ownership from the stream's side
|
|
1988
|
+
- [Platform Differences](../execution-and-compatibility/platform-differences.md#availability-matrix) — which reader operations exist on the JVM, on Scala.js, and on both
|
|
1989
|
+
- [Zero-Boxing Streams](../execution-and-compatibility/zero-boxing.md) — how a primitive lane is chosen, and the per-lane end-of-stream table
|
|
1990
|
+
- [Stream](../core/stream.md) — the operator and terminal reference for the type that compiles to a `Reader`
|
|
1991
|
+
- [Sink](../core/sink.md) — the consumer that drains a `Reader`
|
|
1992
|
+
- [Async](../../async.md#the-pollable-protocol) — `Async[A]`, `Pollable`, and what awaiting an asynchronous result means
|