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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,138 +1,607 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'securerandom'
5
+ require_relative 'solid_cache_coordination'
4
6
  require_relative 'store'
5
7
 
6
8
  module Woods
7
9
  module SessionTracer
8
- # SolidCache-backed session store.
10
+ # SolidCache-backed session store using atomic counters and bounded slots.
9
11
  #
10
- # Uses SolidCache key-value storage with `expires_in`. Single JSON blob
11
- # per session (read-modify-write pattern). Requires the `solid_cache` gem.
12
+ # Storage layout, all bounded by construction:
13
+ # - one global epoch counter that fences +clear_all+
14
+ # - one global admission counter that orders memberships
15
+ # - +max_sessions+ directory slots, each with a permanent record counter
16
+ # - +max_sessions+ x +max_requests_per_session+ ring record keys, keyed by
17
+ # directory slot (never by session or token), so a writer that crashes
18
+ # after publishing cannot grow storage past the fixed keyspace
12
19
  #
13
- # @example
14
- # store = SolidCacheStore.new(cache: SolidCache::Store.new, expires_in: 3600)
15
- # store.record("abc123", { controller: "OrdersController", action: "create" })
16
- #
17
- class SolidCacheStore < Store
20
+ # Counters recover from observable evidence (ring payload sequences,
21
+ # directory memberships) when missing or corrupt, and fail closed with
22
+ # BackendWriteError when the backend cannot hold the recovered value.
23
+ class SolidCacheStore < Store # rubocop:disable Metrics/ClassLength
18
24
  KEY_PREFIX = 'woods:session:'
19
- INDEX_KEY = 'woods:session_index'
25
+ INDEX_PREFIX = 'woods:session_index'
26
+ DEFAULT_MAX_SESSIONS = 1_000
27
+ DEFAULT_MAX_REQUESTS = 1_000
28
+ DIRECTORY_CLAIM_ATTEMPTS = 3
29
+ RECORD_PUBLISH_ATTEMPTS = 32
30
+ READ_ATTEMPTS = 8
31
+ COUNTER_RECOVERY_ATTEMPTS = 3
32
+
33
+ class AtomicIncrementRequired < Woods::Error; end
34
+ class BackendWriteError < Woods::Error; end
35
+ class DirectoryContentionError < Woods::Error; end
36
+ class ReadContentionError < Woods::Error; end
37
+
38
+ DirectorySlot = Struct.new(:slot, :raw, :membership, keyword_init: true)
20
39
 
21
- # @param cache [ActiveSupport::Cache::Store] A SolidCache (or compatible) cache instance
40
+ # @param cache [ActiveSupport::Cache::Store] A Solid Cache store, or a cache with atomic
41
+ # +increment+, +write_if_absent+, and +delete_if_equal+ operations
22
42
  # @param expires_in [Integer, nil] Expiry time in seconds (nil = no expiry)
23
- def initialize(cache:, expires_in: nil)
43
+ def initialize(cache:, expires_in: nil, max_sessions: DEFAULT_MAX_SESSIONS,
44
+ max_requests_per_session: DEFAULT_MAX_REQUESTS)
24
45
  super()
25
46
  @cache = cache
47
+ unless cache.respond_to?(:increment)
48
+ raise ArgumentError, 'SolidCacheStore requires a backend with atomic #increment'
49
+ end
50
+ unless cache.respond_to?(:write_if_absent) || solid_cache_backend?
51
+ raise ArgumentError,
52
+ 'SolidCacheStore requires atomic #write_if_absent or a direct SolidCache::Store backend'
53
+ end
54
+ unless cache.respond_to?(:delete_if_equal) || solid_cache_backend?
55
+ raise ArgumentError,
56
+ 'SolidCacheStore requires atomic #delete_if_equal or a direct SolidCache::Store backend'
57
+ end
58
+
26
59
  @expires_in = expires_in
60
+ @max_sessions = positive_integer!(:max_sessions, max_sessions)
61
+ @max_requests_per_session = positive_integer!(:max_requests_per_session, max_requests_per_session)
62
+ @backend = solid_cache_backend? ? SolidCacheCoordination.new(cache) : cache
27
63
  end
28
64
 
29
- # Append a request record to a session (read-modify-write).
30
- #
31
- # NOTE: Not atomic — concurrent writes to the same session may lose data.
32
- # Acceptable for development tracing. For high-concurrency tracing, use
33
- # RedisStore (RPUSH is atomic) or FileStore (LOCK_EX).
34
- #
35
- # @param session_id [String] The session identifier
36
- # @param request_data [Hash] Request metadata to store
37
- # @return [void]
65
+ # Admit the session, allocate a monotonic sequence, and publish one record.
38
66
  def record(session_id, request_data)
39
- key = session_key(session_id)
40
- existing = @cache.read(key)
41
- requests = existing ? JSON.parse(existing) : []
42
- requests << request_data
43
-
44
- write_opts = @expires_in ? { expires_in: @expires_in } : {}
45
- @cache.write(key, JSON.generate(requests), **write_opts)
67
+ normalized_record = JSON.parse(JSON.generate(request_data))
68
+ epoch = current_epoch
69
+ membership = owned_membership(session_id, epoch) || admit_session(session_id, epoch)
70
+ return unless membership
46
71
 
47
- update_index(session_id)
72
+ publish_record(membership, normalized_record)
48
73
  end
49
74
 
50
- # Read all request records for a session.
51
- #
52
- # @param session_id [String] The session identifier
53
- # @return [Array<Hash>] Request records, oldest first
75
+ # Read one completed bounded sequence window. Retries when a concurrent
76
+ # overwrite moves the ring mid-read so a mixed window is never returned.
54
77
  def read(session_id)
55
- key = session_key(session_id)
56
- raw = @cache.read(key)
57
- return [] unless raw
78
+ READ_ATTEMPTS.times do
79
+ records = read_snapshot(session_id)
80
+ return records if records
81
+ end
58
82
 
59
- JSON.parse(raw)
60
- rescue JSON::ParserError
61
- []
83
+ raise ReadContentionError,
84
+ "SolidCacheStore could not obtain a stable read for #{session_id.inspect} after " \
85
+ "#{READ_ATTEMPTS} attempts; retry once backend write contention subsides"
62
86
  end
63
87
 
64
- # List recent session summaries.
65
- #
66
- # @param limit [Integer] Maximum number of sessions to return
67
- # @return [Array<Hash>] Session summaries
88
+ # List the bounded active-session set, newest admission first.
68
89
  def sessions(limit: 20)
69
- index = read_index
70
- active = index.select { |id| @cache.exist?(session_key(id)) }
71
-
72
- # Clean up expired entries from the index
73
- write_index(active) if active.size != index.size
74
-
75
- active.first(limit).map do |session_id|
76
- session_summary(session_id, read(session_id))
77
- end
90
+ active_memberships.reverse_each.filter_map do |membership|
91
+ session_id = membership['session_id']
92
+ requests = read(session_id)
93
+ session_summary(session_id, requests) unless requests.empty?
94
+ end.first([limit, @max_sessions].min)
78
95
  end
79
96
 
80
- # Remove all data for a single session.
81
- #
82
- # @param session_id [String] The session identifier
83
- # @return [void]
84
97
  def clear(session_id)
85
- @cache.delete(session_key(session_id))
86
- index = read_index
87
- index.delete(session_id)
88
- write_index(index)
98
+ membership = active_membership(session_id)
99
+ retire_membership(membership) if membership
100
+ nil
89
101
  end
90
102
 
91
- # Remove all session data.
92
- #
93
- # @return [void]
94
103
  def clear_all
95
- index = read_index
96
- index.each { |id| @cache.delete(session_key(id)) }
97
- @cache.delete(INDEX_KEY)
104
+ increment!(epoch_key)
105
+ directory_slots.each do |entry|
106
+ if entry.membership
107
+ retire_membership(entry.membership)
108
+ elsif entry.raw
109
+ atomic_delete_if_equal(index_slot_key(entry.slot), entry.raw)
110
+ end
111
+ end
112
+ nil
98
113
  end
99
114
 
100
115
  private
101
116
 
102
- # @param session_id [String]
103
- # @return [String] Cache key for this session
104
- def session_key(session_id)
105
- "#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
117
+ def publish_record(membership, normalized_record)
118
+ slot = membership['slot']
119
+ sequence_key = slot_sequence_key(slot)
120
+ sequence = increment!(sequence_key, slot: slot)
121
+ key = record_key(slot, sequence)
122
+ payload = JSON.generate('sequence' => sequence, 'token' => membership['token'],
123
+ 'record' => normalized_record)
124
+ published = publish_current_slot?(key, sequence, sequence_key, slot, payload)
125
+ epoch_now = current_epoch
126
+ if published && current_membership?(membership, epoch_now)
127
+ refresh_activity!(membership)
128
+ return if current_membership?(membership, epoch_now)
129
+ end
130
+
131
+ atomic_delete_if_equal(key, payload)
132
+ # Only tear down the activity key when the membership is genuinely no
133
+ # longer ours. A publish lost to a window race leaves the membership
134
+ # fully current, and deleting the live token's activity key here made
135
+ # the next TTL check retire the session and destroy committed records.
136
+ @backend.delete(activity_key(membership)) unless current_membership?(membership, current_epoch)
137
+ nil
138
+ end
139
+
140
+ # One read attempt: returns records for a stable window, [] for no
141
+ # session, or nil when the window moved and the caller must retry.
142
+ def read_snapshot(session_id)
143
+ epoch = current_epoch
144
+ membership = owned_membership(session_id, epoch)
145
+ return [] unless membership
146
+
147
+ slot = membership['slot']
148
+ sequence_key = slot_sequence_key(slot)
149
+ sequence = counter_value(sequence_key, slot: slot)
150
+ first_sequence = [sequence - @max_requests_per_session + 1, membership['record_floor'] + 1, 1].max
151
+ return [] if first_sequence > sequence
152
+
153
+ records = (first_sequence..sequence).filter_map do |expected|
154
+ parse_record(@backend.read(record_key(slot, expected)), membership, expected)
155
+ end
156
+ return unless counter_value(sequence_key, slot: slot) == sequence
157
+ return [] unless current_membership?(membership, epoch)
158
+
159
+ records
160
+ end
161
+
162
+ def increment!(key, amount = 1, slot: nil)
163
+ ensure_counter_present!(key, slot: slot)
164
+ value = @backend.increment(key, amount)
165
+ return value if value.is_a?(Integer) && value.positive?
166
+
167
+ message = "SolidCache backend atomic #increment failed for #{key.inspect}; " \
168
+ 'configure a backend that supports increment'
169
+ raise AtomicIncrementRequired, message
170
+ rescue NotImplementedError, NoMethodError => e
171
+ raise AtomicIncrementRequired,
172
+ "SolidCacheStore requires a working backend atomic #increment (#{e.class}: #{e.message})"
173
+ end
174
+
175
+ def ensure_counter_present!(key, slot: nil)
176
+ return if integer_value(@backend.read(key))
177
+
178
+ counter_value(key, slot: slot)
179
+ end
180
+
181
+ # Read a counter, recovering from evidence when it is missing or corrupt.
182
+ def counter_value(key, slot: nil)
183
+ raw = @backend.read(key)
184
+ value = integer_value(raw)
185
+ return value if value
186
+
187
+ recover_counter!(key, raw, slot: slot)
188
+ end
189
+
190
+ def integer_value(raw)
191
+ return raw if raw.is_a?(Integer)
192
+ return unless raw.is_a?(String)
193
+
194
+ Integer(raw, 10)
195
+ rescue ArgumentError
196
+ nil
197
+ end
198
+
199
+ # Re-initialize a lost or corrupt counter at a floor no visible sequence
200
+ # exceeds, so recovery never reuses a published sequence. Fails closed
201
+ # when the backend cannot hold the recovered value.
202
+ def recover_counter!(key, observed_raw, slot: nil)
203
+ floor = counter_floor(key, slot)
204
+ COUNTER_RECOVERY_ATTEMPTS.times do
205
+ atomic_delete_if_equal(key, observed_raw) if observed_raw
206
+ atomic_write_if_absent(key, floor)
207
+ observed_raw = @backend.read(key)
208
+ value = integer_value(observed_raw)
209
+ return value if value
210
+ end
211
+
212
+ raise BackendWriteError,
213
+ "SolidCache backend could not recover counter #{key.inspect}; " \
214
+ 'clear the corrupt entry or repair the cache backend'
215
+ end
216
+
217
+ def counter_floor(key, slot)
218
+ return ring_sequence_floor(slot) if slot
219
+
220
+ key == epoch_key ? epoch_recovery_floor : directory_sequence_floor
221
+ end
222
+
223
+ # A lost fence must never regress: any directory membership could be one
224
+ # clear_all already fenced (a crash between the epoch bump and slot
225
+ # retirement leaves it in place), so recovery lands one PAST the newest
226
+ # observed epoch. Live sessions at that epoch re-admit on their next
227
+ # record; cleared data stays cleared. An empty directory recovers to 0.
228
+ def epoch_recovery_floor
229
+ members = directory_slots.filter_map { |entry| entry.membership&.fetch('epoch') }
230
+ return 0 if members.empty?
231
+
232
+ members.max + 1
233
+ end
234
+
235
+ # Evidence floor for a slot counter. An occupied slot is scanned in full
236
+ # (the corrupt-counter-with-live-records case); an unoccupied slot is
237
+ # probed at ring position 1 only, so claiming a fresh slot stays O(1).
238
+ # Residual: a backend that evicts a slot counter while leaving crash
239
+ # orphans deeper in an unoccupied ring can refuse publishes until the
240
+ # counter advances past them; bounded and self-healing.
241
+ def ring_sequence_floor(slot)
242
+ occupant = parse_membership(@backend.read(index_slot_key(slot)), expected_slot: slot)
243
+ sequences = [payload_sequence(@backend.read(record_ring_key(slot, 1)))]
244
+ if occupant
245
+ (2..@max_requests_per_session).each do |ring_slot|
246
+ sequences << payload_sequence(@backend.read(record_ring_key(slot, ring_slot)))
247
+ end
248
+ sequences << occupant['record_floor']
249
+ end
250
+ [0, *sequences.compact].max
251
+ end
252
+
253
+ def directory_sequence_floor
254
+ directory_slots.filter_map { |entry| entry.membership&.fetch('sequence') }.max || 0
255
+ end
256
+
257
+ def write!(key, value)
258
+ return if @backend.write(key, value, **write_options)
259
+
260
+ raise BackendWriteError, "SolidCache backend failed to write #{key.inspect}"
261
+ end
262
+
263
+ def publish_current_slot?(key, sequence, counter_key, slot, value)
264
+ return false unless write_newer_slot(key, sequence, value)
265
+
266
+ if stale_sequence?(sequence, @max_requests_per_session, counter_key, slot)
267
+ atomic_delete_if_equal(key, value)
268
+ false
269
+ else
270
+ true
271
+ end
272
+ end
273
+
274
+ def write_newer_slot(key, sequence, value)
275
+ RECORD_PUBLISH_ATTEMPTS.times do
276
+ raw = @backend.read(key)
277
+ stored_sequence = payload_sequence(raw)
278
+ return false if stored_sequence && stored_sequence >= sequence
279
+
280
+ next if raw && !atomic_delete_if_equal(key, raw)
281
+ return true if atomic_write_if_absent(key, value, **write_options)
282
+
283
+ Thread.pass
284
+ end
285
+
286
+ raise BackendWriteError, "SolidCache backend could not publish #{key.inspect} after " \
287
+ "#{RECORD_PUBLISH_ATTEMPTS} attempts"
288
+ end
289
+
290
+ def write_options
291
+ @expires_in ? { expires_in: @expires_in } : {}
292
+ end
293
+
294
+ def parse_record(raw, membership, expected_sequence)
295
+ return unless raw
296
+
297
+ payload = JSON.parse(raw)
298
+ return unless payload.is_a?(Hash)
299
+ return unless payload['token'] == membership['token'] && payload['sequence'] == expected_sequence
300
+
301
+ payload['record']
302
+ rescue JSON::ParserError, TypeError
303
+ nil
304
+ end
305
+
306
+ def payload_sequence(raw)
307
+ return unless raw
308
+
309
+ payload = JSON.parse(raw)
310
+ payload['sequence'] if payload.is_a?(Hash) && payload['sequence'].is_a?(Integer)
311
+ rescue JSON::ParserError, TypeError
312
+ nil
313
+ end
314
+
315
+ def payload_token(raw)
316
+ return unless raw
317
+
318
+ payload = JSON.parse(raw)
319
+ payload['token'] if payload.is_a?(Hash)
320
+ rescue JSON::ParserError, TypeError
321
+ nil
322
+ end
323
+
324
+ def admit_session(session_id, epoch)
325
+ DIRECTORY_CLAIM_ATTEMPTS.times do
326
+ membership = build_admission(session_id, epoch)
327
+ return unless membership
328
+
329
+ if atomic_write_if_absent(active_key(session_id), serialized_membership(membership))
330
+ refresh_activity!(membership)
331
+ return finalize_admission(membership)
332
+ end
333
+
334
+ release_directory_slot(membership)
335
+ concurrent = owned_membership(session_id, current_epoch)
336
+ return concurrent if concurrent
337
+
338
+ reclaim_active_mapping(session_id)
339
+ end
340
+ nil
341
+ end
342
+
343
+ def build_admission(session_id, epoch)
344
+ attributes = {
345
+ 'sequence' => increment!(index_sequence_key),
346
+ 'epoch' => epoch,
347
+ 'session_id' => session_id.to_s,
348
+ 'token' => SecureRandom.hex(16)
349
+ }
350
+ claim_directory_slot(attributes, epoch)
351
+ end
352
+
353
+ # Conditionally remove an active mapping with no live directory owner or
354
+ # a stale epoch so the session can be admitted again. Exact-value CAS
355
+ # deletes mean a concurrent valid owner is never removed.
356
+ def reclaim_active_mapping(session_id)
357
+ raw = @backend.read(active_key(session_id))
358
+ return unless raw
359
+
360
+ membership = parse_membership(raw)
361
+ if membership.nil?
362
+ atomic_delete_if_equal(active_key(session_id), raw)
363
+ elsif !indexed_membership?(membership) || membership['epoch'] != current_epoch
364
+ retire_membership(membership)
365
+ end
366
+ nil
106
367
  end
107
368
 
108
- # Read the session index (list of known session IDs).
109
- #
110
- # @return [Array<String>]
111
- def read_index
112
- raw = @cache.read(INDEX_KEY)
113
- return [] unless raw
369
+ def finalize_admission(membership)
370
+ epoch_now = current_epoch
371
+ if current_membership?(membership, epoch_now) && !stale_index_sequence?(membership['sequence'])
372
+ return membership
373
+ end
374
+
375
+ retire_membership(membership)
376
+ owned_membership(membership['session_id'], epoch_now)
377
+ end
378
+
379
+ def claim_directory_slot(attributes, admission_epoch)
380
+ DIRECTORY_CLAIM_ATTEMPTS.times do |attempt|
381
+ epoch = attempt.zero? ? admission_epoch : current_epoch
382
+ slots = directory_slots
383
+ membership = claim_reclaimable_slot(attributes, slots, epoch)
384
+ return membership if membership
385
+
386
+ victim = oldest_active_membership(slots.filter_map(&:membership), epoch)
387
+ retire_membership(victim) if victim
388
+ end
389
+
390
+ raise DirectoryContentionError,
391
+ "SolidCacheStore could not claim a directory slot after #{DIRECTORY_CLAIM_ATTEMPTS} attempts " \
392
+ "for #{attributes['session_id'].inspect}; retry the record operation or investigate backend contention"
393
+ end
394
+
395
+ def claim_reclaimable_slot(attributes, slots, epoch)
396
+ slots.each do |entry|
397
+ occupant = entry.membership
398
+ next if occupant && active_current_membership?(occupant, epoch)
399
+
400
+ if occupant
401
+ retire_membership(occupant)
402
+ elsif entry.raw
403
+ atomic_delete_if_equal(index_slot_key(entry.slot), entry.raw)
404
+ end
405
+ membership = attributes.merge(
406
+ 'slot' => entry.slot,
407
+ 'record_floor' => counter_value(slot_sequence_key(entry.slot), slot: entry.slot)
408
+ )
409
+ return membership if claim_directory_slot?(membership)
410
+ end
411
+ nil
412
+ end
413
+
414
+ def oldest_active_membership(memberships, epoch)
415
+ memberships.compact.select { |membership| active_current_membership?(membership, epoch) }
416
+ .min_by { |membership| membership['sequence'] }
417
+ end
418
+
419
+ def claim_directory_slot?(membership)
420
+ key = index_slot_key(membership['slot'])
421
+ written = atomic_write_if_absent(key, serialized_membership(membership))
422
+ written && indexed_membership?(membership)
423
+ end
424
+
425
+ def active_memberships
426
+ epoch = current_epoch
427
+ directory_slots.filter_map(&:membership)
428
+ .select { |membership| active_current_membership?(membership, epoch) }
429
+ .sort_by { |membership| membership['sequence'] }
430
+ end
431
+
432
+ def directory_slots
433
+ @max_sessions.times.map do |slot|
434
+ raw = @backend.read(index_slot_key(slot))
435
+ DirectorySlot.new(slot: slot, raw: raw, membership: parse_membership(raw, expected_slot: slot))
436
+ end
437
+ end
438
+
439
+ def owned_membership(session_id, epoch)
440
+ membership = active_membership(session_id)
441
+ membership if membership && membership['epoch'] == epoch && indexed_membership?(membership)
442
+ end
443
+
444
+ def active_membership(session_id)
445
+ membership = stored_membership(session_id)
446
+ return membership unless membership && @expires_in
447
+ return membership if @backend.read(activity_key(membership))
448
+
449
+ retire_membership(membership)
450
+ nil
451
+ end
452
+
453
+ def stored_membership(session_id)
454
+ parse_membership(@backend.read(active_key(session_id)))
455
+ end
456
+
457
+ def parse_membership(raw, expected_slot: nil)
458
+ return unless raw
459
+
460
+ membership = JSON.parse(raw)
461
+ return unless valid_membership?(membership)
462
+ return if expected_slot && membership['slot'] != expected_slot
463
+
464
+ membership
465
+ rescue JSON::ParserError, TypeError
466
+ nil
467
+ end
468
+
469
+ def valid_membership?(membership)
470
+ return false unless membership.is_a?(Hash)
471
+
472
+ valid_membership_identity?(membership) && valid_membership_epoch?(membership) &&
473
+ valid_membership_position?(membership)
474
+ end
475
+
476
+ def valid_membership_identity?(membership)
477
+ membership['sequence'].is_a?(Integer) && membership['sequence'].positive? &&
478
+ membership['session_id'].is_a?(String) && membership['token'].is_a?(String)
479
+ end
480
+
481
+ def valid_membership_epoch?(membership)
482
+ membership['epoch'].is_a?(Integer) && membership['epoch'] >= 0
483
+ end
484
+
485
+ def valid_membership_position?(membership)
486
+ membership['slot'].is_a?(Integer) && membership['slot'].between?(0, @max_sessions - 1) &&
487
+ membership['record_floor'].is_a?(Integer) && membership['record_floor'] >= 0
488
+ end
489
+
490
+ def current_membership?(membership, epoch)
491
+ membership['epoch'] == epoch &&
492
+ stored_membership(membership['session_id']) == membership &&
493
+ indexed_membership?(membership)
494
+ end
495
+
496
+ def active_current_membership?(membership, epoch)
497
+ membership['epoch'] == epoch &&
498
+ active_membership(membership['session_id']) == membership &&
499
+ indexed_membership?(membership)
500
+ end
501
+
502
+ def retire_membership(membership)
503
+ atomic_delete_if_equal(active_key(membership['session_id']), serialized_membership(membership))
504
+ @backend.delete(activity_key(membership))
505
+ release_directory_slot(membership)
506
+ cleanup_slot_records(membership)
507
+ end
508
+
509
+ def refresh_activity!(membership)
510
+ return unless @expires_in
511
+
512
+ write!(activity_key(membership), true)
513
+ end
514
+
515
+ # Delete only the ring records owned by the retired membership's token,
516
+ # bounded to the window it could have written. A successor's records
517
+ # carry a different token and are never touched.
518
+ def cleanup_slot_records(membership)
519
+ slot = membership['slot']
520
+ upper = counter_value(slot_sequence_key(slot), slot: slot)
521
+ lower = [membership['record_floor'] + 1, upper - @max_requests_per_session + 1, 1].max
522
+ (lower..upper).each do |sequence|
523
+ key = record_key(slot, sequence)
524
+ raw = @backend.read(key)
525
+ next unless raw && payload_token(raw) == membership['token']
526
+
527
+ atomic_delete_if_equal(key, raw)
528
+ end
529
+ end
530
+
531
+ def indexed_membership?(membership)
532
+ raw = @backend.read(index_slot_key(membership['slot']))
533
+ parse_membership(raw, expected_slot: membership['slot']) == membership
534
+ end
535
+
536
+ def release_directory_slot(membership)
537
+ atomic_delete_if_equal(index_slot_key(membership['slot']), serialized_membership(membership))
538
+ end
539
+
540
+ def atomic_delete_if_equal(key, expected)
541
+ @backend.delete_if_equal(key, expected)
542
+ end
543
+
544
+ def atomic_write_if_absent(key, value, **options)
545
+ @backend.write_if_absent(key, value, **options)
546
+ end
547
+
548
+ def solid_cache_backend?
549
+ defined?(::SolidCache::Store) && @cache.is_a?(::SolidCache::Store)
550
+ end
551
+
552
+ def serialized_membership(membership)
553
+ JSON.generate(membership)
554
+ end
555
+
556
+ def stale_sequence?(sequence, window_size, counter_key, slot = nil)
557
+ sequence <= counter_value(counter_key, slot: slot) - window_size
558
+ end
559
+
560
+ def stale_index_sequence?(sequence)
561
+ stale_sequence?(sequence, @max_sessions, index_sequence_key)
562
+ end
563
+
564
+ def current_epoch
565
+ counter_value(epoch_key)
566
+ end
567
+
568
+ def active_key(session_id)
569
+ "#{KEY_PREFIX}#{sanitize_session_id(session_id)}:active"
570
+ end
571
+
572
+ def activity_key(membership)
573
+ "#{KEY_PREFIX}#{sanitize_session_id(membership['session_id'])}:generation:#{membership['token']}:activity"
574
+ end
575
+
576
+ def slot_sequence_key(slot)
577
+ "#{INDEX_PREFIX}:slot:#{slot}:sequence"
578
+ end
579
+
580
+ def record_key(slot, sequence)
581
+ ring_slot = ((sequence - 1) % @max_requests_per_session) + 1
582
+ record_ring_key(slot, ring_slot)
583
+ end
584
+
585
+ def record_ring_key(slot, ring_slot)
586
+ "#{INDEX_PREFIX}:slot:#{slot}:record:#{ring_slot}"
587
+ end
588
+
589
+ def epoch_key
590
+ "#{INDEX_PREFIX}:epoch"
591
+ end
114
592
 
115
- JSON.parse(raw)
116
- rescue JSON::ParserError
117
- []
593
+ def index_sequence_key
594
+ "#{INDEX_PREFIX}:sequence"
118
595
  end
119
596
 
120
- # Write the session index.
121
- #
122
- # @param ids [Array<String>]
123
- def write_index(ids)
124
- @cache.write(INDEX_KEY, JSON.generate(ids))
597
+ def index_slot_key(slot)
598
+ "#{INDEX_PREFIX}:slot:#{slot}"
125
599
  end
126
600
 
127
- # Add a session ID to the index if not already present.
128
- #
129
- # @param session_id [String]
130
- def update_index(session_id)
131
- index = read_index
132
- return if index.include?(session_id)
601
+ def positive_integer!(name, value)
602
+ return value if value.is_a?(Integer) && value.positive?
133
603
 
134
- index << session_id
135
- write_index(index)
604
+ raise ArgumentError, "#{name} must be a positive Integer"
136
605
  end
137
606
  end
138
607
  end