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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +26 -12
  3. data/README.md +83 -112
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +226 -0
  6. data/doc/StartHere.md +122 -0
  7. data/doc/developer/AnnotationSystem.md +184 -0
  8. data/doc/developer/Architecture.md +147 -0
  9. data/doc/developer/Configuration.md +238 -0
  10. data/doc/developer/CoreUtilities.md +265 -0
  11. data/doc/developer/DesignPrinciples.md +129 -0
  12. data/doc/developer/ErrorHandling.md +203 -0
  13. data/doc/developer/LockingAndConcurrency.md +157 -0
  14. data/doc/developer/PathResolution.md +200 -0
  15. data/doc/developer/PersistenceAndResources.md +119 -0
  16. data/doc/developer/StreamingModel.md +236 -0
  17. data/doc/user/AnnotatingData.md +202 -0
  18. data/doc/user/CachingResults.md +183 -0
  19. data/doc/user/CommandLineOptions.md +189 -0
  20. data/doc/user/Cookbook.md +211 -0
  21. data/doc/user/HandlingStreams.md +236 -0
  22. data/doc/user/LoggingAndProgress.md +158 -0
  23. data/doc/user/ProducingResources.md +177 -0
  24. data/doc/user/RemoteData.md +157 -0
  25. data/doc/user/RunningCommands.md +218 -0
  26. data/doc/user/WorkingWithFiles.md +217 -0
  27. data/lib/scout/cmd.rb +343 -40
  28. data/lib/scout/concurrent_stream.rb +14 -1
  29. data/lib/scout/indiferent_hash.rb +1 -1
  30. data/lib/scout/log/fingerprint.rb +13 -8
  31. data/lib/scout/log/progress/report.rb +1 -1
  32. data/lib/scout/log.rb +4 -1
  33. data/lib/scout/misc/filesystem.rb +2 -2
  34. data/lib/scout/misc/format.rb +24 -0
  35. data/lib/scout/named_array.rb +1 -1
  36. data/lib/scout/open/stream.rb +2 -2
  37. data/lib/scout/open/util.rb +4 -0
  38. data/lib/scout/path/find.rb +3 -2
  39. data/lib/scout/persist.rb +14 -10
  40. data/research/annotations-data-analysis.md +206 -0
  41. data/research/behavior-probes.md +1925 -0
  42. data/research/commands-streaming-analysis.md +272 -0
  43. data/research/design-philosophy-analysis.md +383 -0
  44. data/research/doc-audit-findings.md +294 -0
  45. data/research/ecosystem-attribution.md +118 -0
  46. data/research/implementation-inventory-core.md +1029 -0
  47. data/research/implementation-inventory-open.md +417 -0
  48. data/research/implementation-inventory-path-persist-resource.md +774 -0
  49. data/research/io-paths-analysis.md +228 -0
  50. data/research/persistence-resources-analysis.md +244 -0
  51. data/research/synthesis-report.md +80 -0
  52. data/scout-essentials.gemspec +37 -15
  53. data/test/scout/misc/test_filesystem.rb +10 -0
  54. data/test/scout/test_cmd.rb +411 -0
  55. metadata +36 -14
  56. data/doc/Annotation.md +0 -352
  57. data/doc/CMD.md +0 -363
  58. data/doc/ConcurrentStream.md +0 -163
  59. data/doc/IndiferentHash.md +0 -240
  60. data/doc/Log.md +0 -235
  61. data/doc/NamedArray.md +0 -174
  62. data/doc/Open.md +0 -331
  63. data/doc/Path.md +0 -217
  64. data/doc/Persist.md +0 -214
  65. data/doc/Resource.md +0 -229
  66. data/doc/SimpleOPT.md +0 -236
  67. 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).