woods 2.0.0.beta1 → 2.0.0.beta2
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/CHANGELOG.md +138 -0
- data/CONTRIBUTING.md +53 -2
- data/README.md +3 -3
- data/docs/CONFIGURATION_REFERENCE.md +35 -0
- data/docs/INCREMENTAL_EXTRACTION.md +41 -1
- data/docs/INTERNALS.md +8 -5
- data/docs/PUBLISHED_INDEX.md +16 -0
- data/docs/UPGRADING_TO_2.md +2 -2
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/extractor.rb +329 -95
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/graph_analyzer.rb +161 -58
- data/lib/woods/payload_store.rb +14 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +86 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afd0e7789aeee449784af8f4a3b49eb2dfae7bb522381a78dba2e1932c41057c
|
|
4
|
+
data.tar.gz: a1c7c26400a59fae7cce074e086c0928e2267399dba6cedef7c6f5e49697ed46
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3f87143d3a9283f9ad53d6d8fb08c61929e162513c1b5ca55452c24b52f2e804d1ad9e664d754b664b6e76b312f0fd3b1afbe957b7f00d8701f8238ac4976fa
|
|
7
|
+
data.tar.gz: bece3ff52a93f3298ee3792769c61ae75b4d8913a5ea5ce92c1564a5d3c612f8073b15357fe0e9b25d81e983dcdaea0c7030dda5375df958a3d57805b0fd5457
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,139 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.0.beta2] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`release:prepare` runs a live preflight before printing the tag and dispatch
|
|
15
|
+
commands.** The new `release:preflight` task (also runnable standalone) checks, via
|
|
16
|
+
`gh api`, that the live `release` environment still requires review and disallows admin
|
|
17
|
+
bypass; that `REQUIRED_CI_JOBS` in `script/validate-release-run` still matches
|
|
18
|
+
`ci.yml`'s job names; and that both `release.yml` download-artifact steps set
|
|
19
|
+
`merge-multiple: true`. Every check is advisory: a missing or failing `gh` skips with a
|
|
20
|
+
note rather than blocking prepare. CONTRIBUTING.md and the release-flow skill also gained
|
|
21
|
+
a "when a dispatch fails" guide (which failures a main merge alone fixes versus which need
|
|
22
|
+
the tag moved, and only before publication) and the documented final step for creating the
|
|
23
|
+
GitHub Release entry by hand, which the workflow deliberately never automates.
|
|
24
|
+
- **A release_v2 spec greps `spec/` for hard-coded current-version literals** (`'= 2.0.0'`,
|
|
25
|
+
`"2.0.0\n"`, `gem_version: '2.0.0'`-shaped strings), built from `Woods::VERSION`'s base, so
|
|
26
|
+
the next version bump cannot leave one behind the way the beta1 cut did.
|
|
27
|
+
- **`WOODS_PROFILE=1` logs a timing line per extraction phase.** The per-extractor
|
|
28
|
+
lines already reported extraction itself; everything after it (payload seed, previous
|
|
29
|
+
graph load, eager load, blast radius, re-extraction, type index, graph analysis, flows,
|
|
30
|
+
manifest and summary, publish) was unattributed, so a slow run could only be split by
|
|
31
|
+
guessing. One `[Woods] [profile] <phase> in N.NNs` line per phase, on the monotonic
|
|
32
|
+
clock. Off by default and free when off.
|
|
33
|
+
- **`durable_payload_writes` restores the per-file `fsync` on payload files.** Boolean,
|
|
34
|
+
default `false`. Off is not the weaker setting: readers resolve only through
|
|
35
|
+
`generation.json`, and every publish now flushes the whole payload before writing that
|
|
36
|
+
pointer, so the contract holds either way. Turning the key on buys exactly one thing, an
|
|
37
|
+
individual payload file being durable before the pointer exists, and pays two forced
|
|
38
|
+
flushes per file (about 8.9ms each on btrfs) for it. It cannot disable the publish
|
|
39
|
+
flush, which has no opt-out.
|
|
40
|
+
|
|
41
|
+
- **`incremental_blast_radius_depth` bounds how far an incremental run re-extracts.**
|
|
42
|
+
`extract_changed` walked the unbounded transitive dependent closure of every changed
|
|
43
|
+
file, so one edit to a widely referenced unit re-extracted most of the app. The new key
|
|
44
|
+
caps the walk at N reverse hops (`nil`, the default, keeps the unbounded closure). A
|
|
45
|
+
unit outside the cap keeps its content and still gets its `dependents` list refreshed by
|
|
46
|
+
the run's second pass, which the equivalence harness now covers under a cap of 1. The
|
|
47
|
+
default stays unbounded on purpose: an STI grandchild inherits its grandparent's
|
|
48
|
+
associations, validations and callback chain while sitting two hops away in the graph,
|
|
49
|
+
and nested `has_many :through` resolves the same way, so a host opts in for a tree it
|
|
50
|
+
knows has neither. On a 200-service chain in the dummy app, one leaf edit re-extracts
|
|
51
|
+
200 units unbounded and 2 under a depth of 1.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **Payload durability moved from every file to the generation pointer.** `AtomicFile.write`
|
|
56
|
+
fsynced the temp file and the containing directory for every file it wrote, so a full
|
|
57
|
+
extraction of a large application paid two forced flushes 8323 times: 71.3s of the write
|
|
58
|
+
phase for 8000 files on btrfs, against 1.0s for one filesystem flush. Payload writes (unit
|
|
59
|
+
files, type indexes, the dependency graph, the graph analysis, the manifest, the summary,
|
|
60
|
+
the flow documents) now skip both, and `publish_generation` calls the new
|
|
61
|
+
`AtomicFile.sync_directory_tree` on the payload directory immediately before writing
|
|
62
|
+
`generation.json`.
|
|
63
|
+
|
|
64
|
+
The guarantee that replaces the old one: **when `generation.json` is durable, every file
|
|
65
|
+
in the payload it names is durable.** What is given up is an individual payload file being
|
|
66
|
+
durable before the pointer exists, and nothing reads a payload file in that window, since
|
|
67
|
+
every reader resolves through the pointer and a crash there leaves an unreferenced partial
|
|
68
|
+
payload the next run prunes. `generation.json` itself, the watch daemon's status file, the
|
|
69
|
+
update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal
|
|
70
|
+
snapshots, embedding checkpoints and MCP task records all keep the per-file fsync: their
|
|
71
|
+
readers do not go through the pointer. The gem mapper's self-map publishes through the
|
|
72
|
+
same pointer but keeps its per-file fsync for now; it is a small payload and adopts the
|
|
73
|
+
single flush in a follow-up.
|
|
74
|
+
|
|
75
|
+
`sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare
|
|
76
|
+
`sync`, then an `fsync` on every file in the tree, and returns which one ran. The last
|
|
77
|
+
resort is what keeps this honest: the chain never silently does nothing, it only gets
|
|
78
|
+
slower. Fiddle is required inside a rescue and stays out of the gemspec, since it is a
|
|
79
|
+
bundled gem from Ruby 3.5. `bench/atomic_write_bench.rb` measures all four modes.
|
|
80
|
+
|
|
81
|
+
- **Seeding a payload creates each directory once rather than once per file.**
|
|
82
|
+
`PayloadStore#clone` ran `FileUtils.mkdir_p` before every file it replicated.
|
|
83
|
+
`Pathname#find` visits a directory before its children, so the directory branch had
|
|
84
|
+
already created every parent a file could need. 8001 files across four type directories
|
|
85
|
+
on btrfs: 0.906s before, 0.847s after.
|
|
86
|
+
|
|
87
|
+
- **Cycle detection is capped, and says so.** `GraphAnalyzer#analyze` runs on every
|
|
88
|
+
extraction regardless of the change set, and enumerating every cycle was the largest
|
|
89
|
+
part of it: one cycle per DFS back-edge, uncapped in count and in length, each
|
|
90
|
+
canonicalized by joining a path that on a deep DFS is thousands of nodes long. Two new
|
|
91
|
+
config keys bound it, `graph_cycle_limit` (default 500) and `graph_cycle_max_length`
|
|
92
|
+
(default 50, in distinct nodes); either firing sets the new
|
|
93
|
+
`stats.cycle_limit_reached` in `graph_analysis.json`. Set either to `nil` to remove
|
|
94
|
+
its cap and restore exhaustive enumeration. Signatures are now keyed by a digest of
|
|
95
|
+
the rotated cycle rather than the joined path. On a synthetic 8201-node graph, cycle
|
|
96
|
+
detection went from 8.6s to 0.4s.
|
|
97
|
+
- **Bridge detection reuses its work.** `bfs_shortest_path` carries parent pointers
|
|
98
|
+
instead of enqueueing a copy of the path so far for every node it reaches, and forward
|
|
99
|
+
adjacency resolves through a per-analyzer memo instead of being re-derived on each of
|
|
100
|
+
the 200 sampled traversals. Output is unchanged. On the same graph, bridges went from
|
|
101
|
+
2.2s to 0.6s and the whole report from 3.1s to 1.0s.
|
|
102
|
+
- **Flow assembly caches loaded units and parsed sources.** One `FlowAssembler` serves a
|
|
103
|
+
whole precompute run, but nothing was cached across it: a unit's JSON was re-globbed
|
|
104
|
+
and re-parsed on every expansion, and its source re-parsed once per action of every
|
|
105
|
+
controller that reached it. Three per-instance LRU memos (unit data, whole-source AST,
|
|
106
|
+
and the method index derived from it) bound at 1000 entries each. On 435 controllers x
|
|
107
|
+
7 actions over 3000 services, precompute went from 20.5s to 3.8s.
|
|
108
|
+
- **The manifest and `SUMMARY.md` read each type index once.** Both derive their totals
|
|
109
|
+
from the per-type `_index.json` files and run back to back at the end of every
|
|
110
|
+
incremental run, so every index was globbed, read and parsed twice. An unreadable index
|
|
111
|
+
still drops that type from both, now with one warning instead of two.
|
|
112
|
+
- **The incremental flow refresh is scoped to the flow assembly radius.** Flows were
|
|
113
|
+
reassembled for every controller in the run's touched set, and the touched set is the
|
|
114
|
+
graph's whole reverse closure, so one leaf edit re-ran flow assembly for nearly every
|
|
115
|
+
controller in the app. A flow document reaches `FlowPrecomputer::DEFAULT_MAX_DEPTH`
|
|
116
|
+
units, so the run now walks the pre-change graph to that same depth and reassembles
|
|
117
|
+
only the controllers inside it. Controllers outside it carry their
|
|
118
|
+
`metadata[:flow_paths]` annotation forward out of the previous flow index rather than
|
|
119
|
+
losing it. A targeted `refresh`, a routes re-run, and a controller whose action set no
|
|
120
|
+
longer matches the index all still reassemble in full. On 50 controllers over a
|
|
121
|
+
50-service chain in the dummy app, one edit at the far end went from 50 controllers
|
|
122
|
+
reassembled to 3 plus 47 carried.
|
|
123
|
+
- **`graph_sha` is digested from the bytes written.** `graph_analysis.json`'s digest of
|
|
124
|
+
`dependency_graph.json` came from reading the file back off disk, one whole-file read
|
|
125
|
+
per run of an artifact that on a large app is tens of megabytes, to digest bytes the
|
|
126
|
+
run had just serialized. The value is unchanged.
|
|
127
|
+
|
|
10
128
|
### Fixed
|
|
11
129
|
|
|
130
|
+
- `TraceEnricher.record` rejects calls without a block before creating a
|
|
131
|
+
TracePoint, preventing an enabled hook from leaking into subsequent execution (#308).
|
|
132
|
+
|
|
133
|
+
- **The Changed list only names the surface inventory when regenerating it actually moved
|
|
134
|
+
it.** `release:prepare` used to list `.Codex/release-v2/surface-inventory.json`
|
|
135
|
+
unconditionally, even on the ordinary run where nothing in the public surface changed.
|
|
136
|
+
- **The release banner no longer links an upgrade guide that does not exist yet.** A major
|
|
137
|
+
version bump past `docs/UPGRADING_TO_2.md`'s own major derived a
|
|
138
|
+
`docs/UPGRADING_TO_<major>.md` link without checking the file exists.
|
|
139
|
+
- **Changelog entries merged from a duplicate heading no longer carry a stray blank line.**
|
|
140
|
+
Two occurrences of the same `###` heading in one `## [Unreleased]` cycle folded into a
|
|
141
|
+
release section with a blank line between their bullets, splitting one list into two; they
|
|
142
|
+
now join tight.
|
|
12
143
|
- **Release validation names the live-backends CI job as it is called.** The release
|
|
13
144
|
validator required a CI job named `Live backends (pgvector + Qdrant + Solid Cache)`,
|
|
14
145
|
but the job gained `+ Redis` in its name, so every release dispatch failed at
|
|
@@ -23,6 +154,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
154
|
snapshot fixture, so the first prerelease failed them (Bundler never resolves a prerelease
|
|
24
155
|
from an unpinned requirement). They now use `Woods::VERSION`. The candidate host also
|
|
25
156
|
installs `webrick` so `woods-mcp-http` finds a Rack handler on Rubies that no longer ship one.
|
|
157
|
+
- **Inspector contract specs track the pinned `@modelcontextprotocol/inspector` version instead
|
|
158
|
+
of a literal.** Bumping the dev dependency to 2.6.0 fixed the SDK bug where Inspector sent a
|
|
159
|
+
legacy `logging/setLevel` call after negotiating the modern 2026-07-28 protocol, so the two
|
|
160
|
+
`pending` stdio/HTTP examples in `mcp_inspector_contract_spec.rb` asserted a stderr message
|
|
161
|
+
that no longer occurs. Those examples now assert a clean modern handshake, and every version
|
|
162
|
+
literal in that file reads from `package.json` instead. `sdk_dependency_spec.rb`'s deliberate
|
|
163
|
+
version-and-integrity trip wire is bumped to match the new pin.
|
|
26
164
|
|
|
27
165
|
## [2.0.0.beta1] - 2026-09-09
|
|
28
166
|
|
data/CONTRIBUTING.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Contributing to Woods
|
|
2
2
|
|
|
3
3
|
<!-- release-state:contributing-intro -->
|
|
4
|
-
Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.0.
|
|
4
|
+
Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta2/AGENTS.md).
|
|
5
5
|
<!-- release-state:end -->
|
|
6
6
|
|
|
7
7
|
## Choose the right channel
|
|
@@ -25,6 +25,8 @@ bin/rake spec
|
|
|
25
25
|
bin/rubocop
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
`Gemfile.lock` is gitignored, so a fresh worktree (as opposed to a clone) needs it copied in from an existing checkout before running any `bin/*` command.
|
|
29
|
+
|
|
28
30
|
Create a branch from current `main`. Keep each pull request to one logical change and preserve unrelated formatting and refactors for separate work.
|
|
29
31
|
|
|
30
32
|
`main` is the development branch: it holds work for the next release and can run ahead of the latest published gem. Releases are cut from version tags by the guarded workflow in the [release section below](#release-flow); documentation matching a published gem lives on that release's tag.
|
|
@@ -44,7 +46,7 @@ Create a branch from current `main`. Keep each pull request to one logical chang
|
|
|
44
46
|
| `plugin/skills/` | Distributed Woods skills (setup/upgrade, MCP configuration, investigation, agent enablement, diagnosis) |
|
|
45
47
|
|
|
46
48
|
<!-- release-state:contributing-architecture -->
|
|
47
|
-
Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.0.
|
|
49
|
+
Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta2/CLAUDE.md) for architecture and implementation gotchas before changing runtime behavior.
|
|
48
50
|
<!-- release-state:end -->
|
|
49
51
|
|
|
50
52
|
### Agent orientation and static self-map
|
|
@@ -233,6 +235,55 @@ Nothing is published from a laptop: the workflow builds and pushes the gem from
|
|
|
233
235
|
|
|
234
236
|
After a final release publishes, reopen development with `release:reopen` in a follow-up pull request.
|
|
235
237
|
|
|
238
|
+
### When a dispatch fails
|
|
239
|
+
|
|
240
|
+
`release-context` and `publish` both check out `github.sha`, the default
|
|
241
|
+
branch's tip at dispatch time, not the tag: `release-context` re-validates the
|
|
242
|
+
named CI run, checks the live `release` environment, and runs
|
|
243
|
+
`script/validate-release`; `publish` runs `script/verify-release-tag` and
|
|
244
|
+
pushes the downloaded artifact. Only `package-test` checks out
|
|
245
|
+
`needs.release-context.outputs.release-sha`, the tag's own commit, because
|
|
246
|
+
that is what CI actually built and tested. The gem bytes `publish` pushes were
|
|
247
|
+
built by CI at `release-sha`; `publish` never rebuilds them.
|
|
248
|
+
|
|
249
|
+
That split decides the fix for a failed dispatch:
|
|
250
|
+
|
|
251
|
+
| What failed | Lives in | Fix |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `script/validate-release-run`, `script/validate-release`, `script/verify-release-tag`, or the workflow files themselves | main, read at `github.sha` | merge the fix to main, then re-dispatch at the same tag; the tag never moves |
|
|
254
|
+
| Live `release` environment settings (protection rule, admin bypass) | GitHub environment configuration, not the tree | fix the setting directly; no commit or re-dispatch needed |
|
|
255
|
+
| Anything under `spec/` or `lib/` that `package-test` actually runs against the candidate | the tagged commit, read at `release-sha` | a main-only fix does not reach the candidate; merge it, then move the tag to the new main tip and get a fresh CI run on it |
|
|
256
|
+
|
|
257
|
+
Both failure classes happened in the beta1 dispatch: a `REQUIRED_CI_JOBS`
|
|
258
|
+
prefix left behind by a `ci.yml` job rename was a validator fix that needed
|
|
259
|
+
only a merge and a re-dispatch; two `packaged_gem_spec.rb` smoke failures
|
|
260
|
+
traced to hard-coded `2.0.0` literals needed the tag moved to the commit that
|
|
261
|
+
fixed them, because the candidate job runs the spec file at the tag.
|
|
262
|
+
|
|
263
|
+
**Moving a tag is acceptable only before publication.** Once `publish` has
|
|
264
|
+
pushed the gem to RubyGems, the tag is the permanent, immutable record of what
|
|
265
|
+
was published; move it before that point only, with
|
|
266
|
+
`git tag -f v<version> <new-sha> && git push --force origin v<version>` run by
|
|
267
|
+
the maintainer, followed by a fresh CI run on the new tag SHA before
|
|
268
|
+
re-dispatching.
|
|
269
|
+
|
|
270
|
+
### Publishing the GitHub Release entry
|
|
271
|
+
|
|
272
|
+
The workflow deliberately creates no GitHub Release: the API cannot bind an
|
|
273
|
+
existing tag to an expected commit atomically, so automating it would race the
|
|
274
|
+
tag's own verification. Once `gem info woods --remote` (or `--remote
|
|
275
|
+
--prerelease`) confirms publication, create the entry by hand:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
gh release create v<version> --verify-tag --notes-file <file> # release
|
|
279
|
+
gh release create v<version> --verify-tag --prerelease --notes-file <file> # beta or rc
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`--verify-tag` refuses if the tag is missing or moved. Write `<file>` as a
|
|
283
|
+
short body that links `CHANGELOG.md` at the tag itself (not at `main`) and
|
|
284
|
+
anchors straight to that version's dated heading, so the note a reader lands
|
|
285
|
+
on always matches the bytes RubyGems published.
|
|
286
|
+
|
|
236
287
|
### Stable branches
|
|
237
288
|
|
|
238
289
|
A stable branch is `N-M-stable`, cut from the release tag. Create one only when a released line needs a patch after a newer major has shipped on `main`; until then, `main` is the only branch. There is no stable branch today.
|
data/README.md
CHANGED
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
>
|
|
16
16
|
> `main` is the development branch and can run ahead of the latest published gem. The gem badge above shows the latest published version; documentation for a published version lives on its tag.
|
|
17
17
|
>
|
|
18
|
-
> ### Version: 2.0.0.
|
|
18
|
+
> ### Version: 2.0.0.beta2 is published as a prerelease; `main` documents 2.0.0
|
|
19
19
|
>
|
|
20
20
|
> | Line | Version | Documentation |
|
|
21
21
|
> |---|---|---|
|
|
22
22
|
> | Documented here | **2.0.0**, unreleased | this README and the [documentation index](docs/README.md) |
|
|
23
|
-
> | Latest prerelease | **2.0.0.
|
|
23
|
+
> | Latest prerelease | **2.0.0.beta2** | [the v2.0.0.beta2 tag](https://github.com/lost-in-the/woods/tree/v2.0.0.beta2) |
|
|
24
24
|
> | Latest published gem | **1.6.1** | [the v1.6.1 tag](https://github.com/lost-in-the/woods/tree/v1.6.1) |
|
|
25
25
|
>
|
|
26
|
-
> RubyGems treats 2.0.0.
|
|
26
|
+
> RubyGems treats 2.0.0.beta2 as a prerelease, so `gem "woods", "~> 2.0"` does not resolve it. Install it explicitly with `gem "woods", "2.0.0.beta2"`. The released constraint stays `gem "woods", "~> 1.6"`.
|
|
27
27
|
<!-- release-state:end -->
|
|
28
28
|
|
|
29
29
|
Woods boots your Rails app, extracts the behavior Rails assembles at runtime, and serves it to AI tools through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Agents can inspect resolved routes, schema, associations, callbacks, included concerns, dependencies, and execution flows instead of guessing from source files alone.
|
|
@@ -358,6 +358,40 @@ end
|
|
|
358
358
|
| `extract_navigation_edges` | Boolean | `true` | Extract `link_to`, `redirect_to`, and `form_action` navigation edges from views and controllers |
|
|
359
359
|
| `enable_snapshots` | Boolean | `false` | Enable temporal snapshots. Woods automatically migrates its internal output-directory SQLite store; if SQLite is unavailable, it uses the JSON snapshot store. No Rails migration is required. |
|
|
360
360
|
| `volatile_dependency_ratio` | Float | `3.0` | A dependency whose commit count (last 365 days) exceeds the dependent's by this ratio appears in the `volatile_dependencies` report (top 20, ranked by PageRank). Must be greater than 1. Report only, never a gate. |
|
|
361
|
+
| `graph_cycle_limit` | Integer or `nil` | `500` | How many distinct cycles `GraphAnalyzer` enumerates before it stops. Cycle detection finds one cycle per DFS back-edge, so a dense graph has tens of thousands of them and enumerating every one is the largest single cost of the analysis that runs on every extraction. Set to `nil` for exhaustive enumeration. |
|
|
362
|
+
| `graph_cycle_max_length` | Integer or `nil` | `50` | The longest cycle recorded, in distinct nodes. A back-edge deep in the DFS closes a cycle as long as the path, which on a large graph is thousands of nodes: unreadable as a report and expensive to canonicalize. Set to `nil` to record a cycle of any length. |
|
|
363
|
+
|
|
364
|
+
| `incremental_blast_radius_depth` | Integer or `nil` | `nil` | How many reverse hops an incremental run walks from a changed file before it stops re-extracting dependents. `nil` keeps the unbounded transitive closure. See the note below before setting it. |
|
|
365
|
+
| `durable_payload_writes` | Boolean | `false` | Force an `fsync` on every payload file as it is written, on top of the single flush every publish already performs. See the note below before setting it. |
|
|
366
|
+
|
|
367
|
+
`incremental_blast_radius_depth` is unbounded by default because a unit two hops
|
|
368
|
+
out really can have content that depends on the changed file. An STI grandchild
|
|
369
|
+
(`SportsCar < Car < Vehicle`) inherits its grandparent's associations,
|
|
370
|
+
validations and callback chain, and a nested `has_many :through` resolves
|
|
371
|
+
through the same kind of chain, while the graph only records the one-hop
|
|
372
|
+
superclass reference each source file mentions. Set the key on a tree you know
|
|
373
|
+
has neither shape; the units it stops re-extracting still get their `dependents`
|
|
374
|
+
list refreshed, and `spec/integration/incremental_equivalence_spec.rb` holds a
|
|
375
|
+
depth of 1 to full-extraction equivalence.
|
|
376
|
+
|
|
377
|
+
`durable_payload_writes` is off by default, and off is not the weaker
|
|
378
|
+
guarantee. Readers resolve only through `generation.json`, so a payload file
|
|
379
|
+
has no reader until that pointer names it. Every publish flushes the whole
|
|
380
|
+
payload directory once (`syncfs`, else `sync -f`, else `sync`, else a per-file
|
|
381
|
+
`fsync` pass) *before* writing the pointer, which makes the contract: when
|
|
382
|
+
`generation.json` is durable, every file in the payload it names is durable.
|
|
383
|
+
A crash before that leaves an unreferenced partial payload the next run prunes.
|
|
384
|
+
|
|
385
|
+
Turning the key on buys exactly one thing: an individual payload file being
|
|
386
|
+
durable before the pointer exists. It costs two forced flushes per file, about
|
|
387
|
+
8.9ms each on btrfs, so 8000 units is roughly 71s of writing against 1s. It
|
|
388
|
+
cannot disable the publish flush, which has no opt-out.
|
|
389
|
+
|
|
390
|
+
When either cap fires, `graph_analysis.json` reports `stats.cycle_limit_reached: true`
|
|
391
|
+
alongside `stats.cycle_count`, so a reader of the `cycles` array can tell a truncated
|
|
392
|
+
view from a short one. Enumeration stops at the cap rather than counting past it, so
|
|
393
|
+
there is no full population count to publish. Both caps must be a positive Integer or
|
|
394
|
+
`nil`; anything else raises `Woods::ConfigurationError`.
|
|
361
395
|
|
|
362
396
|
## Session tracer options
|
|
363
397
|
|
|
@@ -573,6 +607,7 @@ These variables are read by the gem and its MCP servers at runtime. They complem
|
|
|
573
607
|
| `CI_COMMIT_BEFORE_SHA`, `CI_COMMIT_SHA` | unset (GitLab) | Build the diff range `<before>..<after>` for `woods:incremental`. A zero before-SHA (new branch) makes the range unresolvable, which exits 1 unless a running daemon covers the index. |
|
|
574
608
|
| `GITHUB_BASE_REF` | unset (GitHub Actions) | Build the diff range `origin/<ref>...HEAD` for `woods:incremental`; an unfetched ref makes the range unresolvable, same exit behavior. |
|
|
575
609
|
| `RAILS_ENV` | `development` | Rails environment the rake tasks boot in. |
|
|
610
|
+
| `WOODS_PROFILE` | unset | Set to `"1"` to log one `[Woods] [profile] <phase> in N.NNs` line per run phase (payload seed, previous graph load, eager load, extraction or blast radius and re-extraction, type index, graph analysis, flows, manifest and summary, publish). Complements the per-extractor timing lines, which cover extraction only. Off by default and free when off. |
|
|
576
611
|
| `WOODS_GIT_DIR` | unset | Absolute path to the canonical git directory. Wins over the repository Woods would otherwise find, at all three of its git call sites: per-unit `commit_count`/`change_frequency` (enrichment), `manifest.json`'s `git_branch`/`git_sha` (provenance), and the `woods:incremental` diff range. All three build their command line with `Woods::GitCommand.argv`. |
|
|
577
612
|
| `GIT_BRANCH`, `GIT_SHA` | unset | Provenance for a checkout with no `.git` at all (a source tarball, a Docker `COPY` that excludes it). Ignored when a `.git` is present but unresolvable, so a stale build arg cannot mask a worktree. |
|
|
578
613
|
|
|
@@ -82,7 +82,8 @@ same rooting rule the manifest's git provenance follows.
|
|
|
82
82
|
step before it.
|
|
83
83
|
|
|
84
84
|
1. **Blast radius** from the *pre-change* graph, so dependents of a file that
|
|
85
|
-
just disappeared still get re-extracted.
|
|
85
|
+
just disappeared still get re-extracted. Unbounded by default; see
|
|
86
|
+
[Bounding the blast radius](#bounding-the-blast-radius).
|
|
86
87
|
2. **Reconcile changed paths.** Every changed path that still exists is handed
|
|
87
88
|
to the file-based extractors that claim it (`PathDispatcher`), and units the
|
|
88
89
|
path no longer produces are dropped. This is what indexes a file the index
|
|
@@ -130,6 +131,33 @@ A run that changed nothing **does not rewrite the manifest**. The manifest
|
|
|
130
131
|
timestamp drives `woods_status.staleness_seconds`, and touching it after a no-op
|
|
131
132
|
would report the index as freshly synced when nothing was re-read.
|
|
132
133
|
|
|
134
|
+
## Bounding the blast radius
|
|
135
|
+
|
|
136
|
+
`incremental_blast_radius_depth` caps how many reverse hops step 1 walks.
|
|
137
|
+
`nil`, the default, keeps the unbounded transitive closure: every unit that
|
|
138
|
+
reaches the changed file, at any depth, is re-extracted.
|
|
139
|
+
|
|
140
|
+
The reason to cap it is cost. A unit's extracted content is mostly a function
|
|
141
|
+
of its own source and its own reflection, so on most graphs the deep half of
|
|
142
|
+
the closure re-derives bytes that do not change. On a 200-service chain in the
|
|
143
|
+
dummy app, one leaf edit re-extracts 200 units unbounded and 2 at a depth
|
|
144
|
+
of 1.
|
|
145
|
+
|
|
146
|
+
The reason the default is not capped is that "mostly" is not "always". An STI
|
|
147
|
+
grandchild reads its grandparent's reflection: `SportsCar < Car < Vehicle`
|
|
148
|
+
inherits `Vehicle`'s associations, validations and callback chain, and a
|
|
149
|
+
nested `has_many :through` resolves through the same kind of chain. The graph
|
|
150
|
+
records only the one-hop superclass reference each source file mentions, so
|
|
151
|
+
that grandchild sits two hops out while its content depends on hop zero. Set
|
|
152
|
+
the key on a tree you know has neither shape.
|
|
153
|
+
|
|
154
|
+
What the cap never affects is `dependents`. Edges change only when a unit is
|
|
155
|
+
re-extracted, and the run marks every target of a re-extracted unit's edges,
|
|
156
|
+
before and after registration, so a unit that gains or loses an inbound edge
|
|
157
|
+
is rewritten by the second pass whether or not the walk reached it.
|
|
158
|
+
`spec/integration/incremental_equivalence_spec.rb` holds a depth of 1 to
|
|
159
|
+
full-extraction equivalence, including that case.
|
|
160
|
+
|
|
133
161
|
## Dispatch inventory
|
|
134
162
|
|
|
135
163
|
### Per-file
|
|
@@ -274,6 +302,18 @@ them for its **delta**:
|
|
|
274
302
|
generation, which payload seeding hardlinks into the run's payload
|
|
275
303
|
directory.
|
|
276
304
|
|
|
305
|
+
Re-assembly is scoped further, because a flow document only reaches
|
|
306
|
+
`FlowPrecomputer::DEFAULT_MAX_DEPTH` units. The run walks the pre-change graph
|
|
307
|
+
to that same depth from its changed files; a re-extracted controller inside
|
|
308
|
+
that radius is re-assembled, and one outside it takes its
|
|
309
|
+
`metadata[:flow_paths]` back from the previous index without paying for the
|
|
310
|
+
assembly. Three cases opt out and re-assemble every re-extracted controller: a
|
|
311
|
+
targeted `Extractor#refresh`, which has no change set; a routes re-run, which
|
|
312
|
+
replaces every controller and moves the route a flow document carries without
|
|
313
|
+
touching any dependency edge; and a controller whose action set no longer
|
|
314
|
+
matches the previous index, which is how an action inherited from further up a
|
|
315
|
+
controller chain than the radius reaches still lands.
|
|
316
|
+
|
|
277
317
|
After the index is rewritten, a **dedicated flow-artifact sweep** removes
|
|
278
318
|
every `flows/` document no index entry references. It validates against
|
|
279
319
|
`flow_index.json` and is deliberately separate from the unit sweep: flows/
|
data/docs/INTERNALS.md
CHANGED
|
@@ -172,7 +172,7 @@ Scores feed into the retrieval ranker as one signal in the final ranking formula
|
|
|
172
172
|
| **Orphans** | Units with no dependents, potential dead code or public entry points. Framework sources are excluded (they're naturally unreferenced in the reverse index). |
|
|
173
173
|
| **Dead ends** | Units with no dependencies, self-contained leaf nodes (value objects, standalone utilities) |
|
|
174
174
|
| **Hubs** | Units with many dependents, architectural bottlenecks; changes here have high blast radius |
|
|
175
|
-
| **Cycles** | Circular dependencies, A→B→C→A. Detected via DFS. |
|
|
175
|
+
| **Cycles** | Circular dependencies, A→B→C→A. Detected via DFS, and capped: `graph_cycle_limit` (default 500) bounds how many are enumerated and `graph_cycle_max_length` (default 50) skips one longer than that. Either cap firing sets `stats.cycle_limit_reached`. Set both to `nil` for exhaustive enumeration. |
|
|
176
176
|
| **Bridges** | Edges whose removal would disconnect the graph, high-risk structural connections |
|
|
177
177
|
| **Cross-database edges** | Association or foreign-key edges whose two ends resolve to different databases. A `has_many :through` is reported as `join_through_across_databases` when `disable_joins` is false and `from_db`, `through_db` (the join model's database), or `to_db` disagree. A foreign key never resolves to an owner in the source database, even when another database also claims the table; when every owner sits elsewhere and they span more than one database, the entry comes back with `to: nil` and an `ambiguous_owners` list instead of guessing. Read from graph node and edge attributes, so full and incremental runs agree. Scoped to primary nodes (units registered in the graph), not variants. |
|
|
178
178
|
| **Volatile dependencies** | Edges that point at a unit changing at least `volatile_dependency_ratio` times more often than the dependent (POODR: depend on things that change less often than you do). Dependencies with fewer than 5 commits or a `new` change frequency are skipped. Ranked by the dependency's PageRank; the persisted list keeps the top 20, while `stats.volatile_dependency_count` reports the full qualifying count and `stats.volatile_dependencies_limit` reports the cap. |
|
|
@@ -191,15 +191,18 @@ persisted rather than every hub in the graph.
|
|
|
191
191
|
| `orphans` | `stats.orphan_count` | no | every orphan |
|
|
192
192
|
| `dead_ends` | `stats.dead_end_count` | no | every dead end |
|
|
193
193
|
| `hubs` | `stats.hub_count` | yes, top 20 by dependent count | the persisted array, so it stops at 20 |
|
|
194
|
-
| `cycles` | `stats.cycle_count` |
|
|
194
|
+
| `cycles` | `stats.cycle_count`, `stats.cycle_limit_reached` | yes, `graph_cycle_limit` cycles of at most `graph_cycle_max_length` nodes | the count is the persisted array; the flag says whether either cap fired |
|
|
195
195
|
| `bridges` | none | yes, top 10 by score | not counted |
|
|
196
196
|
| `cross_database_edges` | `stats.cross_database_edge_count` | no | every crossing edge |
|
|
197
197
|
| `volatile_dependencies` | `stats.volatile_dependency_count`, `stats.volatile_dependencies_limit` | yes, top 20 by the dependency's PageRank | the count is every qualifying edge; the limit is the cap |
|
|
198
198
|
| `undeclared_package_edges` | `stats.undeclared_package_edge_count` | no | every undeclared crossing |
|
|
199
199
|
|
|
200
|
-
A capped section means the array on disk is a page, not the population.
|
|
201
|
-
`volatile_dependencies` publishes both numbers
|
|
202
|
-
|
|
200
|
+
A capped section means the array on disk is a page, not the population.
|
|
201
|
+
`volatile_dependencies` publishes both numbers and `cycles` publishes a
|
|
202
|
+
truncation flag, so those two are the ones where a reader can tell truncation
|
|
203
|
+
from a short list without re-running the analysis. Cycle enumeration stops at
|
|
204
|
+
the cap rather than counting past it, which is why `cycles` gets a flag and not
|
|
205
|
+
a population count.
|
|
203
206
|
The `graph_analysis` MCP tool pages each section independently when given
|
|
204
207
|
`limit` or `offset`, and its `Showing N of M (truncated)` line counts the array
|
|
205
208
|
it read from disk, not the population the stat keys count.
|
data/docs/PUBLISHED_INDEX.md
CHANGED
|
@@ -65,6 +65,22 @@ A directory numbered above the pointer (a payload built but never bumped to) and
|
|
|
65
65
|
|
|
66
66
|
A *missing* `generation.json` is not an error: it means a flat (pre-2.0) index, generation 0. A `generation.json` that **exists but will not parse** is different, a corrupt install, not an empty index, so `.available_generations` and `.new`/`.open` raise `Woods::PublishedIndex::CorruptPointerError` naming the file's path instead of silently reporting zero published generations.
|
|
67
67
|
|
|
68
|
+
### Durability: the pointer is the commit point
|
|
69
|
+
|
|
70
|
+
The pointer is not only what makes a generation *visible*, it is what makes it *durable*.
|
|
71
|
+
|
|
72
|
+
Payload files are written through `Woods::AtomicFile.write` with `durable: false`: a temp file, a chmod to the final mode, and a rename, but no `fsync`. Immediately before `generation.json` is written, the writer flushes the whole payload directory once with `Woods::AtomicFile.sync_directory_tree`, and then writes the pointer durably.
|
|
73
|
+
|
|
74
|
+
So the contract a reader can rely on is:
|
|
75
|
+
|
|
76
|
+
> When `generation.json` is durable, every file in the payload it names is durable.
|
|
77
|
+
|
|
78
|
+
What that gives up is an individual payload file being durable *before* the pointer exists. Nothing reads a payload file in that window: every reader resolves through the pointer, and a crash there leaves an unreferenced partial payload that the next run prunes. The saving is large, since two forced flushes per file is about 8.9ms each on btrfs, or 71s for 8000 units against 1s for one flush.
|
|
79
|
+
|
|
80
|
+
`sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare `sync`, then an `fsync` on every file in the tree. The last resort is what keeps this honest: the chain never silently does nothing, it only gets slower.
|
|
81
|
+
|
|
82
|
+
A file whose readers do **not** resolve through the pointer keeps its own `fsync`: `generation.json` itself, the watch daemon's `watch_status.json`, the update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal snapshots, `checkpoint.json`, and MCP task records. Set `durable_payload_writes = true` to pay the per-file `fsync` on payload files as well; it cannot disable the publish flush.
|
|
83
|
+
|
|
68
84
|
## Keying a RuboCop cache on the index
|
|
69
85
|
|
|
70
86
|
RuboCop caches offenses per file and invalidates the cache when a cop's `external_dependency_checksum` changes. `rubocop-rails` uses this to re-run schema-aware cops when `db/schema.rb` changes. The same pattern works against Woods: the pinned payload's `manifest.json` is rewritten through `AtomicFile` on every publish, so its digest is the checksum.
|
data/docs/UPGRADING_TO_2.md
CHANGED
|
@@ -5,8 +5,8 @@ Woods 2.0 changes observable index identifiers, publication layout, vector-store
|
|
|
5
5
|
This guide assumes the last v1 release, 1.6.1, and targets 2.0.0.
|
|
6
6
|
|
|
7
7
|
<!-- release-state:upgrade-availability -->
|
|
8
|
-
> RubyGems lists 2.0.0.
|
|
9
|
-
> `gem "woods", "2.0.0.
|
|
8
|
+
> RubyGems lists 2.0.0.beta2 as a prerelease. Pin it explicitly with
|
|
9
|
+
> `gem "woods", "2.0.0.beta2"`; `~> 2.0` resolves only once
|
|
10
10
|
> 2.0.0 is published.
|
|
11
11
|
<!-- release-state:end -->
|
|
12
12
|
|
data/lib/woods/atomic_file.rb
CHANGED
|
@@ -25,11 +25,30 @@ module Woods
|
|
|
25
25
|
# one: the watch daemon's +watch_status.json+, read by host-side hooks
|
|
26
26
|
# through a bind mount).
|
|
27
27
|
#
|
|
28
|
+
# +durable:+ chooses when the bytes are forced to the disk, never whether
|
|
29
|
+
# the write is atomic. Both paths go tempfile, chmod, rename, so a reader
|
|
30
|
+
# sees the old content or the new content and never a torn partial.
|
|
31
|
+
# +durable: false+ drops the two forced flushes (the temp file's own
|
|
32
|
+
# +fsync+ and the containing directory's), which is the whole cost of the
|
|
33
|
+
# write on a journalling filesystem: roughly 8.9ms per file against
|
|
34
|
+
# 0.11ms without, measured on btrfs.
|
|
35
|
+
#
|
|
36
|
+
# It is only safe for a file with no reader until something else commits
|
|
37
|
+
# it. Woods' payload files qualify: every reader resolves through
|
|
38
|
+
# +generation.json+, and that pointer is written durably after
|
|
39
|
+
# {.sync_directory_tree} has flushed the payload it names. A file whose
|
|
40
|
+
# readers do not go through the pointer (the watch daemon's status, the
|
|
41
|
+
# update check's cache, an export, an embedding checkpoint) must stay
|
|
42
|
+
# durable.
|
|
43
|
+
#
|
|
28
44
|
# @param path [String, Pathname] destination path
|
|
29
45
|
# @param content [String] file content
|
|
30
46
|
# @param mode [Integer] permissions for the written file (default 0600)
|
|
47
|
+
# @param durable [Boolean] force the bytes to disk before returning
|
|
48
|
+
# (default true). Pass false only for a file that something else makes
|
|
49
|
+
# durable before any reader can resolve it.
|
|
31
50
|
# @return [void]
|
|
32
|
-
def write(path, content, mode: 0o600)
|
|
51
|
+
def write(path, content, mode: 0o600, durable: true)
|
|
33
52
|
path = path.to_s
|
|
34
53
|
FileUtils.mkdir_p(File.dirname(path))
|
|
35
54
|
tmp = Tempfile.new('.woods-', File.dirname(path))
|
|
@@ -38,19 +57,130 @@ module Woods
|
|
|
38
57
|
tmp.binmode
|
|
39
58
|
tmp.write(content)
|
|
40
59
|
tmp.flush
|
|
41
|
-
tmp.fsync
|
|
60
|
+
tmp.fsync if durable
|
|
42
61
|
tmp.close
|
|
43
62
|
# Chmod the temp file so the destination is born with its final
|
|
44
63
|
# permissions — never observed more open or more closed in between.
|
|
45
64
|
File.chmod(mode, tmp.path)
|
|
46
65
|
File.rename(tmp.path, path)
|
|
47
|
-
fsync_directory(File.dirname(path))
|
|
66
|
+
fsync_directory(File.dirname(path)) if durable
|
|
48
67
|
rescue StandardError
|
|
49
68
|
tmp&.close
|
|
50
69
|
tmp&.unlink
|
|
51
70
|
raise
|
|
52
71
|
end
|
|
53
72
|
|
|
73
|
+
# Make every file under +directory+ durable with one filesystem flush.
|
|
74
|
+
#
|
|
75
|
+
# The counterpart to {.write}'s +durable: false+. Thousands of per-file
|
|
76
|
+
# +fsync+ calls and one +syncfs+ buy the same guarantee for a payload that
|
|
77
|
+
# is published all at once, and cost 71.3s against 1.0s for 8000 files on
|
|
78
|
+
# btrfs.
|
|
79
|
+
#
|
|
80
|
+
# Strategies, in order, first one that works wins:
|
|
81
|
+
#
|
|
82
|
+
# 1. +syncfs(2)+ on a descriptor for +directory+, through Fiddle. Linux
|
|
83
|
+
# only, and flushes exactly the one filesystem the payload is on.
|
|
84
|
+
# 2. +sync -f <dir>+ (GNU coreutils), the same call through a subprocess.
|
|
85
|
+
# 3. +sync+ with no arguments (BSD/macOS), which flushes everything
|
|
86
|
+
# mounted rather than one filesystem, but is still one call.
|
|
87
|
+
# 4. an +fsync+ on every file and directory in the tree.
|
|
88
|
+
#
|
|
89
|
+
# The last resort is what keeps the guarantee honest: the chain never
|
|
90
|
+
# silently does nothing, it only ever gets slower.
|
|
91
|
+
#
|
|
92
|
+
# Fiddle is a default gem through Ruby 3.4 and a bundled gem from 3.5, so
|
|
93
|
+
# the require lives inside a rescue and Fiddle is deliberately not in the
|
|
94
|
+
# gemspec. A host without it lands on +sync -f+.
|
|
95
|
+
#
|
|
96
|
+
# @param directory [String, Pathname] the tree to flush
|
|
97
|
+
# @return [Symbol, nil] the strategy that ran (+:syncfs+, +:sync_f+,
|
|
98
|
+
# +:sync+, +:fsync_pass+), or nil when the directory does not exist
|
|
99
|
+
def sync_directory_tree(directory)
|
|
100
|
+
directory = directory.to_s
|
|
101
|
+
return nil unless File.directory?(directory)
|
|
102
|
+
|
|
103
|
+
strategy = syncfs(directory) || sync_f(directory) || plain_sync || fsync_pass(directory)
|
|
104
|
+
log_sync_strategy(strategy, directory)
|
|
105
|
+
strategy
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol, nil] +:syncfs+ when the libc call succeeded
|
|
109
|
+
def syncfs(directory)
|
|
110
|
+
call = syncfs_function
|
|
111
|
+
return nil unless call
|
|
112
|
+
|
|
113
|
+
succeeded = File.open(directory, File::RDONLY) { |dir| call.call(dir.fileno).zero? }
|
|
114
|
+
succeeded ? :syncfs : nil
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolved once per process, and nil for the whole process when it cannot
|
|
120
|
+
# be. `dlopen` is not free, and a failed `require 'fiddle'` prints a
|
|
121
|
+
# bundled-gem warning on every attempt from Ruby 3.5 onward, and a publish
|
|
122
|
+
# must not emit one line of noise per generation.
|
|
123
|
+
#
|
|
124
|
+
# @return [Fiddle::Function, nil]
|
|
125
|
+
def syncfs_function
|
|
126
|
+
return @syncfs_function if defined?(@syncfs_function)
|
|
127
|
+
|
|
128
|
+
@syncfs_function = begin
|
|
129
|
+
require 'fiddle'
|
|
130
|
+
Fiddle::Function.new(Fiddle.dlopen(nil)['syncfs'], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
131
|
+
rescue LoadError, StandardError
|
|
132
|
+
# No Fiddle in the bundle, no libc symbol (macOS has none), or the
|
|
133
|
+
# handle would not open. `sync -f` is next in the chain.
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Symbol, nil] +:sync_f+ when `sync -f` exited 0
|
|
139
|
+
def sync_f(directory)
|
|
140
|
+
:sync_f if system('sync', '-f', directory, out: File::NULL, err: File::NULL)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Symbol, nil] +:sync+ when a bare `sync` exited 0
|
|
146
|
+
def plain_sync
|
|
147
|
+
:sync if system('sync', out: File::NULL, err: File::NULL)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Symbol] always +:fsync_pass+; this is the floor of the chain
|
|
153
|
+
def fsync_pass(directory)
|
|
154
|
+
Dir.glob(File.join(directory, '**', '*'), File::FNM_DOTMATCH).each do |entry|
|
|
155
|
+
next if %w[. ..].include?(File.basename(entry))
|
|
156
|
+
|
|
157
|
+
fsync_path(entry)
|
|
158
|
+
end
|
|
159
|
+
fsync_path(directory)
|
|
160
|
+
# The directory entry that names this tree lives in the parent, so a
|
|
161
|
+
# freshly created (or renamed) payload directory is only durable once
|
|
162
|
+
# the parent is flushed too. The three faster strategies flush a whole
|
|
163
|
+
# filesystem and cover this for free; only the scoped pass has to say
|
|
164
|
+
# it.
|
|
165
|
+
fsync_path(File.dirname(directory))
|
|
166
|
+
:fsync_pass
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param path [String] file or directory to flush
|
|
170
|
+
# @return [void]
|
|
171
|
+
def fsync_path(path)
|
|
172
|
+
File.open(path, File::RDONLY, &:fsync)
|
|
173
|
+
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR, Errno::ENOENT, Errno::EACCES
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [void]
|
|
178
|
+
def log_sync_strategy(strategy, directory)
|
|
179
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
180
|
+
|
|
181
|
+
Rails.logger.debug { "[Woods] payload sync via #{strategy} on #{directory}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
54
184
|
def fsync_directory(directory)
|
|
55
185
|
File.open(directory, File::RDONLY, &:fsync)
|
|
56
186
|
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR
|