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,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.
|