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,265 @@
1
+ # Core Utilities
2
+
3
+ The small modules everything else in the stack is built on:
4
+ `IndiferentHash`, `NamedArray`, the `Misc.format` family, `TmpFile` and
5
+ `Hook`. Each is tiny, each is load-bearing. Sources:
6
+ `lib/scout/indiferent_hash.rb` + `lib/scout/indiferent_hash/{options,serialize,case_insensitive}.rb`,
7
+ `lib/scout/named_array.rb`, `lib/scout/misc/format.rb`,
8
+ `lib/scout/tmpfile.rb`, `lib/scout/misc/hook.rb`.
9
+
10
+ ## IndiferentHash
11
+
12
+ `IndiferentHash.setup(hash)` does not create a class; it extends a plain
13
+ `Hash` with the module, so an IndiferentHash IS an Array/Hash of its base
14
+ type and every `Hash` operation still works. Reads and writes are
15
+ indifferent between String and Symbol keys.
16
+
17
+ Verified by `tmp/rewrite_D/probe_06_indiferent_misc.rb`:
18
+
19
+ ```ruby
20
+ h = IndiferentHash.setup({ 'a' => 1 })
21
+ h[:b] = 2
22
+ h[:a] # => 1
23
+ h['b'] # => 2
24
+ h.keys # => ["a", "b"] (first-written form is kept)
25
+ ```
26
+
27
+ - **`slice`** — `{'a'=>1,'b'=>2}.slice(:a)` => `{"a"=>1}`
28
+ - **`merge` keeps existing keys, and keeps the FIRST key's form**:
29
+ `{'a'=>1,'b'=>2}.merge('a'=>9,'c'=>3).keys` => `["b","a","c"]`; with a
30
+ duplicate `{'k1'=>1, :k1=>2}` both keys survive and `[:k1]` returns `2`.
31
+ - **`clean_version`** — a fresh copy without the module's bookkeeping:
32
+ `{'a'=>1,'b'=>2}`.
33
+ - **`dig`** — module-level `IndiferentHash.dig(h, :x, :y)` works
34
+ symbol-first on a String-keyed hash => `1`.
35
+ - `add_defaults(options, defaults)` only fills missing keys:
36
+ `add_defaults({:a=>1}, {:b=>2})` => `{:a=>1, :b=>2}`.
37
+
38
+ ### The destructive pair
39
+
40
+ `process_options(hash, *keys)` **destroys** the keys it extracts — it
41
+ `delete`s them (verified: `process_options(opts, :a, :b)` leaves
42
+ `{:extra=>3}`). `pull_keys(hash, :prefix)` returns the sub-hash under that
43
+ prefix key AND removes it from the original
44
+ (`pull_keys({persist: true, x: 1}, :persist)` => `{:persist=>true}`, hash
45
+ left with `{:x=>1}`).
46
+
47
+ ### `string2hash` / `parse_options`
48
+
49
+ `string2hash(string, sep="#")` splits on `sep`, then per pair applies, in
50
+ order: bare key => `true`; `:sym` values; `/regex/`; quoted strings;
51
+ `Integer`; `Float`; `"true"`; else the raw String
52
+ (`lib/scout/indiferent_hash/options.rb:96-114`). Note `"false"` is *not*
53
+ in the coercion list — see the asymmetry below.
54
+
55
+ `parse_options(str)` scans `key=value` pairs on whitespace
56
+ (`/\w+=(\"[^\"]*\"|[^\s\"]+)/`), strips surrounding quotes, and splits a
57
+ value containing a comma into an Array. Its coercion ladder is shorter than
58
+ `string2hash`'s: `true`/`false` are **not** special-cased at all, so both
59
+ survive as Strings (verified `tmp/rewrite_D/probe_20_parse_options_false.rb`:
60
+ `parse_options('a=false')` => `{"a"=>"false"}`,
61
+ `parse_options('a=1,b=2')` => `{"a"=>["1", "b=2"]}` — the comma split eats
62
+ the second pair — and `parse_options('a="x y"')` => `{"a"=>"x y"}`).
63
+ `print_options(options)` is the inverse, re-quoting values with spaces.
64
+
65
+ **Known asymmetry (bug candidate).** In `string2hash`, the `== "false"`
66
+ branch (`options.rb:110`) is dead code for the plain case: `a=false` goes
67
+ through `options[key] = value` and the String survives. The only way the
68
+ branch is reached is when `false` is the *value* of a pair that the default
69
+ `#` separator has already split off — and the executed result is still
70
+ `"false"` in every probed form (`tmp/rewrite_D/probe_21_s2h_false_traced.rb`).
71
+ `parse_options` has no boolean branches at all. `Scout::Config.get`, by
72
+ contrast, turns a stored `'false'` into the boolean `false` (see
73
+ [Configuration.md](Configuration.md)). Verified by
74
+ `tmp/rewrite_D/probe_19_false_coercion.rb` and `probe_20_parse_options_false.rb`:
75
+
76
+ ```text
77
+ string2hash('a=true') => {"a"=>true}
78
+ string2hash('a=false') => {"a"=>"false"}
79
+ string2hash('a=false#b=1') => {"a"=>"false", "b"=>1}
80
+ parse_options('a=true') => {"a"=>true}
81
+ parse_options('a=false') => {"a"=>"false"}
82
+ Scout::Config.get('fc') => false (set from the String 'false')
83
+ ```
84
+
85
+ So a boolean-looking option that arrives through a string carries no
86
+ guarantee of being a boolean: check the specific path, and treat values
87
+ coming out of `parse_options` as Strings unless you coerce them yourself.
88
+ (`string2hash` also expects `#`-separated pairs; `'a=false b=1'` is a
89
+ single pair because the separator never appears — the value is the literal
90
+ `"false b=1"`.)
91
+
92
+ ### `serializable`
93
+
94
+ `IndiferentHash.serializable(obj)` (`serialize.rb`) returns a deep copy of
95
+ Hash/Array structures. Arrays longer than 100 are truncated: first 70 +
96
+ `'...'` + last 30 + the marker `"TRUNCATED only 100 out of N shown"`
97
+ (probe_06: 250-element array => 102 elements, marker last). Use it for
98
+ logging fingerprints, not for round-tripping data.
99
+
100
+ ### CaseInsensitiveHash
101
+
102
+ `lib/scout/indiferent_hash/case_insensitive.rb` — **not auto-loaded**;
103
+ `require 'scout/indiferent_hash/case_insensitive'` first. Only `[]` and
104
+ `values_at` are overridden; writes are plain `Hash` writes. Reads are
105
+ downcase-mapped against the *first* case seen for each key, so a Symbol
106
+ key can never be found by a String lookup (Symbol has no meaningful
107
+ downcase mapping in `downcase_keys`). Verified by
108
+ `tmp/rewrite_D/probe_24_cihash_writes.rb`:
109
+
110
+ ```text
111
+ ci = CaseInsensitiveHash.setup({"Key" => 1})
112
+ ci["Other"] = 5 keys => ["Key", "Other"]
113
+ ci["other"], ci["OTHER"] => 5, 5 (case-insensitive read)
114
+ ci[:third] = 7 keys => ["Key", "Other", :third]
115
+ ci["third"] => nil (String lookup misses a Symbol key)
116
+ ci[:third] => 7
117
+ ```
118
+
119
+ Treat it as a read-side convenience for String-keyed hashes; it is not an
120
+ indifferent-access container.
121
+
122
+ ## NamedArray
123
+
124
+ `require 'scout/named_array'` (also **not** auto-required by the gem root).
125
+ `NamedArray.setup(array, fields, key)` extends an ordinary Array — it is
126
+ the `Annotation` module applied to Arrays, **not** a String type, and it
127
+ does **not** extend `AnnotatedArray` (that is for annotated Strings; see
128
+ [AnnotationSystem.md](AnnotationSystem.md)).
129
+
130
+ ```ruby
131
+ na = NamedArray.setup([1,2,3], [:first, :second, :third], 'mykey')
132
+ na.class # => Array
133
+ na.first # => 1 (Array#first shadows nothing here)
134
+ na[:first] # => 1
135
+ na['first'] # => 1
136
+ na.second # => 2 (method_missing field accessor)
137
+ na.respond_to?(:second) # => false (fields are not real methods)
138
+ na.zip([4,5,6]) # => [[1,4],[2,5],[3,6]] (Array#zip wins)
139
+ na.count # => 3
140
+ ```
141
+
142
+ So `count`/`first`/`last`/`zip` are Array's, and any field that happens to
143
+ be named like an Array method is unreachable through the accessor. The
144
+ `key` is exposed via `all_fields` (`[key, fields].compact.flatten`).
145
+
146
+ ## Misc.format family
147
+
148
+ All from `lib/scout/misc/format.rb`, verified by probe_06/probe_15:
149
+
150
+ | call | result |
151
+ |---|---|
152
+ | `Misc.snake_case('FooBar')` | `"foo_bar"` |
153
+ | `Misc.camel_case('foo_bar')` | `"FooBar"` |
154
+ | `Misc.humanize('foo_bar')` | `"Foo bar"` |
155
+ | `Misc.human_number(1234567)` | `"1.2M"` |
156
+ | `Misc.format_paragraph(text, 30)` | re-wraps to the given width |
157
+ | `Misc.format_definition_list([[dt, dd]])` | aligned dt/dd block |
158
+
159
+ ### `Misc.timespan` takes ONE unit per token
160
+
161
+ `timespan(str, default="s")` (`lib/scout/misc/format.rb:279`) supports the
162
+ unit tokens `s sec m min '' ' h d w mo y`, `HH:MM[:SS]` clock strings, and a
163
+ leading `-` for negatives. The parser is `str.scan(/(\d+)(\w*)/)` and `\w*`
164
+ is greedy, so **each number may be followed by only one unit token**.
165
+ `"1h30m"` is scanned as the single pair `["1", "h30m"]`; `"h30m"` is not in
166
+ the token table, so the product becomes `1 * nil` and raises
167
+ `TypeError: nil can't be coerced into Integer`. Verified by probe_06 and
168
+ `tmp/rewrite_D/probe_16_timespan_exact.rb`:
169
+
170
+ ```text
171
+ Misc.timespan('1h') => 3600
172
+ Misc.timespan('1d') => 86400
173
+ Misc.timespan('2w') => 1209600
174
+ Misc.timespan('3mo') => 8035200
175
+ Misc.timespan('1y') => 31536000
176
+ Misc.timespan('1:30') => 90 (HH:MM clock form)
177
+ Misc.timespan('-1h') => -3600
178
+ Misc.timespan('1h30m') => TypeError: nil can't be coerced into Integer
179
+ Misc.timespan('1x') => TypeError: nil can't be coerced into Integer
180
+ Misc.timespan('2') => 2 (bare number uses the default unit, seconds)
181
+ ```
182
+
183
+ So: one number, one unit, per token; combine durations with `HH:MM:SS` or
184
+ add the seconds yourself.
185
+
186
+ ## Misc.digest and `file_md5`
187
+
188
+ `Misc.digest(obj)` produces a 32-char MD5 hex (`Misc.digest('x')` =>
189
+ `"9dd4e461268c8034f5c8564e155c67a6"`). `Misc.file_md5(path)` hashes the
190
+ file **contents**; when the digest falls back to a plain `Misc.digest` on a
191
+ path string, it hashes the **path string**, not the content — a missing
192
+ `/nope/x` still yields a digest (`14470bb0...`) instead of raising
193
+ (probe_06). Check `File.exist?` yourself if that distinction matters.
194
+
195
+ ## `Misc.insist`
196
+
197
+ See [ErrorHandling.md](ErrorHandling.md) for the retry protocol with
198
+ `TryAgain`/`StopInsist`/`Aborted`.
199
+
200
+ ## Hook
201
+
202
+ `lib/scout/misc/hook.rb` defines a top-level `Hook` module — **not
203
+ auto-required** (verify with `defined?(Hook)` => nil after
204
+ `require 'scout-essentials'`; it becomes a constant only after
205
+ `require 'scout/misc/hook'`). It exposes `Hook.extended`, `Hook.apply` and
206
+ `hook_method` (probe_06). `Hook.apply(hook_class, base_class)` redefines the
207
+ methods the two classes share on `base_class`, aliasing the originals as
208
+ `orig_<name>` and dispatching first to the registered hooks, honouring an
209
+ optional `claim(*args)` predicate on each hook. It is the mechanism behind
210
+ tool registration in `CMD::TOOLS`-style setups.
211
+
212
+ ## TmpFile
213
+
214
+ Root: `TmpFile.tmpdir` => `$HOME/tmp/scout/tmpfiles` (here
215
+ `/home/mvazque2/tmp/scout/tmpfiles` — a machine-specific example value),
216
+ overridable with `TmpFile.tmpdir=`;
217
+ `TmpFile.user_tmp('sub')` => `$HOME/tmp/scout/sub` (probe_05/probe_15).
218
+
219
+ ### Naming conventions (`tmp_for_file`, `lib/scout/tmpfile.rb:98-118`)
220
+
221
+ | piece | meaning |
222
+ |---|---|
223
+ | `·` (U+00B7) | each `/` in the source path |
224
+ | `PREFIX:` | `:prefix` option |
225
+ | `[key]` | `:key` option |
226
+ | `&F[match=...]` | `:filters` option (the "other options" hash) |
227
+ | `:md5` tail | digest of the remaining options |
228
+ | `MAX_FILE_LENGTH = 150` | names longer than this are truncated |
229
+
230
+ Verified shapes (probe_05, re-run as probe_13 for gate 2; `...` is
231
+ `TmpFile.tmpdir`, here `~/tmp/scout/tmpfiles`):
232
+
233
+ ```text
234
+ tmp_for_file('/a/b/c') => .../·a·b·c
235
+ tmp_for_file('/a/b/c', :prefix => 'P') => .../P:·a·b·c
236
+ tmp_for_file('/a/b/c', :key => 'k') => .../·a·b·c[k]
237
+ tmp_for_file('/a/b/c', {}, :filters => {:m=>1}) => .../·a·b·c&F[m=c4ca4238a0b923820dcc509a6f75849b]:4db87253e0818c624c185ac939aa99c1
238
+ tmp_for_file('/a/b/c', {}, :other => 2) => .../·a·b·c:9e984fda99565456fdde6f77833b61b4
239
+ ```
240
+
241
+ The `:filters` form embeds `Misc.digest(value)` (here the digest of `1`) in
242
+ the name and, because `other_options` is then non-empty, still gets the
243
+ `:md5` tail of the whole options hash (tmpfile.rb:112-115, 129).
244
+
245
+ **`nil` is not `#{}`**: `tmp_for_file('/a/b/c', nil, ...)` raises
246
+ `NoMethodError: super: no superclass method 'include?' for nil` from
247
+ `process_options` — pass an explicit empty Hash as the second argument when
248
+ you use `other_options` (probe_13).
249
+
250
+ ### `with_file` leaks on raise
251
+
252
+ `TmpFile.with_file(content, erase, options)` writes the temp file, yields
253
+ it, then `Open.rm_rf tmpfile if Open.exist?(tmpfile) && erase` — there is
254
+ **no `ensure`**. If the block raises, the temp file stays on disk
255
+ (probe_05: file still present after `raise "boom"`; removed normally when
256
+ the block succeeds). Add your own `begin/ensure` around `with_file` when the
257
+ block can fail. See [ErrorHandling.md](ErrorHandling.md).
258
+
259
+ ## Related pages
260
+
261
+ - [AnnotationSystem.md](AnnotationSystem.md) — the `Annotation` module
262
+ `NamedArray` builds on.
263
+ - [ErrorHandling.md](ErrorHandling.md) — `Misc.insist`, cleanup limits.
264
+ - [PathResolution.md](PathResolution.md) — how `tmpfiles` fits into the
265
+ `tmp` map.
@@ -0,0 +1,129 @@
1
+ # Design Principles
2
+
3
+ The handful of conventions that hold across `lib/scout`. Everything below is
4
+ observable in the source of this repo; nothing here is aspirational.
5
+
6
+ Everything below is backed by `tmp/rewrite_C/probe_11_design.rb`
7
+ (probe_11), which reproduces each claim from a clean `require`.
8
+
9
+ ## Composition by annotation
10
+
11
+ Rather than defining wrapper classes, scout-essentials **annotates ordinary
12
+ Ruby objects**. `Path`, `Resource`, `Persist` and `NamedArray` are modules
13
+ that are `extend`ed into Strings, Arrays or other modules; the receiver keeps
14
+ its class and gains accessors:
15
+
16
+ ```ruby
17
+ Path.setup('some/dir/file.txt') # a String with path machinery
18
+ SampleInfo.setup('S003', organism: 'Human') # a String with metadata
19
+ ```
20
+
21
+ `AnnotatedObject`-backed metadata, `annotation_types`, `purge`, and the
22
+ `setup`/`annotate` round-trip are documented in
23
+ [Annotation System](AnnotationSystem.md).
24
+
25
+ This is *the* extension mechanism of the gem: when you need to attach data or
26
+ behaviour to a value that may arrive from outside your code, you define an
27
+ annotation module instead of a wrapper class.
28
+
29
+ ## Modules, not classes
30
+
31
+ The public namespaces (`Path`, `Open`, `CMD`, `Persist`, `Misc`, `Log`,
32
+ `Resource`, `SOPT`, `IndiferentHash`, `TmpFile`, `Annotation`) are modules
33
+ whose methods are module-functions or class methods. Subclassing one of them
34
+ is not an option — `Persist` is a `Module`, so `class X < Persist` raises
35
+ `TypeError: superclass must be an instance of Class` (probe_11). The reuse
36
+ pattern is `extend` (`resource/path.rb`, `annotation/annotated_object.rb`).
37
+
38
+ ## Options hashes over positional flags
39
+
40
+ Public methods take a trailing `options = {}` and read named keys with
41
+ defaults, rather than growing positional booleans:
42
+
43
+ ```ruby
44
+ Open.sensible_write(path, content, :mode => 'w', :replicates => 3)
45
+ Persist.persist(name, :marshal, :check => [...], :update => true) { ... }
46
+ CMD.cmd('grep foo', :pipe => true, :in => input)
47
+ ```
48
+
49
+ Consequences for callers: options are permissive (unknown keys are ignored),
50
+ they are normalised inside helpers (`IndiferentHash.process_options`,
51
+ `IndiferentHash.add_defaults`), and they compose when one helper forwards its
52
+ options to another. See [Path Resolution](PathResolution.md) and
53
+ [Persistence and Resources](PersistenceAndResources.md).
54
+
55
+ ## Exception-borne control flow
56
+
57
+ Signals are ordinary exception objects derived from `Exception`, **not**
58
+ `StandardError` (`lib/scout/exceptions.rb`):
59
+
60
+ ```ruby
61
+ class StopInsist < Exception; end # misc/insist.rb: stop retrying, re-raise last error
62
+ class DontClose < Exception; end # open.rb: keep the payload, skip closing
63
+ class DontPersist < Exception; end # persist.rb: do not delete a partial cache
64
+ class KeepLocked < DontPersist; end # open/lock.rb: leave the lockfile alone
65
+ class KeepBar < Exception; end # log/progress/util.rb: keep the bar
66
+ ```
67
+
68
+ (`lib/scout/exceptions.rb` also defines the *ordinary* half of the ladder —
69
+ `ScoutException < StandardError` with `ParameterException`,
70
+ `MissingParameterException` and `ResourceNotFound`; `Aborted`,
71
+ `ProcessFailed`, `TryAgain`, `ClosedStream` … — those are exactly the ones a
72
+ bare `rescue` *does* catch.)
73
+
74
+ The point is deliberate: a bare `rescue => e` (which rescues
75
+ `StandardError`) **does not intercept them** — probe_11 raises each in turn
76
+ inside `begin ... rescue => e` and shows the signal escapes the bare
77
+ `rescue`. A producer that wants the caller to keep the stream it was handed
78
+ raises `DontClose` (carrying the result in `.payload`), and `Open.open`'s
79
+ block form rescues it by name (`lib/scout/open.rb:66-67`) to return that
80
+ payload without closing; `Persist` likewise checks `DontPersist === e`
81
+ before deleting a half-written cache (`lib/scout/persist.rb:130`). Callers
82
+ must name the class explicitly.
83
+
84
+ Practical rules:
85
+
86
+ - in code that closes streams or removes temp files, `rescue DontClose`,
87
+ `rescue DontPersist`, `rescue KeepLocked` by name;
88
+ - never convert a signal into `StandardError` (e.g. `raise e` inside a
89
+ `rescue StandardError`) — you would swallow it for the outer caller;
90
+ - aborting a `CMD` pipe surfaces as `Aborted` (a `StandardError`) or as the
91
+ `AbortedStream` **module** that is `extend`ed onto the stream object —
92
+ these are what streaming consumers should rescue
93
+ ([Streaming Model](StreamingModel.md); probe_11 shows
94
+ `AbortedStream` is a Module, not a Class).
95
+
96
+ ## Atomic writes
97
+
98
+ Anything that produces a file other users may read concurrently goes through
99
+ `Open.sensible_write(path, content, options)`:
100
+
101
+ - content is written to a temporary name generated next to the target
102
+ (`TmpFile.tmp_for_file(path, :dir => Open.sensible_write_dir)`) and guarded
103
+ by its own lockfile,
104
+ - the real file appears via `Open.mv` inside `Misc.insist`
105
+ (`stream.rb:137-139`), then is touched,
106
+ - on abort or any other exception the temporary is removed and the target is
107
+ removed if it appeared (`stream.rb:148-161`); the original exception is
108
+ re-raised.
109
+
110
+ `Persist.persist` (`persist.rb:108`), the `Persist.save` drivers
111
+ (`persist/serialize.rb:105,119`) and `Resource#produce`
112
+ (`resource/produce.rb:97,106`) all funnel through it. See
113
+ [Persistence and Resources](PersistenceAndResources.md).
114
+
115
+ ## Release semantics you must not assume
116
+
117
+ Cleanup is explicit, not automatic. `TmpFile.with_file` removes the temporary
118
+ file only when the block ends normally — there is no `ensure` around the
119
+ `yield`, so an exception leaves the file behind (probe_11; verified against
120
+ `lib/scout/tmpfile.rb:71-73`). Likewise stream closing depends on the signal classes
121
+ above, and progress bars rely on `Log::ProgressBar.remove_bar` being called.
122
+ When correctness matters, put the cleanup in an `ensure` or use
123
+ `Open.consume_stream`.
124
+
125
+ ## Related
126
+
127
+ - [Architecture](Architecture.md) — module map and attribution of the Scout
128
+ features that live in other repos.
129
+ - [Streaming Model](StreamingModel.md) — signal classes in the pipe pipeline.
@@ -0,0 +1,203 @@
1
+ # Error Handling
2
+
3
+ Everything that can be raised by `scout-essentials`, and the two very
4
+ different jobs exceptions do in this codebase: real failures vs.
5
+ control-flow signals. Source: `lib/scout/exceptions.rb` (78 lines, whole
6
+ file), `lib/scout/cmd.rb:22-31` (`CMD::Timeout`),
7
+ `lib/scout/concurrent_stream.rb:2-8` (`AbortedStream` module),
8
+ `lib/scout/open/stream.rb` (`sensible_write`).
9
+
10
+ ## The taxonomy
11
+
12
+ Verified by `tmp/rewrite_D/probe_14_exceptions_full.rb`:
13
+
14
+ ```text
15
+ ScoutDeprecated parent=StandardError
16
+ ScoutException parent=StandardError
17
+ FieldNotFoundError parent=StandardError
18
+ TryAgain parent=StandardError
19
+ StopInsist parent=Exception <- NOT StandardError
20
+ Aborted parent=StandardError
21
+ ParameterException parent=ScoutException
22
+ MissingParameterException parent=ParameterException
23
+ ProcessFailed parent=StandardError
24
+ ConcurrentStreamProcessFailed parent=ProcessFailed
25
+ OpenURLError parent=StandardError
26
+ DontClose parent=Exception <- NOT StandardError
27
+ DontPersist parent=Exception <- NOT StandardError
28
+ KeepLocked parent=DontPersist <- NOT StandardError
29
+ KeepBar parent=Exception <- NOT StandardError
30
+ LockInterrupted parent=TryAgain
31
+ ClosedStream parent=StandardError
32
+ ResourceNotFound parent=ScoutException
33
+ CMD::Timeout parent=ProcessFailed
34
+ ```
35
+
36
+ Plus one non-class member: `AbortedStream` is a **module**
37
+ (`lib/scout/concurrent_stream.rb:2-8`) that gets extended onto a stream;
38
+ `AbortedStream#exception` carries the original upstream exception. It is
39
+ not in the raise/catch taxonomy at all.
40
+
41
+ ## Two families, and why it matters
42
+
43
+ Failures derive from `StandardError` and behave normally. Control-flow
44
+ signals derive straight from `Exception`, so a bare `rescue =>` (which
45
+ means `rescue StandardError`) will **not** see them — verified
46
+ `probe_14`:
47
+
48
+ ```ruby
49
+ raise DontClose.new("payload")
50
+ # rescue => -> does NOT catch
51
+ # rescue Exception -> catches
52
+ ```
53
+
54
+ Probe results:
55
+
56
+ ```text
57
+ DontClose caught by 'rescue =>' => NOT caught; needs 'rescue Exception'
58
+ KeepLocked caught by 'rescue =>' => NOT caught; needs 'rescue Exception'
59
+ KeepBar caught by 'rescue =>' => NOT caught; needs 'rescue Exception'
60
+ DontPersist caught by 'rescue =>' => NOT caught; needs 'rescue Exception'
61
+ ```
62
+
63
+ `Aborted` and `TryAgain`/`LockInterrupted` ARE `StandardError`, so they
64
+ travel through ordinary `rescue =>` handlers — which is exactly what the
65
+ `Aborted` protocol relies on (below).
66
+
67
+ Each signal has a payload slot, and `StopInsist` carries the inner
68
+ exception it wants re-raised (`StopInsist#exception`,
69
+ `lib/scout/exceptions.rb:10-15`).
70
+
71
+ ## ProcessFailed family
72
+
73
+ `ProcessFailed.new(pid, msg)` builds its own message
74
+ (`lib/scout/exceptions.rb:22-37`):
75
+
76
+ ```text
77
+ ProcessFailed.new(1234,'custom msg').message => "Process 1234 failed - custom msg"
78
+ ProcessFailed.new(nil,'custom msg').message => "Failed to run custom msg"
79
+ ```
80
+
81
+ `pid` and `msg` are exposed as accessors.
82
+
83
+ `ConcurrentStreamProcessFailed < ProcessFailed`
84
+ (`lib/scout/exceptions.rb:40-47`) takes `(pid, msg, concurrent_stream)` and
85
+ exposes `concurrent_stream`; note the constructor has a real quirk — it
86
+ reads `@concurrent_stream` (still `nil`) instead of assigning the argument,
87
+ so the accessor always stays `nil`. The message is whatever `msg` was
88
+ passed at the raise site (`lib/scout/concurrent_stream.rb:113`); it is
89
+ raised from `ConcurrentStream#join_threads` when a thread's value is a
90
+ `Process::Status` that did not succeed (unless `no_fail`).
91
+
92
+ `CMD::Timeout < ProcessFailed` (`lib/scout/cmd.rb:22-31`) adds `command`
93
+ and `timeout` readers and composes the message through `super` — verified:
94
+
95
+ ```text
96
+ CMD::Timeout message => "Process 12 failed - command 'sleep 3 ' exceeded timeout of 0.1 seconds"
97
+ command => "sleep 3 " timeout => 0.1
98
+ ```
99
+
100
+ ## The Aborted protocol
101
+
102
+ `Aborted` means "this stream is dead, stop consuming it". It is a
103
+ `StandardError`, so it propagates through normal rescues;
104
+ `Open.sensible_write` has a dedicated `rescue Aborted` arm
105
+ (`lib/scout/open/stream.rb:150-154`) that:
106
+
107
+ 1. logs `Aborted sensible_write -- <path>`,
108
+ 2. calls `content.abort` if the content responds to it,
109
+ 3. **deletes the partial output** (`Open.rm path if File.exist? path`).
110
+
111
+ For the generic arm (`rescue Exception`, `lib/scout/open/stream.rb:155-163`)
112
+ the same cleanup happens, plus one extra move: when the content is an
113
+ `AbortedStream` carrying an `exception`, **that original exception is what
114
+ gets re-raised**, not the `Exception` raised in this frame:
115
+
116
+ ```ruby
117
+ exception = (AbortedStream === content and content.exception) ? content.exception : $!
118
+ ```
119
+
120
+ So the caller of `sensible_write` sees the root cause that made the
121
+ producer abort, rather than a secondary wrapper. Verified by
122
+ `research/behavior-probes.md` P25/P33 and `tmp/rewrite_B/probe_12_swallow.rb`
123
+ (`exists after Aborted => false`).
124
+
125
+ The `ensure` always removes the temp file and unlocks a held
126
+ `Lockfile` (`lib/scout/open/stream.rb:165-171`).
127
+
128
+ ## `Misc.insist` and the retry loop
129
+
130
+ `Misc.insist` (`lib/scout/misc/insist.rb`) retries the block while the
131
+ code inside raises `TryAgain`; `StopInsist` and `Aborted` break out.
132
+ Verified `tmp/rewrite_B/probe_01` / `probe_10_misc.rb` and
133
+ `tmp/rewrite_D/probe_04_exceptions.rb`:
134
+
135
+ ```ruby
136
+ tries = 0
137
+ Misc.insist do
138
+ tries += 1
139
+ raise TryAgain unless tries == 3
140
+ :ok
141
+ end
142
+ # => :ok, after 3 tries
143
+
144
+ Misc.insist do
145
+ raise StopInsist.new(ArgumentError.new("inner"))
146
+ end
147
+ # => raises ArgumentError (the inner exception is recovered)
148
+
149
+ Misc.insist do
150
+ raise Aborted
151
+ end
152
+ # => raises Aborted, no retry
153
+ ```
154
+
155
+ `LockInterrupted < TryAgain` is the bridge between the two worlds: when
156
+ `Open.lock` fails to acquire, it raises `LockInterrupted`
157
+ (`lib/scout/open/lock.rb:43`), which an outer `Misc.insist` treats as
158
+ "try again".
159
+
160
+ ## `canfail` / `no_fail` semantics
161
+
162
+ - `Persist.persist(..., :canfail => true)` — exceptions from the block are
163
+ caught; the persist file is removed and `nil` is returned
164
+ (`lib/scout/persist.rb:63-100`; verified `tmp/rewrite_D/probe_03_locks.rb`:
165
+ `persist canfail => nil`, `file removed: true`).
166
+ - `ConcurrentStream` `no_fail: true` — a non-success
167
+ `Process::Status` from a joined thread is logged at low level instead of
168
+ raising `ConcurrentStreamProcessFailed`
169
+ (`lib/scout/concurrent_stream.rb:113`; also `research/behavior-probes.md`
170
+ P26).
171
+ - `CMD`'s `no_fail` similarly suppresses both `ProcessFailed` from a thread
172
+ join and exceptions during join.
173
+
174
+ The distinction matters when composing: a `no_fail` stream that dies
175
+ produces no exception at join time, so downstream persistence will
176
+ happily write empty output unless the producer itself is aborted.
177
+
178
+ ## Cleanup guarantees, and their real limits
179
+
180
+ `Open.sensible_write` is the model of a real guarantee: temp file + lock +
181
+ `ensure` that removes both. But cleanup is not uniform across the gem:
182
+
183
+ - **`TmpFile.with_file` leaks the temp file when the block raises** — there
184
+ is no `ensure` around the `yield`
185
+ (`lib/scout/tmpfile.rb:34-48`; verified
186
+ `tmp/rewrite_D/probe_05_tmpfile_leak.rb`: the created tmp file is still
187
+ present after the block raises `RuntimeError`). Wrap your own `begin/ensure`
188
+ around `with_file` if the block can fail.
189
+ - `Open.sensible_write` deletes the *target* on failure but only the
190
+ *temp* file, never other files written by a streaming producer.
191
+ - `Misc.insist` retries without undoing side effects already produced by
192
+ earlier attempts.
193
+
194
+ ## Related pages
195
+
196
+ - [StreamingModel.md](StreamingModel.md) — where `Aborted` is raised,
197
+ consumed, and swallowed.
198
+ - [LockingAndConcurrency.md](LockingAndConcurrency.md) — `KeepLocked`,
199
+ `LockInterrupted`, and the lock lifecycle.
200
+ - [PersistenceAndResources.md](PersistenceAndResources.md) — `DontPersist`,
201
+ `canfail`.
202
+ - [LoggingAndProgress.md](../user/LoggingAndProgress.md) — `KeepBar` and
203
+ progress-bar lifecycle.