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