woods 1.6.1 → 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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../change_set'
|
|
4
|
+
require_relative '../dependency_graph'
|
|
5
|
+
require_relative '../coordination/pipeline_lock'
|
|
6
|
+
require_relative '../atomic_file'
|
|
7
|
+
require_relative '../generation'
|
|
8
|
+
require_relative '../reload_policy'
|
|
9
|
+
require_relative 'status'
|
|
10
|
+
require_relative 'tree_scan'
|
|
11
|
+
require_relative 'watcher'
|
|
12
|
+
require 'json'
|
|
13
|
+
require 'set'
|
|
14
|
+
require 'securerandom'
|
|
15
|
+
|
|
16
|
+
module Woods
|
|
17
|
+
module Watch
|
|
18
|
+
# A resident, booted-app process that keeps the index current as files
|
|
19
|
+
# change.
|
|
20
|
+
#
|
|
21
|
+
# Woods' differentiating data — reflection-true associations, resolved
|
|
22
|
+
# callback chains, inlined concerns — requires a booted Rails app, which
|
|
23
|
+
# is why freshness has been pull-based: every sync from a cold process
|
|
24
|
+
# pays a full boot, so syncing gets batched at hook points instead of
|
|
25
|
+
# happening continuously. The boot requirement doesn't actually force
|
|
26
|
+
# batch semantics, though. A process that stays booted and re-extracts on
|
|
27
|
+
# file events gives runtime-true answers with seconds of lag. This is that
|
|
28
|
+
# process.
|
|
29
|
+
#
|
|
30
|
+
# One cycle:
|
|
31
|
+
#
|
|
32
|
+
# watch → debounce → classify → reload if needed → extract → publish
|
|
33
|
+
#
|
|
34
|
+
# ## Classification decides the cycle, not the file list
|
|
35
|
+
#
|
|
36
|
+
# {ReloadPolicy} answers "what has to happen before re-extracting is
|
|
37
|
+
# truthful", and the daemon obeys it rather than re-extracting blindly:
|
|
38
|
+
#
|
|
39
|
+
# * `:ignore` — nothing to do; no publish, no generation bump.
|
|
40
|
+
# * `:reextract` — Woods reads bytes. Extract directly.
|
|
41
|
+
# * `:reload` — an autoloaded constant changed. Reload first, or the
|
|
42
|
+
# extractors introspect classes that no longer match their source.
|
|
43
|
+
# * `:restart` — boot-captured state changed (initializers, `config/**`,
|
|
44
|
+
# `Gemfile.lock`, schema). Rails' reloader re-runs none of it, so the
|
|
45
|
+
# daemon stops and asks to be restarted rather than serving answers
|
|
46
|
+
# derived from a boot that no longer describes the app. This is
|
|
47
|
+
# Spring's contract, copied deliberately: Spring's staleness bugs came
|
|
48
|
+
# from under-scoping exactly this set.
|
|
49
|
+
#
|
|
50
|
+
# ## Failure posture
|
|
51
|
+
#
|
|
52
|
+
# A syntax error mid-edit is normal, not exceptional — it happens every
|
|
53
|
+
# time someone saves halfway through a thought. So a failed reload is not
|
|
54
|
+
# a crash: the daemon publishes a degraded {Status} naming the reason,
|
|
55
|
+
# leaves the index intact at its last good generation, and tries again on
|
|
56
|
+
# the next event. Nothing advances a cursor over work that didn't land,
|
|
57
|
+
# and the generation is bumped only after a successful write, so a reader
|
|
58
|
+
# can always tell "current" from "frozen, and here's why".
|
|
59
|
+
#
|
|
60
|
+
# ## Storms
|
|
61
|
+
#
|
|
62
|
+
# A branch switch or rebase touches hundreds of files at once. Above
|
|
63
|
+
# `full_extraction_threshold`, N incremental steps cost more than one full
|
|
64
|
+
# extraction and risk interleaving with a still-settling tree, so the
|
|
65
|
+
# daemon falls back to a full run and logs that it did.
|
|
66
|
+
#
|
|
67
|
+
# ## Startup is not a clean slate
|
|
68
|
+
#
|
|
69
|
+
# A daemon that only reacts to events it personally witnessed is stale the
|
|
70
|
+
# moment it starts: edits and pulled commits that landed while nothing was
|
|
71
|
+
# watching are invisible to it forever. That matters because callers stand
|
|
72
|
+
# down when a daemon is alive ({Status#alive?}), so "a daemon is running"
|
|
73
|
+
# has to mean "these changes are covered". {#run} therefore reconciles
|
|
74
|
+
# against the index's own watermark before waiting for its first event.
|
|
75
|
+
#
|
|
76
|
+
# ## Placement
|
|
77
|
+
#
|
|
78
|
+
# Every collaborator is injected, so this class doesn't care whether it
|
|
79
|
+
# runs as a dedicated process (`rake woods:watch`), inside an existing
|
|
80
|
+
# dev-server process, or driven a batch at a time by a host that owns its
|
|
81
|
+
# own loop. {#process} is the whole cycle for one batch and is safe to
|
|
82
|
+
# call directly — which is how the specs drive it, and how an embedded
|
|
83
|
+
# host would. It also drains anything a previous cycle carried forward, so
|
|
84
|
+
# an embedded caller gets the same retry behaviour {#run} does.
|
|
85
|
+
#
|
|
86
|
+
# @example A dedicated daemon
|
|
87
|
+
# Woods::Watch::Daemon.new(output_dir: Rails.root.join("tmp/woods")).run
|
|
88
|
+
#
|
|
89
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
90
|
+
class Daemon
|
|
91
|
+
# Seconds of quiet before a batch is considered settled. An editor
|
|
92
|
+
# save, a formatter rewriting the file, and a linter touching it again
|
|
93
|
+
# should be one cycle, not three.
|
|
94
|
+
DEFAULT_DEBOUNCE = 0.4
|
|
95
|
+
|
|
96
|
+
# Changed-file count above which a full extraction is cheaper and safer
|
|
97
|
+
# than iterating.
|
|
98
|
+
DEFAULT_FULL_EXTRACTION_THRESHOLD = 50
|
|
99
|
+
|
|
100
|
+
# Name of the file lock that serializes writers within one worktree.
|
|
101
|
+
# Worktrees are disjoint by construction — each has its own Rails.root
|
|
102
|
+
# and its own output dir — so this only ever contends with another
|
|
103
|
+
# writer against the *same* index: a manual `woods:extract`, or a hook
|
|
104
|
+
# sync that fired anyway.
|
|
105
|
+
LOCK_NAME = 'extraction'
|
|
106
|
+
|
|
107
|
+
# Name of the O_EXCL claim file that closes the startup TOCTOU: two
|
|
108
|
+
# daemons that both pass {#another_daemon_alive?} before either has
|
|
109
|
+
# published a status record would otherwise both proceed. See
|
|
110
|
+
# {#claim_startup?}.
|
|
111
|
+
CLAIM_FILENAME = 'watch_claim.json'
|
|
112
|
+
|
|
113
|
+
# A daemon cycle is milliseconds; a manual full extraction is seconds to
|
|
114
|
+
# minutes. This bounds how long a crashed writer can block the daemon.
|
|
115
|
+
LOCK_STALE_TIMEOUT = 600
|
|
116
|
+
|
|
117
|
+
# How often an otherwise idle daemon re-stamps its status file.
|
|
118
|
+
#
|
|
119
|
+
# {Status#alive?} disbelieves a record older than {Status::STALE_AFTER},
|
|
120
|
+
# and cycle boundaries are the only other thing that writes one — so
|
|
121
|
+
# without a heartbeat a *healthy* daemon reads as dead after a quiet
|
|
122
|
+
# quarter-hour, which is the single most common state for a worktree
|
|
123
|
+
# nobody is typing in. Callers would then stop standing down and start
|
|
124
|
+
# contending for its lock. A third of the window leaves room for two
|
|
125
|
+
# missed beats.
|
|
126
|
+
HEARTBEAT_INTERVAL = Status::STALE_AFTER / 3.0
|
|
127
|
+
|
|
128
|
+
# How long shutdown waits for the heartbeat thread to notice
|
|
129
|
+
# @stop_reason and exit on its own before falling back to Thread#kill.
|
|
130
|
+
#
|
|
131
|
+
# This is not the correctness backstop — #process's own `ensure`
|
|
132
|
+
# re-merges a batch the heartbeat had drained regardless of whether it
|
|
133
|
+
# exits cooperatively or gets killed, so a short window is enough. Most
|
|
134
|
+
# of the time the heartbeat is parked in its own `sleep(heartbeat_tick)`
|
|
135
|
+
# (up to HEARTBEAT_INTERVAL, minutes, when idle_timeout is unset) and
|
|
136
|
+
# will not wake up to see @stop_reason inside any bounded wait — this
|
|
137
|
+
# only pays off when it happens to already be near the end of a cycle,
|
|
138
|
+
# which most daemon cycles are (a single-file cycle is milliseconds).
|
|
139
|
+
HEARTBEAT_SHUTDOWN_TIMEOUT = 2
|
|
140
|
+
|
|
141
|
+
# @return [Woods::Generation]
|
|
142
|
+
attr_reader :generation
|
|
143
|
+
|
|
144
|
+
# @return [Woods::Watch::Status]
|
|
145
|
+
attr_reader :status
|
|
146
|
+
|
|
147
|
+
# @param output_dir [String, Pathname] index directory
|
|
148
|
+
# @param root [String, Pathname] application root (defaults to Rails.root)
|
|
149
|
+
# @param extractor_factory [#call] returns a fresh {Woods::Extractor}
|
|
150
|
+
# @param reloader [#reload!, #enabled?] Rails reload adapter
|
|
151
|
+
# @param watcher [#start, #stop, nil] built from config when nil
|
|
152
|
+
# @param policy [Woods::ReloadPolicy]
|
|
153
|
+
# @param debounce [Float] quiet window in seconds
|
|
154
|
+
# @param full_extraction_threshold [Integer]
|
|
155
|
+
# @param idle_timeout [Numeric, nil] stop after this many idle seconds
|
|
156
|
+
# @param lock [Woods::Coordination::PipelineLock, nil] writer lock for
|
|
157
|
+
# this index; built from {LOCK_NAME} when nil
|
|
158
|
+
# @param catch_up [Boolean] reconcile changes that predate startup
|
|
159
|
+
# @param force_polling [Boolean] never use the `listen` backend — the
|
|
160
|
+
# right choice across a container bind mount, where native FS events
|
|
161
|
+
# do not propagate
|
|
162
|
+
# @param logger [#info, #warn, #error]
|
|
163
|
+
# rubocop:disable-next Metrics/ParameterLists -- every collaborator is
|
|
164
|
+
# injectable on purpose; that is what makes the daemon placement-agnostic
|
|
165
|
+
# and drivable from a spec without Rails.
|
|
166
|
+
def initialize(output_dir:, root: nil, extractor_factory: nil, reloader: nil, watcher: nil,
|
|
167
|
+
policy: ReloadPolicy.new, debounce: DEFAULT_DEBOUNCE,
|
|
168
|
+
full_extraction_threshold: DEFAULT_FULL_EXTRACTION_THRESHOLD,
|
|
169
|
+
idle_timeout: nil, lock: nil, catch_up: true, force_polling: false, logger: nil)
|
|
170
|
+
@output_dir = output_dir.to_s
|
|
171
|
+
@root = (root || (defined?(Rails) ? Rails.root : Dir.pwd)).to_s
|
|
172
|
+
@extractor_factory = extractor_factory || -> { Woods::Extractor.new(output_dir: @output_dir) }
|
|
173
|
+
@reloader = reloader || RailsReloader.new
|
|
174
|
+
@watcher = watcher
|
|
175
|
+
@policy = policy
|
|
176
|
+
@debounce = debounce
|
|
177
|
+
@full_extraction_threshold = full_extraction_threshold
|
|
178
|
+
@idle_timeout = idle_timeout
|
|
179
|
+
@catch_up = catch_up
|
|
180
|
+
@force_polling = force_polling
|
|
181
|
+
@logger = logger || default_logger
|
|
182
|
+
@generation = Generation.new(output_dir: @output_dir)
|
|
183
|
+
@status = Status.new(output_dir: @output_dir)
|
|
184
|
+
@lock = lock || default_lock
|
|
185
|
+
reset_cycle_state
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Watch until stopped, a restart is required, or the idle timeout fires.
|
|
189
|
+
#
|
|
190
|
+
# @return [Symbol] why the loop ended — `:stopped`, `:restart_required`,
|
|
191
|
+
# `:idle`, or `:already_running` when another daemon already covers this
|
|
192
|
+
# index
|
|
193
|
+
def run
|
|
194
|
+
# Standing down must leave no trace. The shutdown writes live in
|
|
195
|
+
# {#run_started}'s `ensure`, which Ruby runs on an early `return` too —
|
|
196
|
+
# so hanging them off *this* method had the daemon that correctly
|
|
197
|
+
# refused to start persist an empty pending file over the live daemon's
|
|
198
|
+
# carried paths and publish `stopped` under its own pid over the live
|
|
199
|
+
# `running` record. Until the live daemon's next heartbeat re-stamped
|
|
200
|
+
# the truth (up to {HEARTBEAT_INTERVAL}), `woods:watch_status` read
|
|
201
|
+
# dead — a `watch_status || start` hook booted a third daemon and
|
|
202
|
+
# `woods:incremental` stopped standing down.
|
|
203
|
+
return :already_running if another_daemon_alive?
|
|
204
|
+
|
|
205
|
+
# {#another_daemon_alive?} is a plain status READ — cheap, and gives
|
|
206
|
+
# the useful log message above, but proves nothing under a race: two
|
|
207
|
+
# daemons starting together can both read "nothing running yet" and
|
|
208
|
+
# both pass. #claim_startup? is the atomic gate that actually decides
|
|
209
|
+
# who gets to proceed.
|
|
210
|
+
return :already_running unless claim_startup?
|
|
211
|
+
|
|
212
|
+
run_started
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Stop the loop at the next opportunity.
|
|
216
|
+
#
|
|
217
|
+
# @return [void]
|
|
218
|
+
def stop
|
|
219
|
+
@stop_reason = :stopped
|
|
220
|
+
@watcher&.stop
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Run one full cycle for a batch of changed paths.
|
|
224
|
+
#
|
|
225
|
+
# This is the daemon's whole behaviour; {#run} only supplies batches.
|
|
226
|
+
# Calling it directly is the supported way to embed the daemon in a
|
|
227
|
+
# process that owns its own event loop.
|
|
228
|
+
#
|
|
229
|
+
# @param paths [Array<String>] changed paths, absolute or root-relative.
|
|
230
|
+
# Anything a previous cycle carried forward is folded in.
|
|
231
|
+
# @return [Hash] `{ action:, state:, generation:, reason:, count:,
|
|
232
|
+
# duration_ms: }`
|
|
233
|
+
def process(paths = [])
|
|
234
|
+
change_set = ChangeSet.new(paths: drain_with(paths), root: @root)
|
|
235
|
+
cycle_completed = false
|
|
236
|
+
|
|
237
|
+
begin
|
|
238
|
+
result = case required_action(change_set)
|
|
239
|
+
when :ignore then nothing_to_do
|
|
240
|
+
when :restart then require_restart(change_set)
|
|
241
|
+
when :reload then attempt_reload ? extract(change_set) : degraded_reload(change_set)
|
|
242
|
+
else extract(change_set)
|
|
243
|
+
end
|
|
244
|
+
cycle_completed = true
|
|
245
|
+
result
|
|
246
|
+
ensure
|
|
247
|
+
# Thread#kill bypasses rescue, but not ensure — a thread killed
|
|
248
|
+
# anywhere in this dispatch (mid-extraction, most commonly, when a
|
|
249
|
+
# shutdown's heartbeat.kill lands on a thread doing real work) would
|
|
250
|
+
# otherwise lose the batch #drain_with already popped from @pending.
|
|
251
|
+
# Every branch above that finishes normally already carries its own
|
|
252
|
+
# paths forward on failure (or intentionally doesn't, e.g. :restart);
|
|
253
|
+
# this only fires for the abnormal case none of them can catch.
|
|
254
|
+
carry_forward(change_set) unless cycle_completed
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
# The watch loop proper, split from {#run} so the shutdown `ensure` —
|
|
261
|
+
# publish `stopped`, persist carried paths — can only ever fire for a
|
|
262
|
+
# daemon that actually started. A stand-down returns from {#run} without
|
|
263
|
+
# entering this method, so nothing it does on the way out can clobber
|
|
264
|
+
# the live daemon's records.
|
|
265
|
+
#
|
|
266
|
+
# The watcher starts on its own thread BEFORE {#catch_up} runs, not
|
|
267
|
+
# after — see {#launch_watcher}. {#catch_up} still runs to completion
|
|
268
|
+
# on this thread; only once it returns do we park alongside the
|
|
269
|
+
# watcher, waiting for its next event or a stop.
|
|
270
|
+
def run_started
|
|
271
|
+
@watcher ||= build_watcher
|
|
272
|
+
publish_status(:running, reason: nil)
|
|
273
|
+
@last_event_at = monotonic_now
|
|
274
|
+
heartbeat = start_heartbeat
|
|
275
|
+
|
|
276
|
+
watcher_thread = launch_watcher do |paths|
|
|
277
|
+
enqueue(paths)
|
|
278
|
+
drain
|
|
279
|
+
end
|
|
280
|
+
catch_up
|
|
281
|
+
watcher_thread.join
|
|
282
|
+
raise @watcher_failure if @watcher_failure
|
|
283
|
+
|
|
284
|
+
@stop_reason || :stopped
|
|
285
|
+
ensure
|
|
286
|
+
shut_down(heartbeat, watcher_thread)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# @param heartbeat [Thread, nil]
|
|
290
|
+
# @param watcher_thread [Thread, nil]
|
|
291
|
+
# @return [void]
|
|
292
|
+
def shut_down(heartbeat, watcher_thread)
|
|
293
|
+
# The watch loop can end without anything having set a reason — a
|
|
294
|
+
# `stop` that landed before the watcher noticed it, for instance. Set
|
|
295
|
+
# one before touching the heartbeat: its loop's own exit check is
|
|
296
|
+
# `break if @stop_reason`, so a heartbeat that is actually awake right
|
|
297
|
+
# now (as opposed to parked in its long idle sleep) needs this to see
|
|
298
|
+
# the run is ending at all. Doesn't affect {#run_started}'s return
|
|
299
|
+
# value — that already evaluated before this `ensure` ran.
|
|
300
|
+
@stop_reason ||= :stopped
|
|
301
|
+
# Cooperative first: ask the watcher to stop and let its thread notice
|
|
302
|
+
# and exit on its own. #kill bypasses rescue (not ensure — #process
|
|
303
|
+
# guards the batch it drains either way), so it is the last resort,
|
|
304
|
+
# not the first move.
|
|
305
|
+
@watcher&.stop
|
|
306
|
+
# A no-op when {#run_started}'s body already joined normally; this
|
|
307
|
+
# only does real work when something raised (from #catch_up, most
|
|
308
|
+
# plausibly) before reaching that line, and it must still happen
|
|
309
|
+
# before #persist_pending — otherwise a watcher thread still draining
|
|
310
|
+
# a batch races the shutdown snapshot of @pending.
|
|
311
|
+
watcher_thread&.join
|
|
312
|
+
stop_heartbeat(heartbeat)
|
|
313
|
+
# Before #persist_pending, for the same reason the watcher join is: a
|
|
314
|
+
# retry drain still running would race the shutdown snapshot of
|
|
315
|
+
# @pending.
|
|
316
|
+
stop_retry(@retry_thread)
|
|
317
|
+
persist_pending
|
|
318
|
+
publish_status(:stopped, reason: @stop_reason&.to_s)
|
|
319
|
+
release_claim
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Start the watcher backend on its own thread, ahead of {#catch_up}.
|
|
323
|
+
#
|
|
324
|
+
# A file saved while catch_up's reconciliation (potentially a
|
|
325
|
+
# minutes-long, storm-triggered full extraction) was running used to be
|
|
326
|
+
# lost twice over: no watcher existed yet to report it as an event, and
|
|
327
|
+
# {PollingWatcher} takes its baseline snapshot inside `start` — after
|
|
328
|
+
# the save, so the first diff already excludes it. Worse, the save's
|
|
329
|
+
# mtime predates the generation bump catch_up's own extraction publishes
|
|
330
|
+
# at the end, so a future restart's watermark check reads the file as
|
|
331
|
+
# already covered — permanently. Starting the watcher first closes that
|
|
332
|
+
# window: {#enqueue} and {#drain} already tolerate the duplicate paths
|
|
333
|
+
# this produces against whatever catch_up finds on its own via the tree
|
|
334
|
+
# scan.
|
|
335
|
+
#
|
|
336
|
+
# Exceptions are captured here rather than left to `Thread`'s own
|
|
337
|
+
# unhandled-exception handling, so {#run_started} can re-raise the
|
|
338
|
+
# *same* error exactly once — after giving catch_up its turn — instead
|
|
339
|
+
# of `Thread#join` re-raising it again on every subsequent join,
|
|
340
|
+
# including the defensive one in this method's `ensure`.
|
|
341
|
+
#
|
|
342
|
+
# @yieldparam paths [Array<String>] absolute paths from one watcher batch
|
|
343
|
+
# @return [Thread]
|
|
344
|
+
def launch_watcher(&on_change)
|
|
345
|
+
Thread.new do
|
|
346
|
+
start_watching(&on_change)
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
@watcher_failure = e
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @param heartbeat [Thread, nil]
|
|
353
|
+
# @return [void]
|
|
354
|
+
def stop_heartbeat(heartbeat)
|
|
355
|
+
return unless heartbeat
|
|
356
|
+
|
|
357
|
+
heartbeat.join(HEARTBEAT_SHUTDOWN_TIMEOUT) || heartbeat.kill
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# What this batch demands, with one escalation applied: an app that cannot
|
|
361
|
+
# reload at all (`config.enable_reloading = false` — the production
|
|
362
|
+
# default, and common in staging-shaped dev containers) can only honour a
|
|
363
|
+
# `:reload` by restarting, since extracting against constants that no
|
|
364
|
+
# longer match their source is the thing the classification exists to
|
|
365
|
+
# prevent.
|
|
366
|
+
def required_action(change_set)
|
|
367
|
+
action = @policy.classify_all(change_set.relative_paths)
|
|
368
|
+
return :restart if action == :reload && !@reloader.enabled?
|
|
369
|
+
|
|
370
|
+
action
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# An all-ignorable batch is not evidence that a previously degraded
|
|
374
|
+
# condition cleared — nothing was retried, so nothing was proven. Flipping
|
|
375
|
+
# back to `running` here would advertise a healthy index while the reload
|
|
376
|
+
# that failed is still failing.
|
|
377
|
+
def nothing_to_do
|
|
378
|
+
outcome(:ignore, @degraded_reason ? :degraded : :running, reason: @degraded_reason)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def default_lock
|
|
382
|
+
Coordination::PipelineLock.new(
|
|
383
|
+
lock_dir: @output_dir, name: LOCK_NAME, stale_timeout: LOCK_STALE_TIMEOUT
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def reset_cycle_state
|
|
388
|
+
@pending = Set.new
|
|
389
|
+
@pending_mutex = Mutex.new
|
|
390
|
+
@stop_reason = nil
|
|
391
|
+
@drain_mutex = Mutex.new
|
|
392
|
+
@retry_thread = nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Wait out the debounce window so events that land during it join the
|
|
396
|
+
# same cycle.
|
|
397
|
+
#
|
|
398
|
+
# This only coalesces because the watcher callback merges into `@pending`
|
|
399
|
+
# and returns immediately ({#enqueue}) rather than processing inline —
|
|
400
|
+
# so a save, the formatter's rewrite, and the linter's touch accumulate
|
|
401
|
+
# here and {#process} drains all three as one batch. Sleeping alone would
|
|
402
|
+
# just delay the first of three cycles.
|
|
403
|
+
def settle
|
|
404
|
+
sleep(@debounce) if @debounce.to_f.positive?
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Merge a watcher batch into the pending set without processing it.
|
|
408
|
+
def enqueue(paths)
|
|
409
|
+
absolute = ChangeSet.new(paths: paths, root: @root).absolute_paths
|
|
410
|
+
@pending_mutex.synchronize { @pending.merge(absolute) }
|
|
411
|
+
@last_event_at = monotonic_now
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Fold in anything a previous cycle could not process.
|
|
415
|
+
#
|
|
416
|
+
# A cycle skipped for lock contention, a failed reload, or a raising
|
|
417
|
+
# extraction must not lose its paths — the files really did change, and
|
|
418
|
+
# no later event will mention them again. They ride along with the next
|
|
419
|
+
# batch instead.
|
|
420
|
+
def drain_with(paths)
|
|
421
|
+
@pending_mutex.synchronize do
|
|
422
|
+
carried = @pending.to_a
|
|
423
|
+
@pending = Set.new
|
|
424
|
+
carried.empty? ? Array(paths) : (carried + Array(paths)).uniq
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def carry_forward(change_set)
|
|
429
|
+
@pending_mutex.synchronize { @pending.merge(change_set.absolute_paths) }
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Run cycles until the pending set is empty. One call per watcher batch;
|
|
433
|
+
# re-entrant calls return immediately so listen's thread pool cannot run
|
|
434
|
+
# two cycles against one index.
|
|
435
|
+
#
|
|
436
|
+
# `try_lock`, not a boolean: check-then-set on an ivar is exactly the
|
|
437
|
+
# race it is guarding against — two callback threads could both read
|
|
438
|
+
# `false` before either wrote `true` and run two overlapping drains. The
|
|
439
|
+
# loser's paths are already in `@pending` (the callback enqueues before
|
|
440
|
+
# calling here), so the winner's loop picks them up; nothing is lost by
|
|
441
|
+
# returning. `try_lock` also returns false on same-thread re-entry, so a
|
|
442
|
+
# synchronous callback fired from inside a cycle cannot deadlock.
|
|
443
|
+
def drain
|
|
444
|
+
return unless @drain_mutex.try_lock
|
|
445
|
+
|
|
446
|
+
begin
|
|
447
|
+
drain_cycles
|
|
448
|
+
ensure
|
|
449
|
+
# An extraction is work, not idleness. Stamping only on the event
|
|
450
|
+
# would let a cycle longer than `idle_timeout` read as a quiet
|
|
451
|
+
# daemon and stop the watcher mid-run.
|
|
452
|
+
@last_event_at = monotonic_now
|
|
453
|
+
@drain_mutex.unlock
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def drain_cycles
|
|
458
|
+
until pending_empty? || @stop_reason
|
|
459
|
+
settle
|
|
460
|
+
result = process
|
|
461
|
+
|
|
462
|
+
if result[:action] == :restart
|
|
463
|
+
# @watcher, not a captured local — start_watching's polling
|
|
464
|
+
# fallback can have reassigned it earlier in this same run
|
|
465
|
+
# (daemon.rb's start_watching), and stopping the discarded
|
|
466
|
+
# pre-fallback watcher leaves the one actually running untouched.
|
|
467
|
+
@watcher&.stop
|
|
468
|
+
break
|
|
469
|
+
end
|
|
470
|
+
# A degraded cycle deliberately carried its paths forward. Retrying
|
|
471
|
+
# them in a tight loop would spin on a failure that needs an edit to
|
|
472
|
+
# clear, so wait for the next event.
|
|
473
|
+
break if result[:state] == :degraded
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def pending_empty?
|
|
478
|
+
@pending_mutex.synchronize { @pending.empty? }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Carry the pending set across a restart.
|
|
482
|
+
#
|
|
483
|
+
# Within a run, carried paths survive; at shutdown they were dropped, and
|
|
484
|
+
# recovery fell to the mtime watermark — which does not cover them. If
|
|
485
|
+
# another writer bumps the generation *after* the daemon carried a path
|
|
486
|
+
# forward, the watermark is newer than the file's mtime and catch-up skips
|
|
487
|
+
# it: the change is lost for good while the status says `running`.
|
|
488
|
+
# Sequence: save `user.rb`; a hook sync holds the lock; the daemon's cycle
|
|
489
|
+
# contends and carries it; the hook finishes and bumps; the daemon stops.
|
|
490
|
+
def persist_pending
|
|
491
|
+
paths = @pending_mutex.synchronize { @pending.to_a }
|
|
492
|
+
return AtomicFile.write(pending_path, JSON.generate([])) if paths.empty?
|
|
493
|
+
|
|
494
|
+
@logger.info("[Woods] watch: persisting #{paths.size} unindexed path(s) for the next run")
|
|
495
|
+
AtomicFile.write(pending_path, JSON.generate(paths))
|
|
496
|
+
rescue StandardError => e
|
|
497
|
+
@logger.warn("[Woods] watch: could not persist pending paths — #{e.message}")
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Paths a previous run owed, reloaded at startup so catch-up covers them
|
|
501
|
+
# regardless of what the watermark says.
|
|
502
|
+
def restore_pending
|
|
503
|
+
return [] unless File.exist?(pending_path)
|
|
504
|
+
|
|
505
|
+
paths = JSON.parse(AtomicFile.read(pending_path))
|
|
506
|
+
return [] unless paths.is_a?(Array) && paths.any?
|
|
507
|
+
|
|
508
|
+
@logger.info("[Woods] watch: #{paths.size} path(s) carried over from the previous run")
|
|
509
|
+
paths.grep(String)
|
|
510
|
+
rescue StandardError
|
|
511
|
+
[]
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def pending_path
|
|
515
|
+
File.join(@output_dir, 'watch_pending.json')
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Reconcile changes that predate this daemon.
|
|
519
|
+
#
|
|
520
|
+
# The generation file is rewritten as the last act of every successful
|
|
521
|
+
# extraction, so its mtime is "when this index was last known good".
|
|
522
|
+
# Anything modified since is uncovered, whoever made the change and
|
|
523
|
+
# whether or not a daemon was watching at the time. With no generation
|
|
524
|
+
# file at all there is no index, and every file is uncovered — which the
|
|
525
|
+
# storm threshold correctly turns into one full extraction.
|
|
526
|
+
#
|
|
527
|
+
# @return [void]
|
|
528
|
+
def catch_up
|
|
529
|
+
return unless @catch_up
|
|
530
|
+
|
|
531
|
+
carried = restore_pending
|
|
532
|
+
paths = (uncovered_paths + carried).uniq
|
|
533
|
+
if paths.empty?
|
|
534
|
+
return reconcile_deletions if stale_deletions?
|
|
535
|
+
|
|
536
|
+
return @logger.info('[Woods] watch: index is current at startup')
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
@logger.info("[Woods] watch: #{paths.size} path(s) changed before startup — catching up")
|
|
540
|
+
enqueue(paths)
|
|
541
|
+
drain
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# A file deleted while nothing was watching leaves no mtime for the scan
|
|
545
|
+
# to see — {TreeScan} only walks files that exist — so a deletion-only
|
|
546
|
+
# downtime would log "index is current" while ghost units survive. The
|
|
547
|
+
# graph knows every path it attributed a unit to; any of those gone from
|
|
548
|
+
# disk means the extractor's sweep has reconciling to do.
|
|
549
|
+
#
|
|
550
|
+
# The daemon only *detects*; it does not name the paths. Naming them
|
|
551
|
+
# would put them in the change set, whose deletions are authoritative for
|
|
552
|
+
# any unit type — and some registered paths are nominal (on Rails < 7.1,
|
|
553
|
+
# `ActiveRecord::SchemaMigration` registers a convention path no app
|
|
554
|
+
# has), so authoritative deletion would remove units a full extraction
|
|
555
|
+
# still produces. An empty-change-set run reaches the same ghosts through
|
|
556
|
+
# the sweep, which carries the bounds that make it safe.
|
|
557
|
+
def stale_deletions?
|
|
558
|
+
root_prefix = "#{@root}/"
|
|
559
|
+
|
|
560
|
+
persisted_registered_paths.any? do |path|
|
|
561
|
+
path.start_with?(root_prefix) && !File.exist?(path)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# The graph persists **relative** paths (#166) so the index is portable off
|
|
566
|
+
# the machine that wrote it. They have to be absolutized here, because the
|
|
567
|
+
# caller tests them with `File.exist?` and bounds the check with
|
|
568
|
+
# `start_with?(root_prefix)` — against relative paths that guard excludes
|
|
569
|
+
# every entry, which would silently disable deletion reconciliation
|
|
570
|
+
# entirely rather than fail visibly.
|
|
571
|
+
#
|
|
572
|
+
# A path that is already absolute passes through, which covers a graph
|
|
573
|
+
# written before #166 as well as genuinely out-of-tree paths (gem sources)
|
|
574
|
+
# that the caller's prefix check is there to exclude.
|
|
575
|
+
def persisted_registered_paths
|
|
576
|
+
payload = Woods::Generation.new(output_dir: @output_dir).payload_dir
|
|
577
|
+
graph = File.join(payload.to_s, 'dependency_graph.json')
|
|
578
|
+
file_map = JSON.parse(AtomicFile.read(graph))['file_map']
|
|
579
|
+
return [] unless file_map.is_a?(Hash)
|
|
580
|
+
|
|
581
|
+
file_map.keys.map { |path| Woods::DependencyGraph.absolutize(path, @root) }
|
|
582
|
+
rescue SystemCallError, JSON::ParserError
|
|
583
|
+
[]
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def reconcile_deletions
|
|
587
|
+
@logger.info('[Woods] watch: registered file(s) vanished before startup — reconciling deletions')
|
|
588
|
+
extract(ChangeSet.new(paths: [], root: @root))
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def uncovered_paths
|
|
592
|
+
watermark = index_watermark
|
|
593
|
+
TreeScan.files(root: @root, ignored: ignored_directories)
|
|
594
|
+
.select { |path| uncovered?(path, watermark) }
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def uncovered?(path, watermark)
|
|
598
|
+
return true if watermark.nil?
|
|
599
|
+
|
|
600
|
+
File.mtime(path).to_f > watermark
|
|
601
|
+
rescue SystemCallError
|
|
602
|
+
false
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# When this index was last known good, or nil when there is no usable
|
|
606
|
+
# index — which the storm threshold turns into one full extraction.
|
|
607
|
+
#
|
|
608
|
+
# A dangling payload pointer counts as "no index" (INF-10). The marker
|
|
609
|
+
# can outlive the directory it names (a partial restore from a CI
|
|
610
|
+
# artifact, an external cleanup targeting the large directories), and
|
|
611
|
+
# {Generation#payload_dir} deliberately degrades to the index root for
|
|
612
|
+
# *readers* — so a gutted index would otherwise read "current at
|
|
613
|
+
# startup", publish `running`, and have every caller stand down over a
|
|
614
|
+
# directory holding no index at all. "Alive means covered" is the
|
|
615
|
+
# daemon's contract; here it would have been false.
|
|
616
|
+
#
|
|
617
|
+
# @return [Float, nil]
|
|
618
|
+
def index_watermark
|
|
619
|
+
return nil if dangling_payload_pointer?
|
|
620
|
+
|
|
621
|
+
File.mtime(@generation.path).to_f
|
|
622
|
+
rescue SystemCallError
|
|
623
|
+
nil
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# @return [Boolean] true when the marker names a payload directory that
|
|
627
|
+
# no longer resolves
|
|
628
|
+
def dangling_payload_pointer?
|
|
629
|
+
marker = @generation.current
|
|
630
|
+
name = marker.payload
|
|
631
|
+
return false if name.nil? || name.empty?
|
|
632
|
+
|
|
633
|
+
@generation.payload_dir(marker) == @generation.root
|
|
634
|
+
rescue ScriptError, StandardError
|
|
635
|
+
false
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Keep the status file believable, and stop a dormant daemon.
|
|
639
|
+
#
|
|
640
|
+
# Two jobs, one timer. The heartbeat exists because {Status#alive?}
|
|
641
|
+
# disbelieves an old record (see {HEARTBEAT_INTERVAL}); the idle stop
|
|
642
|
+
# exists because N worktrees means N booted apps and most are dormant
|
|
643
|
+
# most of the time — a slot nobody is working in should not hold ~65 MB
|
|
644
|
+
# waiting to be needed, and a worktree hook or session start revives it.
|
|
645
|
+
# Idle stopping is off by default; the heartbeat is not.
|
|
646
|
+
#
|
|
647
|
+
# @return [Thread]
|
|
648
|
+
def start_heartbeat
|
|
649
|
+
Thread.new do
|
|
650
|
+
loop do
|
|
651
|
+
sleep(heartbeat_tick)
|
|
652
|
+
break if @stop_reason
|
|
653
|
+
|
|
654
|
+
if idle_expired?
|
|
655
|
+
@logger.info("[Woods] watch: idle for #{@idle_timeout}s — exiting")
|
|
656
|
+
@stop_reason = :idle
|
|
657
|
+
# @watcher, not a captured local — see the same note in
|
|
658
|
+
# #drain_cycles. Idle-stop is exactly the path that missed this:
|
|
659
|
+
# a daemon that fell back to polling and then went idle never
|
|
660
|
+
# actually stopped, because it stopped the discarded watcher.
|
|
661
|
+
@watcher&.stop
|
|
662
|
+
break
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
restamp_status
|
|
666
|
+
# A storm-triggered full extraction can outlive LOCK_STALE_TIMEOUT,
|
|
667
|
+
# at which point a contender would retire the lock of a run that is
|
|
668
|
+
# still going. The holder has to keep saying it is alive.
|
|
669
|
+
@lock.touch if @lock.respond_to?(:touch)
|
|
670
|
+
retry_pending
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Retry work a degraded cycle carried forward, without waiting for a new
|
|
676
|
+
# file event.
|
|
677
|
+
#
|
|
678
|
+
# `drain_cycles` stops on a degraded result on purpose — retrying in a
|
|
679
|
+
# tight loop would spin on a cause that needs an edit to clear. But then
|
|
680
|
+
# only a *new* event starts another drain, so paths carried past a
|
|
681
|
+
# contending writer sit unindexed for as long as the developer happens to
|
|
682
|
+
# stop typing. The heartbeat is already the right cadence for "try that
|
|
683
|
+
# again": slow enough not to spin, frequent enough that a finished
|
|
684
|
+
# contender is noticed in minutes rather than never.
|
|
685
|
+
#
|
|
686
|
+
# The drain runs on its **own** thread, not this one (INF-2). Running it
|
|
687
|
+
# inline meant the heartbeat stopped restamping the status and stopped
|
|
688
|
+
# touching the lock for the whole duration of the retried extraction —
|
|
689
|
+
# and a retried storm (`extract_all` on a large host) can outlive
|
|
690
|
+
# {LOCK_STALE_TIMEOUT}, at which point any waiting writer retires the
|
|
691
|
+
# live lock and two writers clobber one index. {#drain}'s `try_lock`
|
|
692
|
+
# already refuses overlapping drains; the `alive?` check keeps
|
|
693
|
+
# consecutive heartbeat ticks from stacking threads behind it.
|
|
694
|
+
def retry_pending
|
|
695
|
+
return if pending_empty? || @stop_reason
|
|
696
|
+
return if @retry_thread&.alive?
|
|
697
|
+
|
|
698
|
+
@logger.info('[Woods] watch: retrying paths carried forward from an earlier cycle')
|
|
699
|
+
@retry_thread = start_retry_thread
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# @return [Thread] a thread running one drain
|
|
703
|
+
def start_retry_thread
|
|
704
|
+
thread = Thread.new do
|
|
705
|
+
drain
|
|
706
|
+
rescue ScriptError, StandardError => e
|
|
707
|
+
# #run_extraction rescues its own failures, so reaching here means
|
|
708
|
+
# something outside it broke. Log rather than let `join` re-raise it
|
|
709
|
+
# into the shutdown path.
|
|
710
|
+
@logger.error("[Woods] watch: retry cycle failed — #{e.class}: #{e.message}")
|
|
711
|
+
end
|
|
712
|
+
thread.report_on_exception = false
|
|
713
|
+
thread
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Wind down a retry drain at shutdown.
|
|
717
|
+
#
|
|
718
|
+
# Bounded exactly like {#stop_heartbeat}: before INF-2 the retry ran on
|
|
719
|
+
# the heartbeat thread and inherited that thread's join-then-kill, so
|
|
720
|
+
# this preserves the shutdown timing rather than making a long retry
|
|
721
|
+
# hold the process open. {#shut_down} sets +@stop_reason+ first, so
|
|
722
|
+
# {#drain_cycles} ends after the cycle already in flight.
|
|
723
|
+
#
|
|
724
|
+
# @param thread [Thread, nil]
|
|
725
|
+
# @return [void]
|
|
726
|
+
def stop_retry(thread)
|
|
727
|
+
return unless thread
|
|
728
|
+
|
|
729
|
+
thread.join(HEARTBEAT_SHUTDOWN_TIMEOUT) || thread.kill
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def heartbeat_tick
|
|
733
|
+
ticks = [HEARTBEAT_INTERVAL]
|
|
734
|
+
ticks << (@idle_timeout / 4.0) if @idle_timeout.to_f.positive?
|
|
735
|
+
[ticks.min, 0.05].max
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def idle_expired?
|
|
739
|
+
return false unless @idle_timeout.to_f.positive?
|
|
740
|
+
return false if @drain_mutex.locked?
|
|
741
|
+
|
|
742
|
+
monotonic_now - @last_event_at >= @idle_timeout
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Rewrite the last published record so its timestamp stays fresh. Note it
|
|
746
|
+
# republishes the *last* state rather than `:running` — a degraded daemon
|
|
747
|
+
# is still degraded between events, and saying otherwise is the one thing
|
|
748
|
+
# the status file exists to prevent.
|
|
749
|
+
def restamp_status
|
|
750
|
+
record = @last_status
|
|
751
|
+
return if record.nil?
|
|
752
|
+
|
|
753
|
+
@status.write(**record)
|
|
754
|
+
rescue StandardError => e
|
|
755
|
+
@logger.warn("[Woods] watch: could not refresh status — #{e.message}")
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Start the watcher, falling back to polling if the native backend cannot
|
|
759
|
+
# start at all (inotify exhaustion being the usual reason). A daemon that
|
|
760
|
+
# costs some CPU beats one that silently never fires.
|
|
761
|
+
def start_watching(&on_change)
|
|
762
|
+
@watcher.start(&on_change)
|
|
763
|
+
rescue WatcherError => e
|
|
764
|
+
raise if @polling_fallback
|
|
765
|
+
|
|
766
|
+
@polling_fallback = true
|
|
767
|
+
@logger.warn("[Woods] watch: #{e.message} — falling back to polling")
|
|
768
|
+
# `ignored:` must travel with the rebuild (INF-1). Dropping it re-arms
|
|
769
|
+
# the output-directory feedback loop {#ignored_directories} exists to
|
|
770
|
+
# break, on precisely the path a large tree reaches.
|
|
771
|
+
@watcher = Watcher.build(
|
|
772
|
+
root: @root, ignored: ignored_directories, logger: @logger, force_polling: true
|
|
773
|
+
)
|
|
774
|
+
@watcher.start(&on_change)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def attempt_reload
|
|
778
|
+
@reload_error = nil
|
|
779
|
+
@reloader.reload!
|
|
780
|
+
true
|
|
781
|
+
rescue ScriptError, StandardError => e
|
|
782
|
+
# A syntax error mid-edit lands here and is completely routine. Note
|
|
783
|
+
# ScriptError: SyntaxError is not a StandardError, so rescuing only
|
|
784
|
+
# StandardError would let a half-typed file kill the daemon.
|
|
785
|
+
@reload_error = "#{e.class}: #{e.message}"
|
|
786
|
+
@logger.warn("[Woods] watch: reload failed — #{@reload_error}")
|
|
787
|
+
false
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# The reload failed, so the constants no longer match their source and
|
|
791
|
+
# extracting now would publish introspection of a stale class graph.
|
|
792
|
+
#
|
|
793
|
+
# The paths still have to survive. A developer who saves a valid
|
|
794
|
+
# `post.rb` while `user.rb` sits half-typed gets one app-wide reload
|
|
795
|
+
# failure covering both; when `user.rb` is fixed, that event names only
|
|
796
|
+
# `user.rb`, and `post.rb`'s change would never reach the index at all.
|
|
797
|
+
def degraded_reload(change_set)
|
|
798
|
+
carry_forward(change_set)
|
|
799
|
+
outcome(:reload, :degraded, reason: @reload_error, count: change_set.size)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def require_restart(change_set)
|
|
803
|
+
triggers = @policy.paths_requiring(change_set.relative_paths, :restart)
|
|
804
|
+
reason = "restart required: #{triggers.first(5).join(', ')}"
|
|
805
|
+
@logger.warn("[Woods] watch: #{reason}")
|
|
806
|
+
@stop_reason = :restart_required
|
|
807
|
+
outcome(:restart, :degraded, reason: reason, count: change_set.size)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def extract(change_set)
|
|
811
|
+
started = monotonic_now
|
|
812
|
+
acquired = acquire_lock_for(change_set, started)
|
|
813
|
+
return acquired if acquired.is_a?(Hash) # a raising acquire already built its own degraded outcome
|
|
814
|
+
return contended(change_set, started) unless acquired
|
|
815
|
+
|
|
816
|
+
begin
|
|
817
|
+
run_extraction(change_set, started)
|
|
818
|
+
ensure
|
|
819
|
+
# Best-effort: {#run_extraction} already decided the outcome (which
|
|
820
|
+
# this `ensure` must not touch), so a release that raises here is
|
|
821
|
+
# logged, not escalated. It used to share the method-level rescue
|
|
822
|
+
# below with the acquire — which mislabeled a release failure after
|
|
823
|
+
# a *successful* run as "could not take the extraction lock" and,
|
|
824
|
+
# because an exception raised in `ensure` replaces whatever the
|
|
825
|
+
# `begin` block was about to return, silently discarded that good
|
|
826
|
+
# outcome along with it.
|
|
827
|
+
release_lock_quietly
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Acquiring is itself IO and can fail (a read-only or full output dir).
|
|
832
|
+
# Scoped to just the acquire call so a failure here is never confused
|
|
833
|
+
# with a failure in the extraction or the release that follows it.
|
|
834
|
+
#
|
|
835
|
+
# @return [Boolean, Hash] the acquire result, or a ready-made degraded
|
|
836
|
+
# {#outcome} when acquiring itself raised
|
|
837
|
+
def acquire_lock_for(change_set, started)
|
|
838
|
+
@lock.acquire
|
|
839
|
+
rescue ScriptError, StandardError => e
|
|
840
|
+
carry_forward(change_set)
|
|
841
|
+
reason = "could not take the extraction lock: #{e.class}: #{e.message}"
|
|
842
|
+
@logger.error("[Woods] watch: #{reason}")
|
|
843
|
+
outcome(:extract, :degraded, reason: reason, count: change_set.size,
|
|
844
|
+
duration_ms: elapsed_ms(started))
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# @return [void]
|
|
848
|
+
def release_lock_quietly
|
|
849
|
+
@lock.release
|
|
850
|
+
rescue ScriptError, StandardError => e
|
|
851
|
+
@logger.warn("[Woods] watch: could not release the extraction lock — #{e.message}")
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# Another writer holds the extraction lock — a manual `woods:extract`,
|
|
855
|
+
# or a hook sync that fired anyway. Yield rather than race: the manual
|
|
856
|
+
# run is doing the same job, and the daemon's paths are carried into the
|
|
857
|
+
# next cycle so nothing is lost.
|
|
858
|
+
def contended(change_set, started)
|
|
859
|
+
carry_forward(change_set)
|
|
860
|
+
reason = 'another writer holds the extraction lock — retrying on the next event'
|
|
861
|
+
@logger.info("[Woods] watch: #{reason}")
|
|
862
|
+
outcome(:contended, :degraded, reason: reason, count: change_set.size,
|
|
863
|
+
duration_ms: elapsed_ms(started))
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def run_extraction(change_set, started)
|
|
867
|
+
# Count only paths that imply extraction work. Sixty edited markdown
|
|
868
|
+
# files plus one model is a one-model change, and reading it as a storm
|
|
869
|
+
# would trade a millisecond cycle for a full extraction.
|
|
870
|
+
actionable = actionable_count(change_set)
|
|
871
|
+
full = actionable > @full_extraction_threshold
|
|
872
|
+
log_storm(actionable) if full
|
|
873
|
+
|
|
874
|
+
before = @generation.current.number
|
|
875
|
+
extractor = @extractor_factory.call
|
|
876
|
+
touched = if full
|
|
877
|
+
extractor.extract_all
|
|
878
|
+
:all
|
|
879
|
+
else
|
|
880
|
+
extractor.extract_changed(change_set.absolute_paths)
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
action = full ? :full : :incremental
|
|
884
|
+
return unpublished(action, change_set, started) if wrote_without_publishing?(touched, before)
|
|
885
|
+
|
|
886
|
+
publish(action, change_set, touched, started)
|
|
887
|
+
rescue ScriptError, StandardError => e
|
|
888
|
+
# Extraction failed, so nothing landed — the generation stays where it
|
|
889
|
+
# was and the index keeps serving its last good state.
|
|
890
|
+
reason = "extraction failed: #{e.class}: #{e.message}"
|
|
891
|
+
@logger.error("[Woods] watch: #{reason}")
|
|
892
|
+
# The paths really did change; a later event will not mention them
|
|
893
|
+
# again, so carry them forward and try once the cause clears.
|
|
894
|
+
carry_forward(change_set)
|
|
895
|
+
outcome(:extract, :degraded, reason: reason, count: change_set.size,
|
|
896
|
+
duration_ms: elapsed_ms(started))
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def actionable_count(change_set)
|
|
900
|
+
change_set.relative_paths.count { |path| @policy.classify(path) != :ignore }
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Did the extractor write units without the generation moving?
|
|
904
|
+
#
|
|
905
|
+
# `Extractor#publish_generation` rescues its own failures on purpose — an
|
|
906
|
+
# index that landed correctly should not be thrown away because the
|
|
907
|
+
# marker could not be written. But the generation *is* the freshness
|
|
908
|
+
# contract: readers self-refresh on it, `woods_status` reports it, and a
|
|
909
|
+
# cycle that silently fails to bump leaves every reader serving the
|
|
910
|
+
# previous index while the daemon says `running`. Not raising was right;
|
|
911
|
+
# not noticing was not.
|
|
912
|
+
#
|
|
913
|
+
# A no-op incremental deliberately does not bump, so this only fires when
|
|
914
|
+
# units were actually written.
|
|
915
|
+
def wrote_without_publishing?(touched, before)
|
|
916
|
+
return false if touched != :all && Array(touched).empty?
|
|
917
|
+
|
|
918
|
+
@generation.current.number == before
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# The units are on disk and correct; only the marker that advertises them
|
|
922
|
+
# is missing. Degraded rather than failed, and the paths ride forward so
|
|
923
|
+
# the next successful cycle republishes them.
|
|
924
|
+
def unpublished(action, change_set, started)
|
|
925
|
+
carry_forward(change_set)
|
|
926
|
+
reason = 'index written but the generation did not advance — readers will not see it'
|
|
927
|
+
@logger.error("[Woods] watch: #{reason}")
|
|
928
|
+
outcome(action, :degraded, reason: reason, count: change_set.size,
|
|
929
|
+
duration_ms: elapsed_ms(started))
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def publish(action, change_set, touched, started)
|
|
933
|
+
# The extractor bumps the generation as the last write of a successful
|
|
934
|
+
# run, so the daemon reads the number rather than minting a second one
|
|
935
|
+
# — two bumps per cycle would make the counter lie about how many
|
|
936
|
+
# times the index actually moved.
|
|
937
|
+
marker = @generation.current
|
|
938
|
+
duration = elapsed_ms(started)
|
|
939
|
+
@logger.info("[Woods] watch: #{action} over #{change_set.size} path(s) " \
|
|
940
|
+
"in #{duration}ms → generation #{marker.number}")
|
|
941
|
+
|
|
942
|
+
outcome(action, :running, generation: marker.number, count: change_set.size,
|
|
943
|
+
duration_ms: duration, touched: touched)
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def log_storm(actionable)
|
|
947
|
+
@logger.info("[Woods] watch: #{actionable} actionable paths changed " \
|
|
948
|
+
"(> #{@full_extraction_threshold}) — full extraction instead of incremental")
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# rubocop:disable-next Metrics/ParameterLists -- the shape of one cycle's result.
|
|
952
|
+
def outcome(action, state, reason: nil, count: 0, duration_ms: nil, generation: nil, touched: nil)
|
|
953
|
+
generation ||= @generation.current.number
|
|
954
|
+
# Remembered so an `:ignore` batch cannot advertise recovery, and so the
|
|
955
|
+
# heartbeat republishes the truth rather than `:running`.
|
|
956
|
+
@degraded_reason = state == :degraded ? reason : nil
|
|
957
|
+
publish_status(state, reason: reason, generation: generation,
|
|
958
|
+
last_action: action.to_s, last_batch_size: count,
|
|
959
|
+
last_duration_ms: duration_ms)
|
|
960
|
+
|
|
961
|
+
{ action: action, state: state, reason: reason, generation: generation,
|
|
962
|
+
count: count, duration_ms: duration_ms, touched: touched }
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def publish_status(state, reason:, generation: nil, **details)
|
|
966
|
+
record = { state: state, reason: reason,
|
|
967
|
+
generation: generation || @generation.current.number, **details }
|
|
968
|
+
# Kept so the heartbeat can re-stamp exactly this record rather than
|
|
969
|
+
# inventing a fresh one.
|
|
970
|
+
@last_status = record
|
|
971
|
+
@status.write(**record)
|
|
972
|
+
rescue StandardError => e
|
|
973
|
+
@logger.warn("[Woods] watch: could not write status — #{e.message}")
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Is a *different* live daemon already maintaining this index?
|
|
977
|
+
#
|
|
978
|
+
# {PipelineLock} keeps two daemons from interleaving writes, but nothing
|
|
979
|
+
# stopped them both existing: the second would poll the same tree, take
|
|
980
|
+
# the lock alternately with the first, and double the extraction work
|
|
981
|
+
# while each carried paths forward past cycles the other had already
|
|
982
|
+
# handled. Worse, both publish status to one file, so `alive?` answers for
|
|
983
|
+
# whichever wrote last and `woods:watch_status` cannot tell you there are
|
|
984
|
+
# two. Cheap to prevent at startup, and a crashed predecessor does not
|
|
985
|
+
# trip it — `Status#alive?` requires the recorded pid to still exist.
|
|
986
|
+
#
|
|
987
|
+
# `WOODS_IGNORE_WATCH=1` overrides, matching what it already means for
|
|
988
|
+
# `woods:incremental`.
|
|
989
|
+
#
|
|
990
|
+
# @return [Boolean]
|
|
991
|
+
def another_daemon_alive?
|
|
992
|
+
return false if ENV['WOODS_IGNORE_WATCH'] == '1'
|
|
993
|
+
return false unless @status.alive?
|
|
994
|
+
|
|
995
|
+
other = @status.read['pid']
|
|
996
|
+
return false if other.nil? || other.to_i == Process.pid
|
|
997
|
+
|
|
998
|
+
@logger.warn(
|
|
999
|
+
"[Woods] a watch daemon (pid #{other}) is already maintaining #{@output_dir} — standing down. " \
|
|
1000
|
+
'Set WOODS_IGNORE_WATCH=1 to start anyway.'
|
|
1001
|
+
)
|
|
1002
|
+
true
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# Atomically claim the right to start, closing the race
|
|
1006
|
+
# {#another_daemon_alive?} cannot: two daemons calling this
|
|
1007
|
+
# concurrently both attempt an `O_EXCL` create of the same claim file,
|
|
1008
|
+
# and the filesystem — not thread scheduling — decides which one
|
|
1009
|
+
# actually creates it. The loser gets `Errno::EEXIST` regardless of
|
|
1010
|
+
# which daemon checked {#another_daemon_alive?} first or which one
|
|
1011
|
+
# would have published its status record first.
|
|
1012
|
+
#
|
|
1013
|
+
# `WOODS_IGNORE_WATCH=1` bypasses the claim the same way it bypasses
|
|
1014
|
+
# {#another_daemon_alive?} — forcing a start must not get blocked by
|
|
1015
|
+
# a still-live claim it was explicitly told to override.
|
|
1016
|
+
#
|
|
1017
|
+
# A claim recorded by a pid that no longer exists is reclaimed rather
|
|
1018
|
+
# than left blocking forever, the same "does the recorded pid still
|
|
1019
|
+
# exist" test {Status#alive?} uses. Bounded to a few attempts so a
|
|
1020
|
+
# claim that keeps reappearing (a pathological retry loop, not the
|
|
1021
|
+
# ordinary single-contender case) fails closed instead of spinning.
|
|
1022
|
+
#
|
|
1023
|
+
# @return [Boolean] true when this instance now holds the claim (or
|
|
1024
|
+
# was told to skip claiming entirely)
|
|
1025
|
+
def claim_startup?
|
|
1026
|
+
return true if ENV['WOODS_IGNORE_WATCH'] == '1'
|
|
1027
|
+
|
|
1028
|
+
with_claim_lock do
|
|
1029
|
+
3.times do
|
|
1030
|
+
return true if create_claim
|
|
1031
|
+
return false unless reclaim_if_stale
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
false
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# Serializes reclaim-then-create across starters. Without it the
|
|
1039
|
+
# sequence is read-then-unlink: starter A judges claim S stale and
|
|
1040
|
+
# pauses, B deletes S and publishes its live claim, A resumes and
|
|
1041
|
+
# unlinks B's claim, and both return as owners. An `flock` on a
|
|
1042
|
+
# sidecar file makes the whole loop one critical section; the kernel
|
|
1043
|
+
# releases it if the holder dies. Where the filesystem refuses the
|
|
1044
|
+
# lock (some network mounts) the loop runs unserialized, as before.
|
|
1045
|
+
def with_claim_lock
|
|
1046
|
+
lock = open_claim_lock
|
|
1047
|
+
yield
|
|
1048
|
+
ensure
|
|
1049
|
+
lock&.close
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
def open_claim_lock
|
|
1053
|
+
FileUtils.mkdir_p(@output_dir)
|
|
1054
|
+
# Held open on purpose: the descriptor is the lock; closed in
|
|
1055
|
+
# with_claim_lock's ensure.
|
|
1056
|
+
file = File.open(claim_lock_path, File::RDWR | File::CREAT, 0o644) # rubocop:disable Style/FileOpen
|
|
1057
|
+
file.flock(File::LOCK_EX)
|
|
1058
|
+
file
|
|
1059
|
+
rescue SystemCallError => e
|
|
1060
|
+
file&.close
|
|
1061
|
+
@logger.warn("[Woods] watch: claim lock unavailable on #{@output_dir} (#{e.class}: #{e.message}); " \
|
|
1062
|
+
'startup claims are not serialized here, so two simultaneous starters can both win')
|
|
1063
|
+
nil
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def claim_lock_path
|
|
1067
|
+
"#{claim_path}.lock"
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# @return [Boolean] true when the claim file was created by this call
|
|
1071
|
+
#
|
|
1072
|
+
# Written fully to a temp file first, THEN linked into place — never
|
|
1073
|
+
# `open(O_EXCL)` straight onto +claim_path+ and write after. `File.link`
|
|
1074
|
+
# is atomic and fails closed with `EEXIST` exactly like `O_EXCL` does,
|
|
1075
|
+
# but the instant a reader can see the claim file at all, its content
|
|
1076
|
+
# is already complete. Racing a plain `open`-then-`write` the other way
|
|
1077
|
+
# let a contender's {#stale_claim?} read the file between our create
|
|
1078
|
+
# and our write, see an empty/torn body, misjudge it stale, and delete
|
|
1079
|
+
# the claim we had just won — reintroducing exactly the double-daemon
|
|
1080
|
+
# race this method exists to close.
|
|
1081
|
+
def create_claim
|
|
1082
|
+
FileUtils.mkdir_p(@output_dir)
|
|
1083
|
+
content = JSON.generate(pid: Process.pid, host: Status.host_identity)
|
|
1084
|
+
tmp_path = "#{claim_path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
1085
|
+
File.write(tmp_path, content)
|
|
1086
|
+
begin
|
|
1087
|
+
File.link(tmp_path, claim_path)
|
|
1088
|
+
rescue Errno::EPERM, Errno::ENOTSUP
|
|
1089
|
+
# Some filesystems (certain bind mounts, network shares) don't
|
|
1090
|
+
# support hard links at all — that raises EPERM/ENOTSUP rather
|
|
1091
|
+
# than the EEXIST a real conflict would raise. Fall back to a
|
|
1092
|
+
# plain O_EXCL create so the daemon can still start there; this
|
|
1093
|
+
# loses the tmp-write-then-link guarantee that content is
|
|
1094
|
+
# complete before a reader can see the file exist, but the
|
|
1095
|
+
# content here is a few bytes written in one syscall.
|
|
1096
|
+
#
|
|
1097
|
+
# Documented residual (INF-11): on this path a contender reading
|
|
1098
|
+
# between the create and the write still sees an empty claim, judges
|
|
1099
|
+
# it stale and replaces it. {#release_claim}'s verify-before-delete
|
|
1100
|
+
# bounds the damage to one lost claim rather than two daemons
|
|
1101
|
+
# deleting each other's.
|
|
1102
|
+
File.open(claim_path, File::WRONLY | File::CREAT | File::EXCL) { |f| f.write(content) }
|
|
1103
|
+
end
|
|
1104
|
+
@claimed = true
|
|
1105
|
+
true
|
|
1106
|
+
rescue Errno::EEXIST
|
|
1107
|
+
false
|
|
1108
|
+
ensure
|
|
1109
|
+
# `tmp_path` is assigned after `mkdir_p`, so a raise from `mkdir_p`
|
|
1110
|
+
# itself leaves it `nil` — `FileUtils.rm_f(nil)` is a TypeError that
|
|
1111
|
+
# would otherwise replace whatever `mkdir_p` actually raised.
|
|
1112
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# @return [Boolean] true when a stale claim was cleared and the caller
|
|
1116
|
+
# should retry {#create_claim}; false when the claim is live, was
|
|
1117
|
+
# already replaced by a winning contender, or the directory
|
|
1118
|
+
# vanished — every case where the caller should give up
|
|
1119
|
+
def reclaim_if_stale
|
|
1120
|
+
snapshot = claim_bytes
|
|
1121
|
+
return false unless snapshot
|
|
1122
|
+
return false unless stale_claim?
|
|
1123
|
+
|
|
1124
|
+
# Race guard: two starters can both read the same dead-pid claim
|
|
1125
|
+
# and both judge it stale before either deletes it. Re-read
|
|
1126
|
+
# immediately before deleting: if the bytes changed since the
|
|
1127
|
+
# snapshot, another daemon's create_claim already replaced the file
|
|
1128
|
+
# with its own live claim, and deleting it now would destroy a claim
|
|
1129
|
+
# we never judged stale. Bytes, not the inode: Linux reuses a freed
|
|
1130
|
+
# inode for the very next file created in the directory, so an
|
|
1131
|
+
# inode comparison passed the replaced claim through.
|
|
1132
|
+
return false unless claim_bytes == snapshot
|
|
1133
|
+
|
|
1134
|
+
FileUtils.rm_f(claim_path)
|
|
1135
|
+
true
|
|
1136
|
+
rescue Errno::ENOENT
|
|
1137
|
+
false
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# @return [String, nil] the claim file's current bytes, or nil if it
|
|
1141
|
+
# doesn't exist
|
|
1142
|
+
def claim_bytes
|
|
1143
|
+
File.read(claim_path)
|
|
1144
|
+
rescue Errno::ENOENT
|
|
1145
|
+
nil
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# @return [Boolean] whether the current claim's pid evidence can be
|
|
1149
|
+
# trusted at all and, if so, whether that pid is dead — an unreadable
|
|
1150
|
+
# or already-vanished claim counts as stale too, since it cannot be a
|
|
1151
|
+
# live daemon's claim
|
|
1152
|
+
def stale_claim?
|
|
1153
|
+
record = JSON.parse(File.read(claim_path))
|
|
1154
|
+
return true unless same_claim_host?(record['host'])
|
|
1155
|
+
|
|
1156
|
+
!claim_pid_alive?(record['pid'])
|
|
1157
|
+
rescue JSON::ParserError, SystemCallError
|
|
1158
|
+
true
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Mirrors {Status#alive?}'s host check: a pid is only meaningful inside
|
|
1162
|
+
# the namespace that issued it, so a claim recorded on a different host
|
|
1163
|
+
# cannot be judged live on pid evidence alone. Without this, a dead
|
|
1164
|
+
# container daemon's claim (container pid 47) reads as live forever to
|
|
1165
|
+
# a host-side daemon, because host pid 47 almost always exists too — the
|
|
1166
|
+
# same false positive {Status} already guards against for the status
|
|
1167
|
+
# file, just never mirrored here for the claim file.
|
|
1168
|
+
#
|
|
1169
|
+
# A claim with no recorded host predates this field; treated as
|
|
1170
|
+
# same-host so an in-place upgrade does not misjudge its own claim.
|
|
1171
|
+
#
|
|
1172
|
+
# @param host [String, nil]
|
|
1173
|
+
# @return [Boolean]
|
|
1174
|
+
def same_claim_host?(host)
|
|
1175
|
+
host.nil? || host == Status.host_identity
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Same semantics as {Status#alive?}'s pid check: signal 0 asks "could I
|
|
1179
|
+
# signal this process?" without sending anything.
|
|
1180
|
+
def claim_pid_alive?(pid)
|
|
1181
|
+
return false unless pid.is_a?(Integer) && pid.positive?
|
|
1182
|
+
|
|
1183
|
+
Process.kill(0, pid)
|
|
1184
|
+
true
|
|
1185
|
+
rescue Errno::ESRCH
|
|
1186
|
+
false
|
|
1187
|
+
rescue Errno::EPERM
|
|
1188
|
+
true
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# Drop this daemon's startup claim at shutdown.
|
|
1192
|
+
#
|
|
1193
|
+
# Verify-before-delete, mirroring {#reclaim_if_stale}'s snapshot-compare
|
|
1194
|
+
# and `PipelineLock#release` (INF-11). `@claimed` records that *we* once
|
|
1195
|
+
# created the claim, not that the file on disk is still ours: the
|
|
1196
|
+
# no-hardlink fallback leaves a window in which a contender can judge our
|
|
1197
|
+
# half-written claim stale and replace it. Deleting unconditionally then
|
|
1198
|
+
# removes the successor's *live* claim and lets a third starter in while
|
|
1199
|
+
# it is still running.
|
|
1200
|
+
#
|
|
1201
|
+
# An unreadable or unparseable claim is still removed — it cannot be a
|
|
1202
|
+
# live daemon's claim, the same judgement {#stale_claim?} makes.
|
|
1203
|
+
#
|
|
1204
|
+
# @return [void]
|
|
1205
|
+
def release_claim
|
|
1206
|
+
return unless @claimed
|
|
1207
|
+
|
|
1208
|
+
FileUtils.rm_f(claim_path) if own_claim?
|
|
1209
|
+
@claimed = false
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
# @return [Boolean] whether the claim on disk still records this process
|
|
1213
|
+
def own_claim?
|
|
1214
|
+
record = JSON.parse(File.read(claim_path))
|
|
1215
|
+
return true unless record.is_a?(Hash)
|
|
1216
|
+
|
|
1217
|
+
record['pid'] == Process.pid && same_claim_host?(record['host'])
|
|
1218
|
+
rescue JSON::ParserError, SystemCallError
|
|
1219
|
+
true
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
def claim_path
|
|
1223
|
+
File.join(@output_dir, CLAIM_FILENAME)
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def build_watcher
|
|
1227
|
+
@watcher = Watcher.build(
|
|
1228
|
+
root: @root, ignored: ignored_directories, logger: @logger, force_polling: @force_polling
|
|
1229
|
+
)
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
# The ignore list, plus this daemon's own output directory when it sits
|
|
1233
|
+
# inside the watched tree.
|
|
1234
|
+
#
|
|
1235
|
+
# Every cycle writes `generation.json`, `status.json` and the unit files,
|
|
1236
|
+
# so watching the output directory means each cycle manufactures the
|
|
1237
|
+
# events that trigger the next one — a daemon that never goes idle and an
|
|
1238
|
+
# index that rewrites itself forever. The default `tmp/woods` is already
|
|
1239
|
+
# covered by `tmp` in {Watcher::DEFAULT_IGNORED_DIRECTORIES}, which is why
|
|
1240
|
+
# this has not bitten in practice; a `WOODS_OUTPUT` pointing anywhere else
|
|
1241
|
+
# under the root (`.woods/`, `woods_index/`) had nothing protecting it.
|
|
1242
|
+
#
|
|
1243
|
+
# @return [Array<String>] directory names/prefixes to skip
|
|
1244
|
+
def ignored_directories
|
|
1245
|
+
@ignored_directories ||= [
|
|
1246
|
+
*Watcher::DEFAULT_IGNORED_DIRECTORIES, output_dir_within_root
|
|
1247
|
+
].compact.uniq
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# @return [String, nil] output dir relative to the root, or nil when it
|
|
1251
|
+
# lives outside the watched tree entirely
|
|
1252
|
+
def output_dir_within_root
|
|
1253
|
+
base = resolve_path(@root)
|
|
1254
|
+
out = resolve_path(@output_dir)
|
|
1255
|
+
return nil if base.nil? || out.nil?
|
|
1256
|
+
return nil unless out.start_with?("#{base}/")
|
|
1257
|
+
|
|
1258
|
+
out.delete_prefix("#{base}/")
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
# `realpath` so a symlinked root or output dir still compares, falling
|
|
1262
|
+
# back to `expand_path` because the output directory need not exist yet on
|
|
1263
|
+
# a first run.
|
|
1264
|
+
def resolve_path(path)
|
|
1265
|
+
File.realpath(path)
|
|
1266
|
+
rescue SystemCallError
|
|
1267
|
+
File.expand_path(path)
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
def monotonic_now
|
|
1271
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
def elapsed_ms(started)
|
|
1275
|
+
((monotonic_now - started) * 1000).round
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
def default_logger
|
|
1279
|
+
defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger ? Rails.logger : NullLogger.new
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
# Used when there is no Rails logger — a daemon that can't log is still
|
|
1283
|
+
# better than one that raises on its first message.
|
|
1284
|
+
class NullLogger
|
|
1285
|
+
def info(*); end
|
|
1286
|
+
def warn(*); end
|
|
1287
|
+
def error(*); end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
# Adapter over `Rails.application.reloader`, isolated so the daemon can
|
|
1291
|
+
# be driven without Rails in specs.
|
|
1292
|
+
#
|
|
1293
|
+
# `enabled?` matters: an app booted with `config.enable_reloading =
|
|
1294
|
+
# false` (the production default, and common in staging-shaped dev
|
|
1295
|
+
# containers) cannot reload at all. The daemon escalates `:reload` to
|
|
1296
|
+
# `:restart` in that case rather than extracting against constants that
|
|
1297
|
+
# no longer match their source.
|
|
1298
|
+
class RailsReloader
|
|
1299
|
+
# Whether this process can reload code.
|
|
1300
|
+
#
|
|
1301
|
+
# The two spellings are not two semantics: `enable_reloading` is
|
|
1302
|
+
# *defined* as `!cache_classes` from 7.1 on, so both branches compute
|
|
1303
|
+
# the same thing and the guard is only about which method exists (6.0
|
|
1304
|
+
# through 7.0 have no `enable_reloading` at all). Deliberately reading
|
|
1305
|
+
# the same value Rails' own finisher gates the reloader on — including
|
|
1306
|
+
# the case where an app never sets `cache_classes` and it stays `nil`,
|
|
1307
|
+
# which Rails reads as reloading-enabled and so must we.
|
|
1308
|
+
#
|
|
1309
|
+
# @return [Boolean]
|
|
1310
|
+
def enabled?
|
|
1311
|
+
return false unless defined?(Rails) && Rails.application
|
|
1312
|
+
|
|
1313
|
+
config = Rails.application.config
|
|
1314
|
+
return config.enable_reloading if config.respond_to?(:enable_reloading)
|
|
1315
|
+
|
|
1316
|
+
!config.cache_classes
|
|
1317
|
+
rescue StandardError
|
|
1318
|
+
false
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
# Reload the app's autoloaded constants.
|
|
1322
|
+
#
|
|
1323
|
+
# Deliberately bare. Unloading constants while other threads execute
|
|
1324
|
+
# autoloaded code — the embedded placement this class' doc blesses,
|
|
1325
|
+
# inside a dev server — needs the interlock's unload lock held, or you
|
|
1326
|
+
# get a `NameError` in an unrelated request or a deadlock against a
|
|
1327
|
+
# thread mid-autoload. `reload!` already takes it: the instance's
|
|
1328
|
+
# `class_unload!` calls `require_unload_lock!`, which is
|
|
1329
|
+
# `interlock.start_unloading`, and Rails' finisher registers that
|
|
1330
|
+
# callback whenever reloading is enabled. Wrapping this call in
|
|
1331
|
+
# `interlock.unloading` would re-acquire the same exclusive lock the
|
|
1332
|
+
# block below is about to take.
|
|
1333
|
+
#
|
|
1334
|
+
# `spec/integration/watch_daemon_spec.rb` pins that, so a Rails release
|
|
1335
|
+
# that stopped locking here fails rather than quietly needing a wrapper
|
|
1336
|
+
# nobody remembers to re-add.
|
|
1337
|
+
#
|
|
1338
|
+
# @return [void]
|
|
1339
|
+
def reload!
|
|
1340
|
+
Rails.application.reloader.reload!
|
|
1341
|
+
end
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
end
|
|
1345
|
+
end
|