scout-essentials 1.8.8 → 1.9.1
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.
- checksums.yaml +4 -4
- data/.vimproject +26 -12
- data/README.md +83 -112
- data/VERSION +1 -1
- data/doc/Improvements.md +226 -0
- data/doc/StartHere.md +122 -0
- data/doc/developer/AnnotationSystem.md +184 -0
- data/doc/developer/Architecture.md +147 -0
- data/doc/developer/Configuration.md +238 -0
- data/doc/developer/CoreUtilities.md +265 -0
- data/doc/developer/DesignPrinciples.md +129 -0
- data/doc/developer/ErrorHandling.md +203 -0
- data/doc/developer/LockingAndConcurrency.md +157 -0
- data/doc/developer/PathResolution.md +200 -0
- data/doc/developer/PersistenceAndResources.md +119 -0
- data/doc/developer/StreamingModel.md +236 -0
- data/doc/user/AnnotatingData.md +202 -0
- data/doc/user/CachingResults.md +183 -0
- data/doc/user/CommandLineOptions.md +189 -0
- data/doc/user/Cookbook.md +211 -0
- data/doc/user/HandlingStreams.md +236 -0
- data/doc/user/LoggingAndProgress.md +158 -0
- data/doc/user/ProducingResources.md +177 -0
- data/doc/user/RemoteData.md +157 -0
- data/doc/user/RunningCommands.md +218 -0
- data/doc/user/WorkingWithFiles.md +217 -0
- data/lib/scout/cmd.rb +343 -40
- data/lib/scout/concurrent_stream.rb +14 -1
- data/lib/scout/indiferent_hash.rb +1 -1
- data/lib/scout/log/fingerprint.rb +13 -8
- data/lib/scout/log/progress/report.rb +1 -1
- data/lib/scout/log.rb +4 -1
- data/lib/scout/misc/filesystem.rb +2 -2
- data/lib/scout/misc/format.rb +24 -0
- data/lib/scout/named_array.rb +1 -1
- data/lib/scout/open/stream.rb +2 -2
- data/lib/scout/open/util.rb +4 -0
- data/lib/scout/path/find.rb +3 -2
- data/lib/scout/persist.rb +14 -10
- data/research/annotations-data-analysis.md +206 -0
- data/research/behavior-probes.md +1925 -0
- data/research/commands-streaming-analysis.md +272 -0
- data/research/design-philosophy-analysis.md +383 -0
- data/research/doc-audit-findings.md +294 -0
- data/research/ecosystem-attribution.md +118 -0
- data/research/implementation-inventory-core.md +1029 -0
- data/research/implementation-inventory-open.md +417 -0
- data/research/implementation-inventory-path-persist-resource.md +774 -0
- data/research/io-paths-analysis.md +228 -0
- data/research/persistence-resources-analysis.md +244 -0
- data/research/synthesis-report.md +80 -0
- data/scout-essentials.gemspec +37 -15
- data/test/scout/misc/test_filesystem.rb +10 -0
- data/test/scout/test_cmd.rb +411 -0
- metadata +36 -14
- data/doc/Annotation.md +0 -352
- data/doc/CMD.md +0 -363
- data/doc/ConcurrentStream.md +0 -163
- data/doc/IndiferentHash.md +0 -240
- data/doc/Log.md +0 -235
- data/doc/NamedArray.md +0 -174
- data/doc/Open.md +0 -331
- data/doc/Path.md +0 -217
- data/doc/Persist.md +0 -214
- data/doc/Resource.md +0 -229
- data/doc/SimpleOPT.md +0 -236
- data/doc/TmpFile.md +0 -154
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Streaming Model
|
|
2
|
+
|
|
3
|
+
This page documents the streaming model implemented in
|
|
4
|
+
`lib/scout/concurrent_stream.rb` and `lib/scout/open/stream.rb` from the inside:
|
|
5
|
+
how a stream is constructed, how the two lifecycle callbacks compose, how abort
|
|
6
|
+
and join interact, and how errors travel. The user-facing surface is
|
|
7
|
+
[Handling Streams](../user/HandlingStreams.md); the producer side is
|
|
8
|
+
[Running Commands](../user/RunningCommands.md).
|
|
9
|
+
|
|
10
|
+
## The core idea
|
|
11
|
+
|
|
12
|
+
A "stream" here is an ordinary IO object (a pipe read end from a subprocess, a
|
|
13
|
+
`StringIO`, a `File`) extended with the `ConcurrentStream` **module**. The
|
|
14
|
+
module does not replace the IO; it adds bookkeeping about who is producing the
|
|
15
|
+
data and what must happen when the data is finished:
|
|
16
|
+
|
|
17
|
+
- `threads` / `pids` — the producers feeding this end,
|
|
18
|
+
- `callback` / `abort_callback` — what to run on success / on abort,
|
|
19
|
+
- `std_err`, `log`, `exit_status`, `filename`, `lock`, `next`, `pair`.
|
|
20
|
+
|
|
21
|
+
Everything hangs off `ConcurrentStream.setup`, and most of the model is about
|
|
22
|
+
what setup, join and abort do with that state.
|
|
23
|
+
|
|
24
|
+
## `ConcurrentStream.setup` is idempotent and accumulative
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
def self.setup(stream, options = {}, &block) # concurrent_stream.rb:12
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`setup` extends the object unless it already is a `ConcurrentStream`, then:
|
|
31
|
+
|
|
32
|
+
- `threads ||= []`, `pids ||= []`, and new values are **concatenated** onto the
|
|
33
|
+
existing arrays — a second `setup` adds producers, it does not replace them
|
|
34
|
+
(probe `tmp/rewrite_B/probe_22_cs_semantics.rb`: `idempotent setup: cs? true
|
|
35
|
+
threads=1 pids=[4]`, `after 2nd threads arg: 2`).
|
|
36
|
+
- `std_err` is **reset to `""`** on every setup.
|
|
37
|
+
- `autojoin`, `no_fail`, `next`, `pair`, `filename`, `lock` are only assigned
|
|
38
|
+
when the corresponding option is non-nil, so an existing value survives.
|
|
39
|
+
- **The block becomes a callback**: a block passed to `setup` is treated as
|
|
40
|
+
`callback` and *composed* with any existing callback, so the new block runs
|
|
41
|
+
after the old one (`probe_22`: `setup-block callbacks order: [:block, :block2]`).
|
|
42
|
+
|
|
43
|
+
This is why `Open.open_pipe` (thread mode) can call `setup` on both ends and
|
|
44
|
+
still compose a user callback, and why `add_callback` composes the same way.
|
|
45
|
+
|
|
46
|
+
## Callbacks: `callback`, `add_callback`, `abort_callback`
|
|
47
|
+
|
|
48
|
+
There are two callback slots, each holding a single (possibly chained) Proc:
|
|
49
|
+
|
|
50
|
+
- `callback` (a.k.a. the join callback) — run by `join_callback`, which is
|
|
51
|
+
called from `join` and is skipped if the stream is already joined; it is
|
|
52
|
+
cleared (`@callback = nil`) after running so it fires once.
|
|
53
|
+
- `abort_callback` — run by `abort(exception)` with the abort exception, then
|
|
54
|
+
discarded together with `callback`.
|
|
55
|
+
|
|
56
|
+
`ConcurrentStream#add_callback(&block)` wraps the current callback so the new
|
|
57
|
+
block runs **after** the old one — the same composition `setup` uses:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
stream.add_callback { cleanup_a }
|
|
61
|
+
stream.add_callback { cleanup_b } # join runs a, then b
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Verified in `probe_19_streaming_apis.rb` (`add_callback order: [:first,
|
|
65
|
+
:second]`) and `probe_22_cs_semantics.rb`. There is no `add_abort_callback`;
|
|
66
|
+
`abort_callback = proc { |exception| ... }` replaces the slot (only `setup`
|
|
67
|
+
with an `:abort_callback` option composes it).
|
|
68
|
+
|
|
69
|
+
## `join`: threads, pids, callbacks, close — never the pair
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
def join # concurrent_stream.rb:146
|
|
73
|
+
join_threads; join_pids
|
|
74
|
+
raise stream_exception if stream_exception
|
|
75
|
+
join_callback
|
|
76
|
+
close unless closed?
|
|
77
|
+
ensure
|
|
78
|
+
@joined = true
|
|
79
|
+
lock.unlock if lock && lock.locked?
|
|
80
|
+
raise stream_exception if stream_exception
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `join_threads` waits for each producer thread (skipping the current thread).
|
|
85
|
+
If a thread returned a failed `Process::Status` it raises
|
|
86
|
+
`ConcurrentStreamProcessFailed`; a thread that died with an exception is
|
|
87
|
+
routed through `stream_raise_exception` unless `no_fail`.
|
|
88
|
+
- `join_pids` `Process.waitpid`s each pid, records `self.exit_status`, raises
|
|
89
|
+
`ConcurrentStreamProcessFailed` for a bad status unless `no_fail`, and
|
|
90
|
+
**empties `@pids`** — so `exit_status` is only ever accurate right here.
|
|
91
|
+
- `join` never touches `@pair`. The pair is the *other end of the same pipe*
|
|
92
|
+
(see below) and is joined/aborted through `abort` propagation, not through
|
|
93
|
+
`join`.
|
|
94
|
+
|
|
95
|
+
`close` (concurrent_stream.rb:230) is a thin wrapper: with `autojoin` it closes
|
|
96
|
+
and then joins when the stream is at EOF or already closed, converting a close
|
|
97
|
+
failure into `abort` + `join` + `stream_raise_exception`; without `autojoin` it
|
|
98
|
+
just closes, swallowing `IOError`.
|
|
99
|
+
|
|
100
|
+
## `no_fail`
|
|
101
|
+
|
|
102
|
+
`no_fail` silences every join-time failure path:
|
|
103
|
+
|
|
104
|
+
- a producer thread's failed status does not raise
|
|
105
|
+
`ConcurrentStreamProcessFailed`,
|
|
106
|
+
- a dead producer thread is logged at `Log.low` ("Not failing on exception
|
|
107
|
+
joining thread") instead of being escalated,
|
|
108
|
+
- `join_pids` does not raise for a non-zero exit status.
|
|
109
|
+
|
|
110
|
+
`CMD.cmd` propagates `:no_fail` to the stream and **defaults `autojoin` to
|
|
111
|
+
`no_fail`** (`cmd.rb:219`, `:autojoin => no_fail`), which is why a `no_fail`
|
|
112
|
+
pipe that is simply read does not blow up at close time.
|
|
113
|
+
|
|
114
|
+
## `abort`: idempotent, and it propagates to `pair`
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
def abort(exception = nil) # concurrent_stream.rb:204
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`abort` is the deliberate early-stop path. It:
|
|
121
|
+
|
|
122
|
+
1. records `stream_exception ||= exception`,
|
|
123
|
+
2. marks the object with `AbortedStream.setup(self, exception)` and sets
|
|
124
|
+
`@aborted = true`; a second call only logs (`Already aborted stream`) and
|
|
125
|
+
returns — **idempotent** (probe `probe_22_cs_semantics.rb`:
|
|
126
|
+
`abort idempotent: aborted? true`, `second abort: no raise, aborted? true`),
|
|
127
|
+
3. runs `abort_callback` with the exception,
|
|
128
|
+
4. `abort_threads`: raises `Aborted` (or the given exception) in each producer
|
|
129
|
+
thread and joins them — this is what unblocks a producer stuck writing to a
|
|
130
|
+
pipe nobody reads,
|
|
131
|
+
5. `abort_pids`: sends `SIGINT` to each pid,
|
|
132
|
+
6. clears both callbacks,
|
|
133
|
+
7. **propagates to `@pair`** if it responds to `abort` and is not already
|
|
134
|
+
aborted — killing one end of an `Open.open_pipe` pair takes down the other,
|
|
135
|
+
8. closes and unlocks in `ensure`.
|
|
136
|
+
|
|
137
|
+
Producers are expected to `rescue Aborted` and finish quietly; `Open.grep`,
|
|
138
|
+
`consume_stream` and `sensible_write` all do.
|
|
139
|
+
|
|
140
|
+
## `stream_raise_exception`: the failure amplifier
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
def stream_raise_exception(exception) # concurrent_stream.rb:281
|
|
144
|
+
self.stream_exception = exception
|
|
145
|
+
threads.each { |thread| thread.raise exception }
|
|
146
|
+
self.abort
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This is how one failing producer takes the whole stream down: the exception is
|
|
151
|
+
stored in `stream_exception` (so a later `join`/`read` re-raises it even if the
|
|
152
|
+
current frame recovers), it is raised in every producer thread, and the stream
|
|
153
|
+
is aborted. `join_threads` and `join_pids` call it, and `ConcurrentStreamProcessFailed`
|
|
154
|
+
carries the offending `pid` plus the stream. Verified: `probe_22_cs_semantics.rb`
|
|
155
|
+
(`stream_raise_exception raised: demo`, `stream_exception set: #<Aborted: demo>`,
|
|
156
|
+
`no_fail join of failed: no raise`).
|
|
157
|
+
|
|
158
|
+
## `ConcurrentStream.process_stream`
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
def self.process_stream(stream, close: true, join: true, message: "process_stream",
|
|
162
|
+
**kwargs, &block)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The standard producer wrapper (concurrent_stream.rb:286): it sets the stream up
|
|
166
|
+
with `kwargs`, runs the block, and in an `ensure` closes and joins the stream as
|
|
167
|
+
requested. `Aborted` and any other exception are logged, the stream is aborted
|
|
168
|
+
with the exception, and the exception is re-raised. `Open.open_pipe` (thread
|
|
169
|
+
mode) and `Open.sort_stream` are built on it. Verified: `probe_22_cs_semantics.rb`
|
|
170
|
+
(`process_stream: "z\ny\nx\n" src joined=true src closed=true`).
|
|
171
|
+
|
|
172
|
+
## `AbortedStream` and recovering the original cause
|
|
173
|
+
|
|
174
|
+
`AbortedStream` is not an exception class: it is a **marker module** with an
|
|
175
|
+
`exception` accessor (`concurrent_stream.rb:4-9`). `abort` marks the stream with
|
|
176
|
+
it so downstream code can tell "this stream was aborted" apart from "this IO is
|
|
177
|
+
just closed", and can ask what the real cause was:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
content.abort(my_error) # stream.exception == my_error
|
|
181
|
+
...
|
|
182
|
+
exception = (AbortedStream === content and content.exception) ? content.exception : $!
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
That exact snippet is what `Open.sensible_write` uses: when copying a stream
|
|
186
|
+
into its tmp file raises, it recovers the original upstream exception from the
|
|
187
|
+
marker and re-raises *that* (deleting the target and tmp file), while a plain
|
|
188
|
+
`Aborted` is swallowed and cleaned up. Verified by P25/P33 and the
|
|
189
|
+
`probe_22`/`probe_13` sequences in `research/behavior-probes.md`.
|
|
190
|
+
|
|
191
|
+
## `pair` means pipe ends, not stdout/stderr
|
|
192
|
+
|
|
193
|
+
`ConcurrentStream#pair` is set by `Open.open_pipe` (thread mode) on both ends of
|
|
194
|
+
the pipe it just created: `setup(sin, :pair => sout)` and
|
|
195
|
+
`setup(sout, :pair => sin)` (open/stream.rb:230-231). It exists so `abort` on
|
|
196
|
+
one end can propagate to the other. **It has nothing to do with "a paired
|
|
197
|
+
stdout/stderr stream"** — there is no such object in this repo.
|
|
198
|
+
|
|
199
|
+
Likewise, in `CMD.cmd` pipe mode the child's **stderr is drained by a thread**
|
|
200
|
+
(an `err_thread` registered on the returned stream when a severity or
|
|
201
|
+
`save_stderr` asks for it), not by a second stream. Per-stream diagnostics live
|
|
202
|
+
in `std_err` (filled by `:save_stderr`) and `log` (last stderr line); log
|
|
203
|
+
messages themselves go to the Log logfile / STDERR.
|
|
204
|
+
|
|
205
|
+
## `next`
|
|
206
|
+
|
|
207
|
+
`next` is a forward link to the stream that logically follows this one
|
|
208
|
+
(`setup` assigns it from `:next`). It lets a helper hand you a derived stream
|
|
209
|
+
while keeping a pointer at the original, so bookkeeping (`filename`, locks,
|
|
210
|
+
`annotate`) can be traced back along the chain. `ConcurrentStream#annotate`
|
|
211
|
+
copies `threads`, `pids`, `callback`, `abort_callback`, `filename`, `autojoin`
|
|
212
|
+
and `lock` onto another stream — used by `Open.line_monitor_stream` and
|
|
213
|
+
annotation-style code.
|
|
214
|
+
|
|
215
|
+
## Reading between the lines
|
|
216
|
+
|
|
217
|
+
`read` (concurrent_stream.rb:246) wraps `IO#read` so that an exception is
|
|
218
|
+
recorded in `stream_exception`, the stream aborted and the recorded exception
|
|
219
|
+
re-raised; when `autojoin` is set it polls `eof?` and closes/joins once the
|
|
220
|
+
producer is done. This is what makes a `:pipe => true` result behave like a
|
|
221
|
+
plain IO for `.read`/`.each` while still cleaning up its producers.
|
|
222
|
+
|
|
223
|
+
## Where this is used
|
|
224
|
+
|
|
225
|
+
- `CMD.cmd` — `:pipe => true` produces these streams
|
|
226
|
+
([Running Commands](../user/RunningCommands.md)).
|
|
227
|
+
- `Open.open_pipe`, `Open.tee_stream`, `Open.sort_stream`,
|
|
228
|
+
`Open.line_monitor_stream`, `Open.collapse_stream`, `Open.consume_stream`,
|
|
229
|
+
`Open.sensible_write` — the helpers documented in
|
|
230
|
+
[Handling Streams](../user/HandlingStreams.md).
|
|
231
|
+
- `Open.grep` composes `CMD.cmd` + `:post`; its `force_close` call is a dead
|
|
232
|
+
`respond_to?` guard (no such method exists in this repo).
|
|
233
|
+
- For the module dependency graph, see
|
|
234
|
+
[Architecture](Architecture.md#modules-and-dependencies); for cross-repo
|
|
235
|
+
concepts (TSV, Step, Workflow, HPC) see the
|
|
236
|
+
[attribution table](Architecture.md#ecosystem-boundaries-and-attribution).
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Annotating Data
|
|
2
|
+
|
|
3
|
+
Annotations attach named metadata — an organism, a provenance URL, a
|
|
4
|
+
"kind" of file — to ordinary Ruby objects (Strings, Arrays, Hashes) without
|
|
5
|
+
changing their class or wrapping them. This page is the user-facing view of the
|
|
6
|
+
system; internals and design notes are in
|
|
7
|
+
[Annotation System](../developer/AnnotationSystem.md).
|
|
8
|
+
|
|
9
|
+
Every example below was run against the current `lib/scout/` (probes
|
|
10
|
+
P36–P42 in `research/behavior-probes.md` plus
|
|
11
|
+
`tmp/rewrite_C/probe_01..04.rb` and `probe_09_annotated_array.rb`).
|
|
12
|
+
|
|
13
|
+
## Defining an annotation
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
module SampleInfo
|
|
17
|
+
extend Annotation
|
|
18
|
+
annotation :organism, :tissue, :donor
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sample = SampleInfo.setup('S003', organism: 'Human', tissue: 'Liver')
|
|
22
|
+
|
|
23
|
+
sample # => "S003" (still a String)
|
|
24
|
+
sample.organism # => "Human"
|
|
25
|
+
sample.tissue # => "Liver"
|
|
26
|
+
sample.is_a?(String) # => true
|
|
27
|
+
sample.is_a?(SampleInfo) # => true (the module really was extended in)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`require 'scout-essentials'` is enough: `Annotation` arrives via
|
|
31
|
+
`scout/path` (`lib/scout/path.rb:1`).
|
|
32
|
+
|
|
33
|
+
## The object you get back
|
|
34
|
+
|
|
35
|
+
`setup` **extends the object you pass, in place**, and returns that same
|
|
36
|
+
object — the call is an annotation, not a conversion:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
name = 'S003'
|
|
40
|
+
annotated = SampleInfo.setup(name, organism: 'Human')
|
|
41
|
+
annotated.equal?(name) # => true
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Two consequences, both live-probed (probe_02):
|
|
45
|
+
|
|
46
|
+
- a **frozen** object is `dup`ed first, so `setup` returns a different,
|
|
47
|
+
unfrozen copy — always use the return value;
|
|
48
|
+
- `dup` of an annotated object **loses the annotations** (`SampleInfo ===
|
|
49
|
+
sample.dup => false`); `clone` keeps them (`SampleInfo === sample.clone =>
|
|
50
|
+
true` — `clone` copies the singleton class, `dup` does not). To copy
|
|
51
|
+
metadata onto a `dup` explicitly:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
copy = SampleInfo.setup(sample.dup, sample.annotation_hash)
|
|
55
|
+
# or: sample.annotate(other_object)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`obj.dup` on an `Integer`-like target is different: `setup` rescues the
|
|
59
|
+
`TypeError: can't define singleton` and hands back the plain, un-annotated
|
|
60
|
+
object — nothing raises.
|
|
61
|
+
|
|
62
|
+
## Introspection
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
sample.annotation_types # => [SampleInfo] (module objects, not names)
|
|
66
|
+
sample.annotation_types.include?(SampleInfo) # => true
|
|
67
|
+
|
|
68
|
+
SampleInfo.annotations # => [:organism, :tissue, :donor] (module state)
|
|
69
|
+
|
|
70
|
+
sample.annotation_hash # => {:organism=>"Human", :tissue=>"Liver"}
|
|
71
|
+
sample.annotation_info # => {..same.., :annotation_types=>[SampleInfo],
|
|
72
|
+
# :annotated_array=>false}
|
|
73
|
+
sample.annotation_id # => "860c73f490edb8115064663b7f579d73"
|
|
74
|
+
Annotation.is_annotated?(sample) # => true
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Note where each thing lives: `annotation_types` is on the **object**; the
|
|
78
|
+
declared attribute list is read from the **module** with `.annotations`
|
|
79
|
+
(there is no `ANNOTATIONS` constant).
|
|
80
|
+
|
|
81
|
+
### Round-trip
|
|
82
|
+
|
|
83
|
+
`annotation_hash` plus `Annotation.setup` is the serialisation pair:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
info = sample.annotation_info
|
|
87
|
+
Annotation.setup('S003', 'SampleInfo', sample.annotation_hash)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The type argument may be a `"A|B"` String of module names (unknown names are
|
|
91
|
+
only `Log.warn`ed and skipped, probe_01) or an Array of module objects.
|
|
92
|
+
|
|
93
|
+
`#serialize` produces the plain Hash (`annotation_info` merged with
|
|
94
|
+
`:literal`) consumed by the `:annotation` persistence driver. There is **no
|
|
95
|
+
TSV serialisation of annotations in this repo** — `Annotation.tsv` /
|
|
96
|
+
`Annotation.load_tsv` live in scout-gear (see the attribution table in
|
|
97
|
+
[Architecture](../developer/Architecture.md)).
|
|
98
|
+
|
|
99
|
+
Nested values you store are left alone: hashes stay plain `Hash`, they are
|
|
100
|
+
**not** converted to `IndiferentHash`, and `deep_indifferent` does not exist
|
|
101
|
+
in this gem.
|
|
102
|
+
|
|
103
|
+
## NamedArray — field names over Array positions
|
|
104
|
+
|
|
105
|
+
`NamedArray` (`lib/scout/named_array.rb`) is a separate annotation module
|
|
106
|
+
that gives an Array named fields. It needs its **explicit**
|
|
107
|
+
`require 'scout/named_array'` — `scout-essentials.rb` does not load it
|
|
108
|
+
(probe_03):
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
require 'scout/named_array'
|
|
112
|
+
|
|
113
|
+
row = NamedArray.setup(%w[S003 Human Liver], %w[id organism tissue])
|
|
114
|
+
row.organism # => "Human" (method_missing access)
|
|
115
|
+
row[:organism] # => "Human"
|
|
116
|
+
row['tissue'] # => "Liver"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Signatures (probe_04): `NamedArray.setup(array, names, *rest)` — the names are
|
|
120
|
+
a positional Array, **not** a `key:` keyword.
|
|
121
|
+
|
|
122
|
+
### Access is via `method_missing`
|
|
123
|
+
|
|
124
|
+
Field accessors are *not* real methods, so they do not participate in the
|
|
125
|
+
usual introspection:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
row.respond_to?(:organism) # => false
|
|
129
|
+
row.methods.include?(:organism) # => false
|
|
130
|
+
row.organism # => "Human" (still works)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`:[]` accepts a Symbol or a String and resolves through the field list
|
|
134
|
+
(`identify_name`), so a field named like an Array method can still be reached
|
|
135
|
+
positionally by name.
|
|
136
|
+
|
|
137
|
+
### Array methods shadow field names
|
|
138
|
+
|
|
139
|
+
If a field is called `first`, `last`, `count`, `zip`, `sample` … the real
|
|
140
|
+
`Array` method wins (probe_10):
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
row2 = NamedArray.setup(%w[a b c], %w[first second third])
|
|
144
|
+
row2.first # => "a" (Array#first, not the field)
|
|
145
|
+
row2.first(2) # => ["a", "b"]
|
|
146
|
+
|
|
147
|
+
row3 = NamedArray.setup(%w[S001 S002 S003], %w[values count])
|
|
148
|
+
row3.values # => "S001" (field: Array has no #values)
|
|
149
|
+
row3.count # => 3 (Array#count wins, not the field)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Note `values` — unlike `first`/`count` — is **not** an `Array` method, so the
|
|
153
|
+
field accessor still works; `count` is real and wins. Prefer field names that
|
|
154
|
+
are not `Array`/`Enumerable` verbs, or use `row[:name]` for ambiguous ones.
|
|
155
|
+
|
|
156
|
+
`to_hash` (only available on `NamedArray`) returns an `IndiferentHash` of
|
|
157
|
+
field → value.
|
|
158
|
+
|
|
159
|
+
Watch out for `id` — `annotation_id` (aliased `id`) is defined by the
|
|
160
|
+
annotation system itself, so a field named `id` collides and the digest is
|
|
161
|
+
returned instead (probe_10).
|
|
162
|
+
|
|
163
|
+
## `AnnotatedArray` — elements inherit the container's annotations
|
|
164
|
+
|
|
165
|
+
Annotate the *container*, then `extend AnnotatedArray`, and every element
|
|
166
|
+
handed out by `[]`, `first`, `last`, `each`, `collect`, `select`, `compact`,
|
|
167
|
+
`uniq`, `flatten`, `reverse`, `sort_by`, `subset`, `remove` is re-annotated
|
|
168
|
+
(probe_09):
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
samples = SampleInfo.setup(%w[S001 S002 S003], organism: 'Human')
|
|
172
|
+
samples.extend AnnotatedArray
|
|
173
|
+
|
|
174
|
+
samples[0].organism # => "Human"
|
|
175
|
+
samples.each { |s| s.organism }
|
|
176
|
+
samples.collect { |s| s.length } # [4, 4, 4], elements annotated
|
|
177
|
+
samples.select { |s| s != 'S002' } # re-annotated array
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Not overridden — **annotations are dropped** (probe_09, method owners are
|
|
181
|
+
`Array`/`Enumerable`): `map`, `zip`, `+`, `filter_map`, `flat_map`,
|
|
182
|
+
`each_slice`, `values_at`. `zip` in particular does not propagate annotations
|
|
183
|
+
to the *other* operand's elements.
|
|
184
|
+
|
|
185
|
+
`[index]` re-annotates; `[index, true]` (the clean second argument) returns the
|
|
186
|
+
raw element with no annotations (`lib/scout/annotation/array.rb:21-25`).
|
|
187
|
+
|
|
188
|
+
Two hard limits:
|
|
189
|
+
|
|
190
|
+
- elements must be extendable: an Array of `Integer`s raises `TypeError: can't
|
|
191
|
+
define singleton`;
|
|
192
|
+
- `#make_array` does **not** annotate the elements — it wraps `self` in a
|
|
193
|
+
one-element annotated Array (see
|
|
194
|
+
[Annotation System](../developer/AnnotationSystem.md)).
|
|
195
|
+
|
|
196
|
+
## Related
|
|
197
|
+
|
|
198
|
+
- [Annotation System](../developer/AnnotationSystem.md) — internals, limits,
|
|
199
|
+
serialisation drivers.
|
|
200
|
+
- [Path Resolution](../developer/PathResolution.md) — `Path` is an annotated
|
|
201
|
+
module.
|
|
202
|
+
- [Caching Results](CachingResults.md) — the `:annotation` persistence type.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Caching Results
|
|
2
|
+
|
|
3
|
+
This guide explains how to cache computation results in scout-essentials
|
|
4
|
+
using the `Persist` module: the `persist` pattern, serialization types,
|
|
5
|
+
staleness invalidation, in-memory caching, and locking.
|
|
6
|
+
|
|
7
|
+
## The persist pattern
|
|
8
|
+
|
|
9
|
+
`Persist.persist` runs a block once and reuses the cached result on later
|
|
10
|
+
calls:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require 'scout-essentials'
|
|
14
|
+
|
|
15
|
+
value = Persist.persist('result', :string) do
|
|
16
|
+
"expensive computation"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
value # => "expensive computation" (first call: block runs)
|
|
20
|
+
value # => "expensive computation" (second call: loaded from disk)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The signature is `Persist.persist(name, type = :serializer, options = {}, &block)`:
|
|
24
|
+
|
|
25
|
+
- `name` — a string used to build the cache file name (a path is also
|
|
26
|
+
accepted; its `filename` is used). Unless you give `:path`/`:persist_path`,
|
|
27
|
+
the cache lands under `Persist.cache_dir`, which returns the relative
|
|
28
|
+
`Path`/String `var/cache/persistence`.
|
|
29
|
+
- `type` — a serialization type (table below); `:serializer` is the default
|
|
30
|
+
and resolves to `:json` (`Persist::SERIALIZER == :json`).
|
|
31
|
+
- `options` — a plain `Hash` of options. `persist_path` takes an options
|
|
32
|
+
hash, and a **second positional argument is NOT supported**: the option key
|
|
33
|
+
is `:persist_path` (or `:path`), never a positional argument.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
Persist.persistence_path('result') # => "var/cache/persistence/result"
|
|
37
|
+
Persist.persistence_path('result', key: 'X') # => "var/cache/persistence/result[X]"
|
|
38
|
+
Persist.persistence_path('result', :marshal) # TypeError — no positional type
|
|
39
|
+
Persist.persistence_path('result', dir: tmp_dir) # honoured via :dir
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Options:**
|
|
43
|
+
|
|
44
|
+
| Option | Meaning |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `:persist_path` / `:path` | Exact file to use as cache (String or Path) |
|
|
47
|
+
| `:persist` | `false` bypasses persistence entirely and just returns `yield` |
|
|
48
|
+
| `:no_load` | `true` returns the cache file itself instead of its contents |
|
|
49
|
+
| `:update` | Force recomputation. A `true` recomputes; a `Time`/number recomputes when the cache is older; a `Path` uses that file's `mtime` |
|
|
50
|
+
| `:check` | Array of dependency paths; the cache is invalidated when any is newer |
|
|
51
|
+
| `:canfail` | Swallow errors, returning `nil` (or the file with `:no_load`) instead of raising |
|
|
52
|
+
| `:data` | Passed as the block argument when the block has arity 1 |
|
|
53
|
+
| `:tee_copies` | Number of extra stream copies when the block returns a stream |
|
|
54
|
+
| `:lockfile` | Use a specific lock file instead of the default |
|
|
55
|
+
|
|
56
|
+
If the block has arity 1, it receives the cache file (a `Path`/String), and
|
|
57
|
+
its return value is loaded from disk only if it is `nil`:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Persist.persist('result', :text, persist_path: path) do |file|
|
|
61
|
+
Open.write(file, "written by block\n")
|
|
62
|
+
end # => loads the file
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Serialization types
|
|
66
|
+
|
|
67
|
+
`Persist.serialize` / `Persist.deserialize` / `Persist.save` / `Persist.load`
|
|
68
|
+
understand these types (`type` is `nil, :string, :text, :integer, :float,
|
|
69
|
+
:boolean, :file, :path, :select, :folder, :binary, :array, :yaml, :json,
|
|
70
|
+
:marshal, :annotation, :serializer`, plus `:stream` on load):
|
|
71
|
+
|
|
72
|
+
| Type | Saved as | Loaded as |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `nil`, `:text` | `to_s` | String, exactly as written (no stripping) |
|
|
75
|
+
| `:string` | `to_s` | String, stripped |
|
|
76
|
+
| `:integer`, `:float` | `to_s` | `Integer` / `Float` |
|
|
77
|
+
| `:boolean` | `to_s` | `true` only if in `TRUE_STRINGS` (`"true"`, `"yes"`, `"y"`, `"t"`, `"on"` and their case variants, `"1"`) |
|
|
78
|
+
| `:file`, `:folder`, `:select` | `to_s` | Stripped String (a `:file` entry starting with `"./"` is resolved relative to the cache file's directory) |
|
|
79
|
+
| `:path` | `to_s` | `Path.setup(...)` |
|
|
80
|
+
| `:binary` | bytes (encoding forced to ASCII-8BIT) | bytes read with `mode: 'rb'` |
|
|
81
|
+
| `:array` | elements joined with `"\n"` | Array of lines |
|
|
82
|
+
| `:yaml` | `to_yaml` | Loaded with `Open.yaml` (`YAML.unsafe_load` — `Open.yaml` is defined in the persist layer, `lib/scout/persist/open.rb:11`), so hashes/arrays round-trip. Note `:yaml_array` goes through the per-line `deserialize` path (`YAML.parse`) and yields `Psych::Nodes::Document` objects — use `:json_array` for array round-trips |
|
|
83
|
+
| `:json` | `to_json` | `JSON.parse` |
|
|
84
|
+
| `:marshal` | `Marshal.dump` | `Marshal.load` |
|
|
85
|
+
| `:annotation` | `Annotation.tsv(content, :all).to_s` | `Annotation.load_tsv(TSV.open(...))` (part of the scout-gear ecosystem, not usable standalone) |
|
|
86
|
+
| `:serializer` | alias for `:json` | alias for `:json` |
|
|
87
|
+
| `:stream` (load only) | — | `Open.open(file)` |
|
|
88
|
+
| `:file_array` (load only) | — | Array of files, `"./"`-relative entries resolved |
|
|
89
|
+
|
|
90
|
+
Any type can take an `_array` suffix (`:yaml_array`, `:path_array`, …) when
|
|
91
|
+
saving or loading: elements are serialized individually and joined/split on
|
|
92
|
+
newlines. **No type suffix is ever appended to cache file names** — two
|
|
93
|
+
different types sharing a `name` collide on the same cache file. An unknown
|
|
94
|
+
type raises `RuntimeError: Persist does not know <type>`.
|
|
95
|
+
|
|
96
|
+
## Cache location and lock location
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
Persist.cache_dir # => var/cache/persistence (a relative String path)
|
|
100
|
+
Persist.cache_dir = '/some/other/dir'
|
|
101
|
+
Persist.lock_dir # => $HOME/.scout/tmp/persist_locks (an absolute String)
|
|
102
|
+
Persist.lock_dir = '/some/other/locks'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Locks live under `Persist.lock_dir` and are named after the cache file plus
|
|
106
|
+
`.persist`: `<cache-file>.persist`. They are `Open.lock` lockfiles (see
|
|
107
|
+
[Working with Files](WorkingWithFiles.md)); there is **no** `Persist.lock` —
|
|
108
|
+
`Persist.persist` uses `Open.lock` internally.
|
|
109
|
+
|
|
110
|
+
## Staleness: `:update` and `:check`
|
|
111
|
+
|
|
112
|
+
`:check` lists dependencies; when any of them is newer than the cache, the
|
|
113
|
+
cache is recomputed. `:update` forces recomputation, optionally guarded by a
|
|
114
|
+
`Time` (numeric age in seconds) or a `Path` (an mtime to compare against):
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
cache = Path.setup('var/cache/persistence/example')
|
|
118
|
+
|
|
119
|
+
Persist.persist('example', :string, persist_path: cache, check: [input]) do
|
|
120
|
+
"computed"
|
|
121
|
+
end
|
|
122
|
+
# input updated -> "computed" re-runs
|
|
123
|
+
|
|
124
|
+
Persist.persist('example', :string, persist_path: cache, update: 60) { "x" }
|
|
125
|
+
# re-runs only if the cache is older than 60 seconds
|
|
126
|
+
|
|
127
|
+
Persist.persist('example', :string, persist_path: cache, update: dep_path) { "x" }
|
|
128
|
+
# re-runs only if dep_path's mtime is newer than the cache's
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`:check` and `:update` (and the `update` computation itself) depend on
|
|
132
|
+
`Open.mtime` and on `file.outdated?(check)`, which are `Path` methods; give
|
|
133
|
+
`persist` a `Path` (`persist_path:` a `Path.setup(...)`), not a plain
|
|
134
|
+
`String`, when you rely on them.
|
|
135
|
+
|
|
136
|
+
## Memory caching
|
|
137
|
+
|
|
138
|
+
Type `:memory` stores the block result in a process-global hash
|
|
139
|
+
(`Persist::MEMORY_CACHE`) instead of a file; a custom repo hash can be passed
|
|
140
|
+
with `:memory:` / `:repo:`:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
Persist.persist('m', :memory) { [1, 2] } # => [1, 2]
|
|
144
|
+
|
|
145
|
+
Persist.memory('m2', key: 'K') { "in-memory-value" }
|
|
146
|
+
# => "in-memory-value"; the key is composed into the entry name
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`Persist.memory(name, options, &block)` is the helper for the common
|
|
150
|
+
`[name, key]` case.
|
|
151
|
+
|
|
152
|
+
## Streams and `KeepLocked`
|
|
153
|
+
|
|
154
|
+
When the block returns an `IO`/`StringIO`, `persist` does not block: it
|
|
155
|
+
tee's the stream so the caller consumes one copy while a background thread
|
|
156
|
+
writes another to the cache file. The returned stream keeps the persist lock
|
|
157
|
+
until it is joined (`KeepLocked`); join the stream (or let `autojoin` run)
|
|
158
|
+
before relying on the cache file.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
stream = Open.open_pipe { |sin| 10.times { |i| sin.puts "row#{i}" } }
|
|
162
|
+
res = Persist.persist('rows', :string, persist_path: path) { stream }
|
|
163
|
+
res # => a ConcurrentStream (IO); the cache file is complete once res.join
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Error handling
|
|
167
|
+
|
|
168
|
+
If the block raises, `persist` removes the partial cache file (unless the
|
|
169
|
+
exception is a `DontPersist`) and re-raises — **unless** `:canfail` is set,
|
|
170
|
+
in which case it returns `nil` (or the file with `:no_load`):
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
Persist.persist('failing', :string, canfail: true) { raise "boom" } # => nil
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Related
|
|
177
|
+
|
|
178
|
+
- [Working with Files](WorkingWithFiles.md) — `Open.lock`, `sensible_write`,
|
|
179
|
+
and I/O that `Persist` builds on.
|
|
180
|
+
- [Producing Resources](ProducingResources.md) — Resource composes with
|
|
181
|
+
Persist.
|
|
182
|
+
- For internal implementation details, see
|
|
183
|
+
[Persistence and Resources](../developer/PersistenceAndResources.md).
|