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,157 @@
1
+ # Locking and Concurrency
2
+
3
+ How the Scout stack serialises access to files: a vendored `Lockfile`
4
+ implementation, three distinct lock namespaces, and the process/thread model
5
+ the locks are meant to protect.
6
+
7
+ ## The vendored Lockfile
8
+
9
+ `lib/scout/open/lock/lockfile.rb` is Ara T. Howard's `lockfile` library,
10
+ version **2.1.8**, vendored and modified, and guarded so a second load is a
11
+ no-op:
12
+
13
+ ```ruby
14
+ unless defined?(Lockfile)
15
+ ... (whole class)
16
+ end
17
+ ```
18
+
19
+ `Open.init_lock` (called when the file is loaded) overrides the library
20
+ defaults, so the effective settings here are **not** the upstream ones
21
+ (probe_08):
22
+
23
+ | setting | library default | effective here |
24
+ |---|---|---|
25
+ | `max_age` | 3600 | **30** |
26
+ | `refresh` | 8 | **2** |
27
+ | `suspend` | 16 | **4** |
28
+ | `Lockfile.version` | 2.1.8 | 2.1.8 |
29
+
30
+ So a lock older than 30 s is considered stale and may be stolen, the holder
31
+ re-touches (`FileUtils.touch`) the lock file every 2 s, and a waiter between
32
+ attempts sleeps 4 s.
33
+
34
+ ## What a lock looks like on disk
35
+
36
+ Acquiring `<file>` creates `<file>.lock` and a hidden sibling. The payload
37
+ written into the lock is four lines (probe_08):
38
+
39
+ ```text
40
+ /tmp/…/data.txt.lock contents:
41
+ host: turbo
42
+ pid: 6
43
+ ppid: 3
44
+ time: 2026-08-22 00:58:55.751779
45
+ ```
46
+
47
+ (`dump_lock_id`, `lib/scout/open/lock/lockfile.rb:504-507`: `host`, `pid`,
48
+ `ppid`, `time`).
49
+
50
+ The `.lock` sibling is created by `tmpnam`/`create_tmplock` as a dot-prefixed
51
+ temporary file in the same directory and then **hard-linked** to
52
+ `<file>.lock`; the temp name embeds the pid, which is how the sweep can
53
+ recognise locks left behind by dead processes and remove them. Once the
54
+ original temp name is unlinked, `nlink` on the surviving `.lock` is 1.
55
+
56
+ ## `Open.lock` and `LockInterrupted`
57
+
58
+ ```ruby
59
+ Open.lock(file, options = {}) { ... } # block form: unlock after
60
+ Open.lock(file, true, options) # legacy 2nd arg = unlock
61
+ Open.lock(file, false, options = {}) # do not unlock afterwards
62
+ ```
63
+
64
+ `options[:lock]` accepts a `Lockfile` instance (reuse an existing lock), a
65
+ `Path`/`String` (an alternative lock file location), or `false` (no locking
66
+ at all, and no unlock). Without a block the lock is acquired and the
67
+ `Lockfile` object returned. If another holder releases the lock while we are
68
+ waiting, `Open.lock` raises `LockInterrupted` (< `TryAgain`, so it is a
69
+ `StandardError`) — callers retry the whole operation rather than proceeding
70
+ unlock-guarded.
71
+
72
+ Unlock failures are caught and logged (`Exception unlocking: <path>`), never
73
+ raised over the block's own result.
74
+
75
+ ## The three lock namespaces
76
+
77
+ Each subsystem locks in its own directory so the names never collide
78
+ (probe_02/probe_03):
79
+
80
+ | who | directory | name shape |
81
+ |---|---|---|
82
+ | `Persist` | `Persist.lock_dir` = `tmp/persist_locks`.find → `$HOME/.scout/tmp/persist_locks` | `<persistence_path>.persist` suffix |
83
+ | `Resource#lock_dir` | `$HOME/.scout/tmp/produce_locks` | `TmpFile` digest of the resource path |
84
+ | `Open.sensible_write` | `$HOME/.scout/tmp/sensible_write_locks` | digest of the output path |
85
+
86
+ Verified strings:
87
+
88
+ ```text
89
+ Persist.lock_dir => $HOME/.scout/tmp/persist_locks
90
+ persist lock file => .../persist_locks/probeD3.persist
91
+ Resource#lock_dir => $HOME/.scout/tmp/produce_locks
92
+ produce lock file => .../produce_locks/·home·mvazque2·.scout·etc·probeD2
93
+ sensible_write lock dir => $HOME/.scout/tmp/sensible_write_locks
94
+ ```
95
+
96
+ (The `·home·mvazque2·...` name is literal `TmpFile.tmp_for_file` output —
97
+ each `/` in the probe path was flattened to `·` — and is kept verbatim; the
98
+ home-directory component is a machine-specific example value.)
99
+
100
+ (See [PersistenceAndResources.md](PersistenceAndResources.md) for how
101
+ `persist` uses its lock, and [StreamingModel.md](StreamingModel.md) for
102
+ `sensible_write`.)
103
+
104
+ ## KeepLocked — streaming persistence
105
+
106
+ `Persist.persist(..., :persist_type/:type)` can return a stream that must
107
+ stay locked while the consumer reads it. Raising `KeepLocked.new(res)` from
108
+ inside the persist block leaves the lock held; the caller gets the stream
109
+ back. probe_03:
110
+
111
+ ```ruby
112
+ res = Persist.persist("probeD3", :text, :persist => false) do
113
+ raise KeepLocked.new("payload")
114
+ end
115
+ res # => "payload"
116
+ File.exist?(lock_path) # => true (lock still held)
117
+ ```
118
+
119
+ `KeepLocked < DontPersist < Exception` — it is deliberately outside
120
+ `StandardError` so that generic `rescue =>` blocks do not swallow it.
121
+
122
+ ## The fork/thread model
123
+
124
+ - **Fork.** `CMD.cmd(:pipe => true)` returns an `IO` whose writer is a forked
125
+ child; `Open.open_pipe` in fork mode reuses the same machinery, and
126
+ `ScoutRake.run` forks per task. Child pids are registered on the
127
+ `ConcurrentStream` (`stream.pids`) and reaped by `abort_pids`/`join`.
128
+ - **Threads.** Stream consumers are ordinary Ruby threads, registered with
129
+ `stream.threads`. `ConcurrentStream.join` runs the registered `@callback`
130
+ chain exactly once (`join_callback`) and then joins the threads.
131
+ - **Cross-process.** That is what the locks above are for.
132
+
133
+ ## Thread-safety caveats
134
+
135
+ Verified by `tmp/rewrite_D/probe_09_threads_fork.rb`:
136
+
137
+ - `Log::LAST` is a shared top-level `String` — a write from one thread is
138
+ visible to the next with no synchronisation.
139
+ - `Scout::Config` holds **no mutex**; concurrent `set`/`get`/`with_config`
140
+ is not atomic. See [Configuration.md](Configuration.md).
141
+ - `Persist` likewise has no lock on its class-level state.
142
+ - `ConcurrentStream` callbacks fire at `join`, in registration order, once
143
+ each; they are not protected against being added from two threads at once.
144
+ - `Open.wait`'s `LAST_TIME` hash (see [RemoteData.md](../user/RemoteData.md))
145
+ is unsynchronised shared state.
146
+
147
+ If you need real parallel writers on the same key, take the relevant lock
148
+ namespace yourself, or serialise at the call site.
149
+
150
+ ## Related pages
151
+
152
+ - [ErrorHandling.md](ErrorHandling.md) — `LockInterrupted`, `KeepLocked` and
153
+ the other control-flow exceptions.
154
+ - [PersistenceAndResources.md](PersistenceAndResources.md) — what the
155
+ persist lock actually guards.
156
+ - [StreamingModel.md](StreamingModel.md) — `sensible_write`, forked pipes and
157
+ `ConcurrentStream`.
@@ -0,0 +1,200 @@
1
+ # Path Resolution
2
+
3
+ `Path` is a `String` subclass carrying resource metadata. This page explains
4
+ how an *unlocated* logical name such as `data/config.yaml` is turned into a
5
+ real filesystem location, how to configure the search, and how `find`,
6
+ `follow`, `identify`, and friends behave.
7
+
8
+ ## Anatomy of a `Path`
9
+
10
+ A `Path` is a plain `String` plus annotations (`:pkgdir`, `:libdir`,
11
+ `:path_maps`, `:map_order`, `:where`, `:original`), added by the `Annotation`
12
+ mechanism. Because it is a String, it can be joined, split, and used with
13
+ core `File` methods; annotations travel along when you `join` or use `/`:
14
+
15
+ ```ruby
16
+ require 'scout-essentials'
17
+
18
+ p = Path.setup('data/config.yaml')
19
+ p.pkgdir # => "scout" (Path.default_pkgdir)
20
+ p.located? # => false ('data/config.yaml' is relative, not './x' or '/x')
21
+ p.to_s # => "data/config.yaml"
22
+
23
+ p.join(:a) # => "data/config.yaml/a"
24
+ p.join(:a, :b) # => "data/config.yaml/b/a" (b goes first)
25
+ p / :a # => "data/config.yaml/a"
26
+ p._toplevel # => "data" (first path segment)
27
+ p._subpath # => "config.yaml" (the rest)
28
+ p.data.samples # => "data/config.yaml/data/samples" (method_missing
29
+ # appends a segment, right to left)
30
+ ```
31
+
32
+ `[]` and `/` are both aliases of `join`. **There is no `Path#[]=` and no
33
+ class-level `Path.map_order=`** — use `Path.add_path`,
34
+ `Path.prepend_path`, `Path.append_path`, or the per-instance
35
+ `path_maps`/`map_order` annotations instead.
36
+
37
+ ## Location: `find`
38
+
39
+ `find` resolves an unlocated path by trying every map in `map_order` until
40
+ one produces an existing file (or a `.gz`/`.bgz`/`.zip` alternative).
41
+ `find` **never returns nil**:
42
+
43
+ - If the path is already `located?` (starts with `/`, `./`, or `~/`) and
44
+ exists, it returns the expanded path.
45
+ - If it is `located?` but missing, it tries the compressed alternatives and
46
+ otherwise **returns itself**.
47
+ - If it is unlocated, it walks `map_order`; on total failure it returns
48
+ `follow(:default)` — the default location where the file *would* be.
49
+
50
+ ```ruby
51
+ p = Path.setup('data/config.yaml')
52
+ p.find # tries :current, :user, :home, ... then :default
53
+ p.find(:user) # force one specific map
54
+ p.find(:all) # == find_all
55
+ p.exists? # find then File.exist?
56
+ ```
57
+
58
+ The returned `Path` is annotated with `where` (the map that matched) and
59
+ `original` (a copy of the unlocated path) so you can trace how a file was
60
+ resolved:
61
+
62
+ ```ruby
63
+ found = p.find
64
+ found.where # e.g. :user
65
+ found.original # the original unlocated Path
66
+ ```
67
+
68
+ ## The default map order
69
+
70
+ The built-in maps and their order (13 entries) are:
71
+
72
+ | # | Map | Template |
73
+ |---|---|---|
74
+ | 1 | `:current` | `{PWD}/{TOPLEVEL}/{SUBPATH}` |
75
+ | 2 | `:user` | `{HOME}/.{PKGDIR}/{TOPLEVEL}/{SUBPATH}` |
76
+ | 3 | `:home` | `{HOME}/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
77
+ | 4 | `:local` | `/usr/local/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
78
+ | 5 | `:global` | `/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
79
+ | 6 | `:usr` | `/usr/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
80
+ | 7 | `:scout_essentials_lib` | `<gem libdir>/{TOPLEVEL}/{SUBPATH}` |
81
+ | 8 | `:lib` | `{LIBDIR}/{TOPLEVEL}/{SUBPATH}` |
82
+ | 9 | `:fast` | `/fast/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
83
+ | 10 | `:cache` | `/cache/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
84
+ | 11 | `:bulk` | `/bulk/{TOPLEVEL}/{PKGDIR}/{SUBPATH}` |
85
+ | 12 | `:default` | `{PWD}/{TOPLEVEL}/{SUBPATH}` |
86
+ | 13 | `:tmp` | `/tmp/{PKGDIR}/{TOPLEVEL}/{SUBPATH}` |
87
+
88
+ A per-instance `map_order` is recomputed lazily as
89
+ `(Path.map_order & available_maps) + (remaining maps, in reverse key order)`.
90
+ This is why a new map registered with `Path.add_path` (which clears the
91
+ class-level order) or `p.add_path` (which clears the instance order) ends up
92
+ at the *end* of the effective order, after the built-ins:
93
+
94
+ ```ruby
95
+ p = Path.setup('data/config.yaml')
96
+ p.add_path(:onlymine, '/x/{SUBPATH}')
97
+ p.map_order # => [:current, :user, ..., :tmp, :onlymine]
98
+ ```
99
+
100
+ `*_lib` maps (e.g. `:scout_essentials_lib`) are regular entries built from
101
+ the gem's own libdir at load time; `{LIBDIR}` in a template resolves to the
102
+ `libdir` annotation or, failing that, the directory of the calling library.
103
+
104
+ ## `follow`: applying a map without searching
105
+
106
+ `follow(map)` applies one template, no matter whether the target exists:
107
+
108
+ ```ruby
109
+ Path.setup('data/config.yaml').follow(:user)
110
+ # => "$HOME/.scout/data/config.yaml"
111
+ ```
112
+
113
+ Placeholders available in templates: `{PWD}`, `{HOME}`, `{PKGDIR}`,
114
+ `{RESOURCE}`, `{TOPLEVEL}`, `{SUBPATH}`, `{BASENAME}`, `{PATH}`, `{LIBDIR}`,
115
+ `{MAPNAME}`, `{REMOVE}` (deletes itself and the following slash). A template
116
+ without any placeholder gets `{PATH}` appended, so the *whole path* is used
117
+ verbatim. A map value may be another map name (a `Symbol`), which is
118
+ dereferenced until a String is found. When `map_name` is an unknown String,
119
+ `follow` builds `<map_name>/{TOPLEVEL}/{SUBPATH}` on the fly — this is how
120
+ `Scout.etc` (`"etc"`) resolves to `$HOME/.scout/etc`:
121
+
122
+ ```ruby
123
+ Scout.etc['path_maps'].find # => "$HOME/.scout/etc/path_maps"
124
+ ```
125
+
126
+ ## Configuring the search
127
+
128
+ ```ruby
129
+ Path.add_path(:mymap, '/my/{TOPLEVEL}/{SUBPATH}') # effective in map_order
130
+ Path.prepend_path(:first, '/first/{TOPLEVEL}/{SUBPATH}')
131
+ Path.append_path(:last, '/last/{TOPLEVEL}/{SUBPATH}')
132
+
133
+ p = Path.setup('data/config.yaml')
134
+ p.add_path(:onlymine, '/x/{SUBPATH}') # per-instance; recomputes map_order
135
+ p.path_maps # a dup of Path.path_maps
136
+ p.map_order # instance order, :onlymine included
137
+ ```
138
+
139
+ `Path.load_path_maps(filename)` reads a YAML mapping of `where => location`
140
+ and registers each with `add_path`; at boot,
141
+ `Scout.etc['path_maps']` (i.e. `$HOME/.scout/etc/path_maps`) is loaded this
142
+ way, so users can add search locations without code changes.
143
+
144
+ ## Finding every candidate: `find_all` / `glob_all`
145
+
146
+ ```ruby
147
+ Path.setup('data/config.yaml').find_all
148
+ # => every location in map_order where the file exists (uniqued)
149
+
150
+ Path.setup('data/*').glob_all
151
+ # => Path#glob over each map result; annotated with original/where
152
+ ```
153
+
154
+ `glob` on a `located?` path calls `Dir.glob` directly; on an unlocated path
155
+ it delegates to `glob_all`.
156
+
157
+ ## Reversing the process: `identify` and `relocate`
158
+
159
+ `Resource.identify(path)` maps a located path back to an unlocated one by
160
+ matching each map template as a regexp (dropping `:current`); the shortest
161
+ candidate wins, and `$HOME` is folded back to `~`. `Resource.relocate(path)`
162
+ returns the existing path if it exists, otherwise identifies and re-finds it.
163
+
164
+ ```ruby
165
+ Resource.identify(File.join(ENV['HOME'], '.scout', 'data', 'config'))
166
+ # => "data/config"
167
+ Resource.relocate(File.join(ENV['HOME'], '.scout', 'data', 'config'))
168
+ # => re-resolved through find
169
+ ```
170
+
171
+ ## Digest names and `etc`/`tmp` helpers
172
+
173
+ `Path#digest_str` produces a stable digest for a file or directory (used for
174
+ caching); for a directory with more than 10 files it uses a count plus an
175
+ MD5 of the file list, otherwise the MD5 of each file. See
176
+ [Caching Results](../user/CachingResults.md).
177
+
178
+ Resource helpers: `Scout.etc`, `Scout.tmp`, `Scout.share`, ... are
179
+ `Path#method_missing` segment builders over `Scout`'s own path (`Scout` is
180
+ itself a Resource with `pkgdir 'scout'`), so they produce unlocated
181
+ sub-paths that `find`/`follow(:user)` resolve under `$HOME/.scout`:
182
+
183
+ ```ruby
184
+ Scout.etc # => "etc" (unlocated, pkgdir Scout)
185
+ Scout.etc.find # => "$HOME/.scout/etc"
186
+ Scout.etc['path_maps'].find # => "$HOME/.scout/etc/path_maps"
187
+ ```
188
+
189
+ `Scout.etc` is not a statically defined method: it resolves through
190
+ `Resource#method_missing` (resource.rb:69) into `Path#method_missing`
191
+ (path.rb:45) segment building, the same mechanism as any other segment
192
+ (`Scout.tmp`, `Scout.share`, ...).
193
+
194
+ ## Related
195
+
196
+ - [Producing Resources](../user/ProducingResources.md) — claims and produce
197
+ on top of `find`.
198
+ - [Working with Files](../user/WorkingWithFiles.md) — `Open` I/O that
199
+ consumes `Path`s.
200
+ - [Architecture](Architecture.md) for the module dependency graph.
@@ -0,0 +1,119 @@
1
+ # Persistence and Resources
2
+
3
+ This page documents the developer contracts behind two mechanisms that both
4
+ write files: **`Persist`** (caching the result of a computation) and
5
+ **`Resource#produce`** (materializing a declared resource). Both are
6
+ lock-protected and rely on `Open.sensible_write` for atomic output.
7
+
8
+ ## `Persist.persist`
9
+
10
+ ```ruby
11
+ Persist.persist(name, type, options = {}, &block)
12
+ ```
13
+
14
+ `Persist.persist` runs `block` and stores its serialized result under a
15
+ cache path, unless the cache is already valid, in which case the stored
16
+ value is deserialized and returned without running the block.
17
+
18
+ - `name` — usually a cache path; `options[:persist_path]` (or `:path`)
19
+ overrides it.
20
+ - `type` — a serialization type; see the table in
21
+ [Caching Results](../user/CachingResults.md).
22
+ - With no block the call is a lookup.
23
+ - `path_hash`/`:path` options name the cache file; `key:` appends
24
+ `[key]` to it.
25
+ - `:check` — a file, an Array of files or a Proc; when any of them is newer
26
+ than the cache (`Path#outdated?`) the cache is recomputed. `:check`
27
+ requires the cache path itself to be a `Path` (a plain `String` cache
28
+ path raises `NoMethodError`).
29
+ - `:update` — `true` (always recompute), a `Time` or a `Numeric` number of
30
+ seconds (recompute when the cache file is older), or a `Path` whose mtime
31
+ is compared with the cache's.
32
+ - `:memory` type + `MEMORY_CACHE` — in-process memoization with the same
33
+ API; `Persist.memory("key", key: k) { ... }` is the shorthand.
34
+ - `KeepLocked` — when passed, the persist lock is held across the whole
35
+ read/serialize cycle, preventing readers from seeing a half-written cache
36
+ file.
37
+ - `:canfail` — on failure (of the computation, not of the lookup) the error
38
+ is swallowed and `nil` is returned instead of raising.
39
+ - **Error path**: unless `DontPersist` is raised, a failing block leaves no
40
+ partial cache file behind — the target is removed.
41
+ - `no_load: true` (or a `TrueClass` value) returns the cache **path**
42
+ instead of the content.
43
+
44
+ The serialization drivers are not constants: they are entries in the
45
+ accessor hashes `Persist.save_drivers` and `Persist.load_drivers`, both
46
+ keyed by type symbol. You can add a type by inserting a Proc into both
47
+ hashes. **No serialization suffix is ever appended to the filename**: two
48
+ calls with different types and the same `name`/`persist_path` collide on
49
+ the same file.
50
+
51
+ ## Lock namespaces
52
+
53
+ Three lock directories exist, and they are **not** interchangeable:
54
+
55
+ | Lock dir | Owner | Naming of the lock file |
56
+ |----------|-------|------------------------|
57
+ | `Persist.lock_dir` (`tmp/persist_locks`) | `Persist.persist` | derived from the cache path |
58
+ | `Resource.default_lock_dir` (`tmp/produce_locks`) | `Resource#produce` | `TmpFile.tmp_for_file(final_path, dir: lock_dir)` |
59
+ | `Open.sensible_write_lock_dir` (`tmp/sensible_write_locks`) | `Open.sensible_write` | derived from the target path |
60
+
61
+ The locking primitive is `Open.lock(filename, &block)` on the vendored
62
+ `Lockfile`. **`Persist.lock` does not exist.**
63
+
64
+ ## `Resource` and `Path`: one mechanism
65
+
66
+ `Path` is a String subclass extended with `Annotation`; `Resource` is a
67
+ module extended with `Annotation` and `extend`ed by resource packages.
68
+ Because both are annotation-based, a `Path` produced from a resource module
69
+ carries the module itself as its `pkgdir` annotation, and `Path#produce`
70
+ delegates to `pkgdir.produce(self, force)`:
71
+
72
+ ```ruby
73
+ module MyApp
74
+ extend Resource
75
+ annotation :pkgdir
76
+ self.pkgdir = 'myapp'
77
+ end
78
+ ```
79
+
80
+ - `claim(path, type, content = nil, &block)` — `type` is mandatory and
81
+ positional; valid types are `:string`, `:url`, `:proc`, `:rake`,
82
+ `:install`. `:csv` raises `"TSV/CSV Not implemented yet"` when produced.
83
+ - A `:proc` claim of arity 0 is called with no arguments; arity 1 receives
84
+ the final path. The return value is dispatched: `String`/`IO`/`StringIO`
85
+ → written; `Array` → joined with newlines; `TSV`/`TSV::Dumper` → dumper
86
+ stream (scout-gear only; **a `nil` return hits the `when TSV` branch
87
+ first** and raises `NameError` without scout-gear); `nil` → nothing
88
+ written.
89
+ - Producing an unclaimed path falls through to `.gz`/`.bgz` variants when
90
+ those are claimed — asymmetrically (plain → `.gz` works, `.gz` → plain
91
+ does not).
92
+ - `@produced` on the `Path` latches tri-state: `true` after a successful
93
+ produce, `false` after `ResourceNotFound`, the exception after a failure;
94
+ later calls return/raise that value instead of retrying, unless `force`.
95
+ - `Path#read`/`#open`/`#list` produce first (`#list` uses
96
+ `produce_and_find('list')`); `Path#write` does not.
97
+ - `Resource.sync(path, map, options)` — module-level helper (no `Path#sync`)
98
+ that re-materializes resource files found elsewhere into a local map
99
+ directory.
100
+
101
+ ## Rake and software
102
+
103
+ - `rake_dirs` directories make a package "have rake": `has_rake?(path)` /
104
+ `rake_for(path)` (longest prefix match). Production runs the rake task
105
+ through `ScoutRake`, which forks; a `Don't know how to build task`
106
+ message triggers a walk-up retry, then `ResourceNotFound`.
107
+ - `:install` claims run `Resource.install(name, software_dir, options)`,
108
+ sourcing `share/software/install_helpers` and honoring spec keys such as
109
+ `:git`, `:src`, `:url`, `:jar`, `:extra`. `Resource.set_software_env`
110
+ exports the installed binaries (run once at load time on the default
111
+ `software` dir).
112
+
113
+ ## Related
114
+
115
+ - [Caching Results](../user/CachingResults.md) — user-facing guide to
116
+ `Persist`.
117
+ - [Producing Resources](../user/ProducingResources.md) — user-facing guide
118
+ to claims and `produce`.
119
+ - [Architecture](Architecture.md) — module graph and lock overview.