woods 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +138 -26
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'json'
5
+ require 'yaml'
6
+ require 'pathname'
7
+ require 'woods'
8
+ require 'woods/atomic_file'
9
+ require_relative 'errors'
10
+ require_relative 'name_mapper'
11
+ require_relative 'note_builder'
12
+ require_relative 'vault_assets'
13
+
14
+ module Woods
15
+ module Obsidian
16
+ # Orchestrates exporting Woods extraction output to a self-contained
17
+ # Obsidian vault.
18
+ #
19
+ # Reads the flat extraction directory via IndexReader (using raw_graph_data,
20
+ # so string keys + persisted pagerank), then writes:
21
+ # - one Markdown note per exported unit (NoteBuilder),
22
+ # - per-type `_index.md` MOCs + a root `_Overview.md`,
23
+ # - a `_woods/` machine sidecar (id<->path manifest + verbatim graph copies),
24
+ # - `.obsidian/` config + `Units.base` (only into a woods-owned vault),
25
+ # - a `.woods-vault` ownership sentinel.
26
+ # Finally it sweeps stale woods-managed notes — guarded so it can never
27
+ # delete a user's own notes or run against a foreign vault.
28
+ #
29
+ # All dependency edges come from one in-memory edge_map (the graph's
30
+ # edges/reverse); per-unit JSON is read only for note bodies.
31
+ #
32
+ # @example
33
+ # Woods::Obsidian::VaultExporter.new(
34
+ # index_dir: "tmp/woods", vault_path: "tmp/woods/obsidian_vault"
35
+ # ).export_all
36
+ # # => { exported: 412, indexes: 19, swept: 3, skipped: 0, errors: [] }
37
+ class VaultExporter
38
+ MAX_ERRORS = 100
39
+ PURGE_GUARD_FRACTION = 0.30
40
+ PURGE_GUARD_MIN_DOCS = 10
41
+ SENTINEL = '.woods-vault'
42
+ SIDECAR_DIR = '_woods'
43
+ FRAMEWORK_TYPES = %w[rails_source gem_source].freeze
44
+ # Upper bound on frontmatter lines scanned when identifying a managed note
45
+ # during the sweep — our frontmatter is ~12 lines; this just bounds work on
46
+ # a malformed file that opens with "---" but never closes the fence.
47
+ MAX_FRONTMATTER_LINES = 200
48
+
49
+ # @param index_dir [String] extraction output directory to read
50
+ # @param vault_path [String] directory to write the vault into
51
+ # @param config [Configuration] Woods configuration (default: global)
52
+ # @param reader [Object, nil] IndexReader (auto-created if nil)
53
+ # @param include_source [Boolean] embed credential-scrubbed source in notes
54
+ # @param include_framework [Boolean] include rails_source units
55
+ # @param force_purge [Boolean] bypass the mass-deletion sweep guard
56
+ # @param output [IO] progress stream
57
+ def initialize(index_dir:, vault_path:, config: Woods.configuration, reader: nil,
58
+ include_source: false, include_framework: false, force_purge: false, output: $stdout)
59
+ @config = config
60
+ @vault = Pathname.new(vault_path.to_s)
61
+ @reader = reader || build_reader(index_dir)
62
+ @include_source = include_source
63
+ @include_framework = include_framework
64
+ @force_purge = force_purge
65
+ @output = output
66
+ end
67
+
68
+ # Generate the full vault. Idempotent: re-running against an unchanged
69
+ # extraction produces byte-identical output.
70
+ #
71
+ # @return [Hash] { exported:, indexes:, swept:, skipped:, errors: }
72
+ def export_all
73
+ graph = @reader.raw_graph_data || {}
74
+ @nodes = graph['nodes'] || {}
75
+ @pagerank = graph['pagerank'] || {}
76
+ analysis = safe_graph_analysis
77
+ owned = vault_owned_at_start?
78
+
79
+ emitted, skipped = partition_emitted
80
+ mapper = build_mapper(emitted)
81
+ edge_map = build_edge_map(emitted.to_set, graph)
82
+ builder = build_note_builder(mapper, analysis)
83
+
84
+ written = Set.new
85
+ errors = []
86
+ exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
87
+ indexes = write_indexes(emitted, mapper, written)
88
+ write_sidecar(emitted, mapper, graph, analysis, written)
89
+ write_owned_assets(emitted) if owned
90
+
91
+ swept = owned && errors.empty? ? sweep(written) : 0
92
+ warn_foreign unless owned
93
+ { exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
94
+ end
95
+
96
+ private
97
+
98
+ # ── Selection / mapping ──────────────────────────────────────────
99
+
100
+ # The emitted set = graph nodes of an exportable type that have a loadable
101
+ # unit file. Confirming the file loads (not just that the index lists it)
102
+ # guarantees every wikilink target is backed by a real note — a node listed
103
+ # in the index but with no readable unit is dropped (counted as skipped).
104
+ #
105
+ # @return [Array(Array<String>, Integer)] [sorted emitted ids, skipped count]
106
+ def partition_emitted
107
+ known = known_ids
108
+ candidates = @nodes.keys.select do |id|
109
+ type = @nodes[id]['type']
110
+ next false if !@include_framework && FRAMEWORK_TYPES.include?(type)
111
+
112
+ known.include?(id)
113
+ end.sort
114
+
115
+ # Load each candidate once and keep it: confirms the unit is readable
116
+ # (a corrupt/missing file is skipped, not fatal) and lets Pass 2 render
117
+ # from this cache instead of re-reading every file past the reader's
118
+ # small LRU — avoiding ~2x disk reads + JSON parses on a large vault.
119
+ # Holding the units for one batch export is an acceptable memory trade.
120
+ @units = {}
121
+ skipped = 0
122
+ candidates.each do |id|
123
+ unit = load_unit(id)
124
+ unit ? (@units[id] = unit) : (skipped += 1)
125
+ end
126
+ [@units.keys, skipped]
127
+ end
128
+
129
+ # Load a unit, treating an unreadable/corrupt file as absent rather than
130
+ # letting one bad JSON parse abort the whole export.
131
+ def load_unit(id)
132
+ @reader.find_unit(id)
133
+ rescue StandardError => e
134
+ log " skipped #{id}: #{e.class}: #{e.message}"
135
+ nil
136
+ end
137
+
138
+ def known_ids
139
+ @known_ids ||= @reader.list_units.to_set { |entry| entry['identifier'] }
140
+ end
141
+
142
+ def build_mapper(emitted)
143
+ NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] })
144
+ end
145
+
146
+ def dir_for(type)
147
+ require 'woods/mcp/index_reader'
148
+ Woods::MCP::IndexReader::TYPE_TO_DIR[type] || "#{type}s"
149
+ end
150
+
151
+ def build_note_builder(mapper, analysis)
152
+ NoteBuilder.new(name_mapper: mapper, nodes: @nodes, pagerank: @pagerank,
153
+ analysis: analysis, include_source: @include_source, scanner: scanner)
154
+ end
155
+
156
+ # The single edge source: per-id forward (depends_on) and reverse (used_by)
157
+ # edges from the graph, filtered to the emitted set, self-edges dropped.
158
+ def build_edge_map(emitted, graph)
159
+ edges = graph['edges'] || {}
160
+ reverse = graph['reverse'] || {}
161
+ emitted.each_with_object({}) do |id, map|
162
+ deps = Array(edges[id]).filter_map { |edge| forward_edge(edge, id, emitted) }
163
+ .uniq { |edge| [edge[:target], edge[:via]] }
164
+ used = Array(reverse[id]).select { |sid| sid != id && emitted.include?(sid) }.uniq
165
+ map[id] = { depends_on: deps, used_by: used }
166
+ end
167
+ end
168
+
169
+ def forward_edge(edge, source_id, emitted)
170
+ edge = { 'target' => edge } unless edge.is_a?(Hash)
171
+ target = edge['target']
172
+ return nil if target == source_id || !emitted.include?(target)
173
+
174
+ { target: target, via: edge['via'] }
175
+ end
176
+
177
+ # ── Pass 2: notes ────────────────────────────────────────────────
178
+
179
+ def write_notes(emitted, mapper, edge_map, builder, written, errors)
180
+ exported = 0
181
+ emitted.each do |id|
182
+ unit = @units[id]
183
+ next unless unit # defensive — partition_emitted already cached loadable units
184
+
185
+ edges = edge_map[id] || { depends_on: [], used_by: [] }
186
+ note = builder.build(id: id, unit: unit, depends_on: edges[:depends_on], used_by: edges[:used_by])
187
+ next if note.nil?
188
+
189
+ rel = mapper.path_for(id)
190
+ AtomicFile.write(@vault.join(rel), note)
191
+ written << rel
192
+ exported += 1
193
+ rescue StandardError => e
194
+ errors << "#{id}: #{e.class}: #{e.message}"
195
+ end
196
+ exported
197
+ end
198
+
199
+ # ── Pass 3a: MOC + overview ──────────────────────────────────────
200
+
201
+ def write_indexes(emitted, mapper, written)
202
+ by_dir = emitted.group_by { |id| File.dirname(mapper.path_for(id)) }
203
+ by_dir.sort.each do |dir, ids|
204
+ write_managed(written, "#{dir}/_index.md", moc_note(dir, ids, mapper))
205
+ end
206
+ write_managed(written, '_Overview.md', overview_note(by_dir))
207
+ by_dir.size + 1
208
+ end
209
+
210
+ def moc_note(dir, ids, mapper)
211
+ lines = [managed_frontmatter, "# #{dir} (#{ids.size})", '']
212
+ ids.sort.each { |id| lines << "- #{mapper.wikilink(id)}" }
213
+ "#{lines.join("\n")}\n"
214
+ end
215
+
216
+ def overview_note(by_dir)
217
+ total = by_dir.values.sum(&:size)
218
+ lines = [managed_frontmatter, '# Woods Vault Overview', '',
219
+ "#{total} units across #{by_dir.size} types.", '', '## Units by type']
220
+ by_dir.sort.each { |dir, ids| lines << "- [[#{dir}/_index|#{dir}]] — #{ids.size}" }
221
+ lines.push('', '## Machine-readable',
222
+ '- `_woods/manifest.json` — id ↔ path map + per-note metadata',
223
+ '- `_woods/dependency_graph.json` / `_woods/graph_analysis.json` — full topology')
224
+ "#{lines.join("\n")}\n"
225
+ end
226
+
227
+ def managed_frontmatter
228
+ "#{{ 'woods_managed' => true, 'tags' => ['woods/moc'] }.to_yaml}---"
229
+ end
230
+
231
+ # ── Pass 3b: machine sidecar ─────────────────────────────────────
232
+
233
+ def write_sidecar(emitted, mapper, graph, analysis, written)
234
+ notes = emitted.to_h do |id|
235
+ [id, { 'path' => mapper.path_for(id), 'type' => @nodes[id]['type'],
236
+ 'pagerank' => @pagerank[id]&.round(6) }.compact]
237
+ end
238
+ manifest = { 'schema_version' => 1, 'notes' => sort_hash(notes), 'paths' => sort_hash(mapper.paths_to_ids) }
239
+ write_json(written, "#{SIDECAR_DIR}/manifest.json", manifest)
240
+ write_json(written, "#{SIDECAR_DIR}/dependency_graph.json", graph)
241
+ write_json(written, "#{SIDECAR_DIR}/graph_analysis.json", analysis) if analysis
242
+ end
243
+
244
+ # ── Pass 3c: owned-only assets (.obsidian config, Units.base, sentinel) ──
245
+
246
+ def write_owned_assets(emitted)
247
+ if can_write_obsidian_config?
248
+ AtomicFile.write(@vault.join('.obsidian', 'app.json'), VaultAssets.app_json)
249
+ AtomicFile.write(@vault.join('.obsidian', 'types.json'), VaultAssets.types_json)
250
+ AtomicFile.write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
251
+ AtomicFile.write(@vault.join('Units.base'), VaultAssets.units_base)
252
+ end
253
+ AtomicFile.write(@vault.join(SENTINEL),
254
+ "woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
255
+ end
256
+
257
+ def can_write_obsidian_config?
258
+ obsidian = @vault.join('.obsidian')
259
+ return true unless obsidian.exist?
260
+ return true if @vault.join(SENTINEL).exist?
261
+
262
+ log ' existing .obsidian/ left untouched (not a woods-owned vault)'
263
+ false
264
+ end
265
+
266
+ def present_types(emitted)
267
+ emitted.map { |id| @nodes[id]['type'] }.uniq
268
+ end
269
+
270
+ # ── Pass 4: guarded sweep ────────────────────────────────────────
271
+
272
+ def sweep(written)
273
+ managed = managed_notes
274
+ stale = managed.reject { |rel| written.include?(rel) }
275
+ return 0 if stale.empty?
276
+ return 0 if guard_blocks_purge?(stale, managed)
277
+
278
+ stale.sort.count do |rel|
279
+ path = @vault.join(rel)
280
+ next false unless safe_inside_vault?(path) && path.file?
281
+
282
+ File.delete(path)
283
+ true
284
+ end
285
+ end
286
+
287
+ def managed_notes
288
+ Dir.glob(@vault.join('**', '*.md')).filter_map do |abs|
289
+ next unless managed_marker?(abs)
290
+
291
+ Pathname.new(abs).relative_path_from(@vault).to_s
292
+ end
293
+ end
294
+
295
+ # Only our notes carry this frontmatter flag. Reads just the frontmatter
296
+ # head (notes can be large when include_source is on) rather than the whole
297
+ # file, and bails on anything that isn't a leading "---" fence.
298
+ def managed_marker?(abs)
299
+ fm = frontmatter_head(abs)
300
+ fm.is_a?(Hash) && fm['woods_managed'] == true
301
+ rescue StandardError
302
+ false
303
+ end
304
+
305
+ # Recognizes only a canonical fence: the file must open with exactly "---\n"
306
+ # and close with exactly "---\n" (what woods emits). Anything looser — a
307
+ # fence with trailing characters, CRLF, or no close within the cap — reads
308
+ # as unmanaged. Deliberately conservative: the sweep deletes managed notes,
309
+ # so when in doubt we must not claim a file as ours.
310
+ def frontmatter_head(abs)
311
+ File.open(abs, 'r:UTF-8') do |file|
312
+ return nil unless file.gets == "---\n"
313
+
314
+ lines = []
315
+ MAX_FRONTMATTER_LINES.times do
316
+ line = file.gets
317
+ break if line.nil?
318
+ return YAML.safe_load(lines.join) if line == "---\n"
319
+
320
+ lines << line
321
+ end
322
+ nil
323
+ end
324
+ end
325
+
326
+ def guard_blocks_purge?(stale, managed)
327
+ return false if @force_purge
328
+
329
+ total = managed.size
330
+ return false if total < PURGE_GUARD_MIN_DOCS
331
+
332
+ fraction = stale.size.to_f / total
333
+ return false unless fraction > PURGE_GUARD_FRACTION
334
+
335
+ log " WARNING: refusing to delete #{stale.size}/#{total} managed notes " \
336
+ "(#{(fraction * 100).round}% > #{(PURGE_GUARD_FRACTION * 100).to_i}%). " \
337
+ 'Set WOODS_OBSIDIAN_FORCE_PURGE=1 to override.'
338
+ true
339
+ end
340
+
341
+ def safe_inside_vault?(path)
342
+ root = @vault.realpath.to_s
343
+ target = path.exist? ? path.realpath.to_s : path.expand_path.to_s
344
+ target == root || target.start_with?(root + File::SEPARATOR)
345
+ rescue StandardError
346
+ false
347
+ end
348
+
349
+ # ── Ownership ────────────────────────────────────────────────────
350
+
351
+ def vault_owned_at_start?
352
+ return true unless @vault.exist?
353
+ return true if @vault.join(SENTINEL).exist?
354
+
355
+ (@vault.children.map { |c| c.basename.to_s } - [SENTINEL]).empty?
356
+ end
357
+
358
+ def warn_foreign
359
+ log " WARNING: #{@vault} is not a woods-owned vault (no #{SENTINEL} sentinel and it " \
360
+ 'already has content). Wrote notes additively; skipped config and the stale-note sweep.'
361
+ end
362
+
363
+ # ── Helpers ──────────────────────────────────────────────────────
364
+
365
+ def write_managed(written, rel, content)
366
+ AtomicFile.write(@vault.join(rel), content)
367
+ written << rel
368
+ end
369
+
370
+ def write_json(written, rel, data)
371
+ AtomicFile.write(@vault.join(rel), "#{JSON.pretty_generate(data)}\n")
372
+ written << rel
373
+ end
374
+
375
+ def sort_hash(hash)
376
+ hash.sort.to_h
377
+ end
378
+
379
+ def scanner
380
+ return nil unless @include_source
381
+
382
+ @scanner ||= begin
383
+ require 'woods/console/credential_scanner'
384
+ Woods::Console::CredentialScanner.new
385
+ end
386
+ end
387
+
388
+ def safe_graph_analysis
389
+ @reader.graph_analysis
390
+ rescue StandardError
391
+ nil
392
+ end
393
+
394
+ def build_reader(index_dir)
395
+ require 'woods/mcp/index_reader'
396
+ Woods::MCP::IndexReader.new(index_dir)
397
+ rescue ArgumentError => e
398
+ raise ExportError, "#{e.message} — run `bundle exec rake woods:extract` first"
399
+ end
400
+
401
+ def cap_errors(errors)
402
+ return errors if errors.size <= MAX_ERRORS
403
+
404
+ errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
405
+ end
406
+
407
+ def log(message)
408
+ @output&.puts(message)
409
+ end
410
+ end
411
+ end
412
+ end
@@ -37,22 +37,33 @@ module Woods
37
37
 
38
38
  # Classify an error by severity and suggest remediation.
39
39
  #
40
+ # Most patterns key on the error's class name, so a caller that only
41
+ # has the class name as a string (e.g. the pipeline_diagnose MCP tool,
42
+ # which receives error_class over the wire and can't reconstruct the
43
+ # real exception class) must pass +class_name:+ — otherwise the
44
+ # synthesized StandardError makes every class-keyed pattern miss and
45
+ # the error falls through to :unknown.
46
+ #
40
47
  # @param error [StandardError] The error to classify
48
+ # @param class_name [String, nil] Override for the class name used in
49
+ # pattern matching and reported as :error_class (defaults to
50
+ # error.class.name)
41
51
  # @return [Hash] :severity (:transient or :permanent), :category, :remediation, :error_class, :message
42
- def classify(error)
43
- error_string = "#{error.class} #{error.message}"
52
+ def classify(error, class_name: nil)
53
+ resolved_class = class_name || error.class.name
54
+ error_string = "#{resolved_class} #{error.message}"
44
55
 
45
56
  match = find_match(error_string, TRANSIENT_PATTERNS, :transient) ||
46
57
  find_match(error_string, PERMANENT_PATTERNS, :permanent)
47
58
 
48
59
  if match
49
- match.merge(error_class: error.class.name, message: error.message)
60
+ match.merge(error_class: resolved_class, message: error.message)
50
61
  else
51
62
  {
52
63
  severity: :unknown,
53
64
  category: 'unclassified',
54
65
  remediation: 'Investigate error details and check logs',
55
- error_class: error.class.name,
66
+ error_class: resolved_class,
56
67
  message: error.message
57
68
  }
58
69
  end
@@ -36,50 +36,93 @@ module Woods
36
36
 
37
37
  # @param threshold [Integer] Number of consecutive failures before opening the circuit
38
38
  # @param reset_timeout [Numeric] Seconds to wait before transitioning from open to half_open
39
- def initialize(threshold: 5, reset_timeout: 60)
39
+ # @param success_threshold [Integer] Number of consecutive successful probes required to
40
+ # close the circuit from half_open (default 1). Higher values reduce flapping when a
41
+ # recovering service is still intermittently failing.
42
+ def initialize(threshold: 5, reset_timeout: 60, success_threshold: 1)
40
43
  @threshold = threshold
41
44
  @reset_timeout = reset_timeout
45
+ @success_threshold = success_threshold
42
46
  @state = :closed
43
47
  @failure_count = 0
48
+ @success_count = 0
44
49
  @last_failure_time = nil
50
+ @half_open_probe_in_flight = false
45
51
  @mutex = Mutex.new
46
52
  end
47
53
 
48
54
  # Execute a block through the circuit breaker.
49
55
  #
56
+ # In half_open, only a SINGLE probe is admitted at a time: concurrent
57
+ # calls are rejected with {CircuitOpenError} until the probe resolves.
58
+ # This prevents a thundering herd of probes against a still-recovering
59
+ # service, and — because probes can't overlap — removes the race where a
60
+ # slow probe's success would wipe failures recorded by a concurrent one.
61
+ #
50
62
  # @yield The block to execute
51
63
  # @return [Object] The return value of the block
52
- # @raise [CircuitOpenError] if the circuit is open and the timeout has not elapsed
64
+ # @raise [CircuitOpenError] if the circuit is open, or half_open with a probe already in flight
53
65
  # @raise [StandardError] re-raises any error from the block
54
66
  def call(&block)
55
- # Phase 1: Check state under mutex
67
+ probing = admit_call!
68
+
69
+ begin
70
+ result = block.call
71
+ rescue CircuitOpenError
72
+ # A nested breaker tripped — release our probe slot but don't count
73
+ # it as this breaker's own failure.
74
+ @mutex.synchronize { @half_open_probe_in_flight = false if probing }
75
+ raise
76
+ rescue StandardError => e
77
+ @mutex.synchronize do
78
+ @half_open_probe_in_flight = false if probing
79
+ record_failure(probing)
80
+ end
81
+ raise e
82
+ end
83
+
84
+ @mutex.synchronize do
85
+ @half_open_probe_in_flight = false if probing
86
+ record_success(probing)
87
+ end
88
+ result
89
+ end
90
+
91
+ private
92
+
93
+ # Decide whether this call may proceed, transitioning open→half_open when
94
+ # the reset timeout has elapsed. Runs entirely under the mutex.
95
+ #
96
+ # @return [Boolean] true when this call is the half_open probe (so the
97
+ # caller knows to release the probe slot when it finishes)
98
+ # @raise [CircuitOpenError] when the circuit is open, or half_open with a
99
+ # probe already in flight
100
+ def admit_call!
56
101
  @mutex.synchronize do
57
102
  case @state
58
103
  when :open
59
- unless monotonic_now - @last_failure_time >= @reset_timeout
60
- raise CircuitOpenError, "Circuit breaker is open (#{@failure_count} failures)"
61
- end
104
+ raise CircuitOpenError, "Circuit breaker is open (#{@failure_count} failures)" unless reset_timeout_elapsed?
62
105
 
106
+ # First caller after the timeout becomes the single half_open probe.
63
107
  @state = :half_open
108
+ @half_open_probe_in_flight = true
109
+ true
110
+ when :half_open
111
+ raise CircuitOpenError, 'Circuit breaker is half-open (probe in flight)' if @half_open_probe_in_flight
112
+
113
+ @half_open_probe_in_flight = true
114
+ true
115
+ else
116
+ false
64
117
  end
65
118
  end
66
-
67
- # Phase 2: Execute outside mutex
68
- result = block.call
69
-
70
- # Phase 3: Record success under mutex
71
- @mutex.synchronize { reset! }
72
-
73
- result
74
- rescue CircuitOpenError
75
- raise
76
- rescue StandardError => e
77
- # Phase 4: Record failure under mutex
78
- @mutex.synchronize { record_failure }
79
- raise e
80
119
  end
81
120
 
82
- private
121
+ # @return [Boolean] whether enough time has passed since the last failure
122
+ # to attempt a recovery probe
123
+ def reset_timeout_elapsed?
124
+ @last_failure_time && (monotonic_now - @last_failure_time >= @reset_timeout)
125
+ end
83
126
 
84
127
  # Monotonic clock reading — immune to NTP slews and DST adjustments.
85
128
  #
@@ -88,18 +131,49 @@ module Woods
88
131
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
132
  end
90
133
 
91
- # Record a failure and potentially open the circuit.
92
- def record_failure
134
+ # Record a failure and potentially open the circuit. A failure while
135
+ # half_open reopens immediately, regardless of the failure threshold —
136
+ # but only when it is the genuine in-flight probe. A stale call
137
+ # admitted while the circuit was still closed, completing after the
138
+ # transition to half_open, is not the probe and must not decide
139
+ # recovery (its outcome reflects the pre-outage attempt, not the
140
+ # current one).
141
+ #
142
+ # @param probing [Boolean] whether this call was admitted as the probe
143
+ def record_failure(probing)
144
+ return if @state == :half_open && !probing
145
+
93
146
  @failure_count += 1
94
147
  @last_failure_time = monotonic_now
95
- @state = :open if @failure_count >= @threshold
148
+ @success_count = 0
149
+ @state = :open if @state == :half_open || @failure_count >= @threshold
150
+ end
151
+
152
+ # Record a success. In half_open, only the in-flight probe advances
153
+ # recovery — a stale non-probe success (a call admitted while closed
154
+ # that resolves after the circuit went half_open) must not count toward
155
+ # the success threshold or close the circuit. In closed, a success
156
+ # clears accumulated failures.
157
+ #
158
+ # @param probing [Boolean] whether this call was admitted as the probe
159
+ def record_success(probing)
160
+ if @state == :half_open
161
+ return unless probing
162
+
163
+ @success_count += 1
164
+ reset! if @success_count >= @success_threshold
165
+ elsif @state == :closed
166
+ @failure_count = 0
167
+ end
96
168
  end
97
169
 
98
170
  # Reset the circuit breaker to closed state with zero failures.
99
171
  def reset!
100
172
  @state = :closed
101
173
  @failure_count = 0
174
+ @success_count = 0
102
175
  @last_failure_time = nil
176
+ @half_open_probe_in_flight = false
103
177
  end
104
178
  end
105
179
  end
@@ -203,7 +203,10 @@ module Woods
203
203
  obj.each { |v| deep_freeze(v) }
204
204
  obj.frozen? ? obj : obj.freeze
205
205
  when String
206
- obj.frozen? ? obj : obj.dup.freeze
206
+ # Freeze in place — returning a frozen dup would be discarded by the
207
+ # Hash/Array branches above (they recurse for side effects only),
208
+ # leaving every nested string mutable.
209
+ obj.frozen? ? obj : obj.freeze
207
210
  else
208
211
  obj
209
212
  end