woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- 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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- 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 +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- 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/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- 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 +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- 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 +2503 -192
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- 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 +108 -23
- 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 +907 -154
- 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/mcp/version_aware_tool_dispatch.rb +3 -9
- 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 +223 -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 +70 -38
- 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 +25 -6
- 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.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- 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 +53 -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 +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- 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/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- 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/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- 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
|
@@ -33,20 +33,29 @@ module Woods
|
|
|
33
33
|
# @param context_pool [Hash<String, Hash>] Deduplicated unit data keyed by identifier
|
|
34
34
|
# @param side_effects [Array<Hash>] Async side effects (jobs, mailers)
|
|
35
35
|
# @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
|
|
36
|
-
# @param token_count [Integer] Estimated
|
|
36
|
+
# @param token_count [Integer] Estimated tokens of the rendered context output
|
|
37
|
+
# @param budget_exceeded [Boolean] True when even the fully-truncated
|
|
38
|
+
# rendering exceeds the requested budget, so callers see an honest
|
|
39
|
+
# flag instead of a silently over-budget document
|
|
37
40
|
# @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
|
|
38
41
|
# rubocop:disable-next Metrics/ParameterLists
|
|
39
42
|
def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
|
|
40
|
-
dependency_map: {}, token_count: 0, generated_at: nil)
|
|
43
|
+
dependency_map: {}, token_count: 0, budget_exceeded: false, generated_at: nil)
|
|
41
44
|
@session_id = session_id
|
|
42
45
|
@steps = steps
|
|
43
46
|
@context_pool = context_pool
|
|
44
47
|
@side_effects = side_effects
|
|
45
48
|
@dependency_map = dependency_map
|
|
46
49
|
@token_count = token_count
|
|
50
|
+
@budget_exceeded = budget_exceeded
|
|
47
51
|
@generated_at = generated_at || Time.now.utc.iso8601
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def budget_exceeded?
|
|
56
|
+
@budget_exceeded ? true : false
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
# Serialize to a JSON-compatible Hash.
|
|
51
60
|
#
|
|
52
61
|
# @return [Hash]
|
|
@@ -55,6 +64,7 @@ module Woods
|
|
|
55
64
|
session_id: @session_id,
|
|
56
65
|
generated_at: @generated_at,
|
|
57
66
|
token_count: @token_count,
|
|
67
|
+
budget_exceeded: budget_exceeded?,
|
|
58
68
|
steps: @steps,
|
|
59
69
|
context_pool: @context_pool,
|
|
60
70
|
side_effects: @side_effects,
|
|
@@ -78,6 +88,7 @@ module Woods
|
|
|
78
88
|
side_effects: data[:side_effects] || [],
|
|
79
89
|
dependency_map: data[:dependency_map] || {},
|
|
80
90
|
token_count: data[:token_count] || 0,
|
|
91
|
+
budget_exceeded: data.fetch(:budget_exceeded, false),
|
|
81
92
|
generated_at: data[:generated_at]
|
|
82
93
|
)
|
|
83
94
|
end
|
|
@@ -148,13 +159,13 @@ module Woods
|
|
|
148
159
|
|
|
149
160
|
# Render as LLM-consumable XML context.
|
|
150
161
|
#
|
|
151
|
-
# Follows the format from docs/CONTEXT_AND_CHUNKING.md
|
|
162
|
+
# Follows the format from `git log --follow -- docs/design/CONTEXT_AND_CHUNKING.md`.
|
|
152
163
|
#
|
|
153
164
|
# @return [String]
|
|
154
165
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
155
166
|
def to_context
|
|
156
167
|
lines = []
|
|
157
|
-
header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
|
|
168
|
+
header = "<session_context session_id=\"#{escape_attribute(@session_id)}\" requests=\"#{@steps.size}\" " \
|
|
158
169
|
"tokens=\"#{@token_count}\" units=\"#{@context_pool.size}\">"
|
|
159
170
|
lines << header
|
|
160
171
|
|
|
@@ -173,8 +184,10 @@ module Woods
|
|
|
173
184
|
@context_pool.each do |identifier, unit|
|
|
174
185
|
type = unit[:type] || 'unknown'
|
|
175
186
|
file_path = unit[:file_path] || 'unknown'
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
tag = %(<unit identifier="#{escape_attribute(identifier)}" type="#{escape_attribute(type)}" ) \
|
|
188
|
+
"file=\"#{escape_attribute(file_path)}\">"
|
|
189
|
+
lines << tag
|
|
190
|
+
lines << escape_content(unit[:source_code] || '# source not available')
|
|
178
191
|
lines << '</unit>'
|
|
179
192
|
end
|
|
180
193
|
|
|
@@ -200,6 +213,39 @@ module Woods
|
|
|
200
213
|
lines.join("\n")
|
|
201
214
|
end
|
|
202
215
|
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Escape a value for use inside a double-quoted attribute in
|
|
219
|
+
# {#to_context}'s markup.
|
|
220
|
+
#
|
|
221
|
+
# This is prompt-injection hardening, not XML conformance: `session_id`
|
|
222
|
+
# comes from the client-controlled X-Trace-Session header, and unit
|
|
223
|
+
# identifiers/paths flow through from extracted code. Unescaped, a
|
|
224
|
+
# crafted value (e.g. `"><injected_tag>`) breaks out of the attribute
|
|
225
|
+
# and inserts sibling markup into context an LLM later reads as trusted
|
|
226
|
+
# structure.
|
|
227
|
+
#
|
|
228
|
+
# @param value [Object]
|
|
229
|
+
# @return [String]
|
|
230
|
+
def escape_attribute(value)
|
|
231
|
+
value.to_s
|
|
232
|
+
.gsub('&', '&')
|
|
233
|
+
.gsub('"', '"')
|
|
234
|
+
.gsub('<', '<')
|
|
235
|
+
.gsub('>', '>')
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Escape a value for use as element content (between tags) in
|
|
239
|
+
# {#to_context}'s markup. Same prompt-injection motivation as
|
|
240
|
+
# {#escape_attribute}; content only needs `&` and `<` escaped since
|
|
241
|
+
# there's no attribute quote to break out of.
|
|
242
|
+
#
|
|
243
|
+
# @param value [Object]
|
|
244
|
+
# @return [String]
|
|
245
|
+
def escape_content(value)
|
|
246
|
+
value.to_s.gsub('&', '&').gsub('<', '<')
|
|
247
|
+
end
|
|
248
|
+
|
|
203
249
|
# @api private
|
|
204
250
|
def self.deep_symbolize_keys(obj)
|
|
205
251
|
case obj
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/cache'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
8
|
+
|
|
9
|
+
module SessionTracer
|
|
10
|
+
# Uncached, per-key routed coordination operations for Solid Cache 1.0.
|
|
11
|
+
class SolidCacheCoordination # rubocop:disable Metrics/ClassLength
|
|
12
|
+
FAILURE = Object.new.freeze
|
|
13
|
+
|
|
14
|
+
class BackendError < Woods::Error; end
|
|
15
|
+
|
|
16
|
+
def initialize(cache)
|
|
17
|
+
@cache = cache
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(name, options = nil)
|
|
21
|
+
with_operation(:read, name, options) do |key, merged_options, event|
|
|
22
|
+
raw = routed_read(key, :read_entry)
|
|
23
|
+
entry = @cache.send(:deserialize_entry, raw, **merged_options)
|
|
24
|
+
if entry&.expired?
|
|
25
|
+
delete_expired_entry(key, raw)
|
|
26
|
+
entry = nil
|
|
27
|
+
end
|
|
28
|
+
event[:hit] = !entry.nil? if event
|
|
29
|
+
entry&.value
|
|
30
|
+
end
|
|
31
|
+
rescue ActiveSupport::Cache::DeserializationError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write(name, value, **options)
|
|
36
|
+
with_operation(:write, name, options) do |key, merged_options, _event|
|
|
37
|
+
entry = cache_entry(name, value, merged_options)
|
|
38
|
+
payload = @cache.send(:serialize_entry, entry, **merged_options)
|
|
39
|
+
result = @cache.send(:entry_write, key, payload)
|
|
40
|
+
backend_result!(result, :write, name, nil_is_failure: true)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(name, options = nil)
|
|
45
|
+
with_operation(:delete, name, options) do |key, _merged_options, _event|
|
|
46
|
+
routed_delete(key, :delete_entry).positive?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def increment(name, amount = 1, options = nil)
|
|
51
|
+
with_operation(:increment, name, options, amount: amount) do |key, merged_options, _event|
|
|
52
|
+
payload = @cache.send(:entry_lock_and_write, key) do |raw|
|
|
53
|
+
entry = @cache.send(:adjusted_entry, raw, amount, merged_options)
|
|
54
|
+
@cache.send(:serialize_entry, entry, **merged_options)
|
|
55
|
+
end
|
|
56
|
+
backend_result!(payload, :increment, name, nil_is_failure: true)
|
|
57
|
+
@cache.send(:deserialize_entry, payload, **merged_options).value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def write_if_absent(name, value, **options)
|
|
62
|
+
with_operation(:write, name, options) do |key, merged_options, _event|
|
|
63
|
+
entry = cache_entry(name, value, merged_options, version: "woods-cas:#{SecureRandom.hex(16)}")
|
|
64
|
+
payload = @cache.send(:serialize_entry, entry, **merged_options)
|
|
65
|
+
result = routed_write(key, :write_entry) do
|
|
66
|
+
entry_class = ::SolidCache::Entry
|
|
67
|
+
inserted = attempt_insert?(entry_class, key, payload)
|
|
68
|
+
if !inserted && clear_expired_entry?(entry_class, key, merged_options)
|
|
69
|
+
inserted = attempt_insert?(entry_class, key, payload)
|
|
70
|
+
end
|
|
71
|
+
@cache.send(:track_writes, 1) if inserted
|
|
72
|
+
inserted
|
|
73
|
+
end
|
|
74
|
+
backend_result!(result, :write_if_absent, name)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete_if_equal(name, expected)
|
|
79
|
+
with_operation(:delete, name, nil) do |key, merged_options, _event|
|
|
80
|
+
result = routed_write(key, :delete_entry) do
|
|
81
|
+
locked_compare_and_delete(key) do |raw_value|
|
|
82
|
+
entry = @cache.send(:deserialize_entry, raw_value, **merged_options)
|
|
83
|
+
entry && !entry.expired? && entry.value == expected
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
backend_result!(result, :delete_if_equal, name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def with_operation(operation, name, options, event_options = nil)
|
|
93
|
+
merged_options = @cache.send(:merged_options, options)
|
|
94
|
+
key = @cache.send(:normalize_key, name, merged_options)
|
|
95
|
+
instrument_options = event_options || merged_options
|
|
96
|
+
|
|
97
|
+
@cache.send(:bypass_local_cache) do
|
|
98
|
+
@cache.send(:instrument, operation, key, instrument_options) do |event|
|
|
99
|
+
yield key, merged_options, event
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
rescue NoMethodError => e
|
|
103
|
+
raise BackendError,
|
|
104
|
+
"Solid Cache private API unavailable for #{operation} #{name.inspect} (#{e.message}); " \
|
|
105
|
+
'this Solid Cache version is not supported by Woods session tracing'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Row-locked compare-and-delete on the raw stored payload. The block
|
|
109
|
+
# decides against the exact bytes read under lock, so a concurrent
|
|
110
|
+
# replacement can never be deleted by mistake.
|
|
111
|
+
def locked_compare_and_delete(key)
|
|
112
|
+
entry_class = ::SolidCache::Entry
|
|
113
|
+
key_hash = entry_class.send(:key_hash_for, key)
|
|
114
|
+
entry_class.transaction do
|
|
115
|
+
raw_key, raw_value = entry_class.lock.where(key_hash: key_hash).pick(:key, :value)
|
|
116
|
+
next false unless raw_key == key
|
|
117
|
+
next false unless yield(raw_value)
|
|
118
|
+
|
|
119
|
+
entry_class.delete_by_key(key)
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Conditionally remove an expired entry observed by a read: only the
|
|
125
|
+
# exact expired payload is deleted, never a fresh replacement.
|
|
126
|
+
def delete_expired_entry(key, observed_raw)
|
|
127
|
+
routed_write(key, :delete_entry) do
|
|
128
|
+
locked_compare_and_delete(key) { |raw_value| raw_value == observed_raw }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def attempt_insert?(entry_class, key, payload)
|
|
133
|
+
inserted?(entry_class, insert_entry(entry_class, key, payload), key, payload)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Treat a physically-present but logically-expired row as absent for
|
|
137
|
+
# conditional writes by conditionally removing the exact expired payload.
|
|
138
|
+
def clear_expired_entry?(entry_class, key, merged_options)
|
|
139
|
+
raw = entry_class.read(key)
|
|
140
|
+
return false unless raw
|
|
141
|
+
|
|
142
|
+
entry = @cache.send(:deserialize_entry, raw, **merged_options)
|
|
143
|
+
return false unless entry&.expired?
|
|
144
|
+
|
|
145
|
+
locked_compare_and_delete(key) { |raw_value| raw_value == raw }
|
|
146
|
+
rescue ActiveSupport::Cache::DeserializationError
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cache_entry(name, value, options, version: @cache.send(:normalize_version, name, options))
|
|
151
|
+
ActiveSupport::Cache::Entry.new(value, **options, version: version)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def insert_entry(entry_class, key, payload)
|
|
155
|
+
attributes = entry_class.send(:add_key_hash_and_byte_size, [{ key: key, value: payload }])
|
|
156
|
+
entry_class.insert_all(attributes, unique_by: entry_class.send(:upsert_unique_by))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def inserted?(entry_class, result, key, payload)
|
|
160
|
+
affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
|
|
161
|
+
return affected_rows.positive? unless affected_rows.nil?
|
|
162
|
+
return result.rows.any? if entry_class.connection.supports_insert_returning?
|
|
163
|
+
|
|
164
|
+
entry_class.read(key) == payload
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def routed_read(key, failsafe)
|
|
168
|
+
result = @cache.send(:reading_key, key, failsafe: failsafe, failsafe_returning: FAILURE) do
|
|
169
|
+
::SolidCache::Entry.read(key)
|
|
170
|
+
end
|
|
171
|
+
backend_result!(result, :read, key)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def routed_write(key, failsafe, &block)
|
|
175
|
+
@cache.send(:writing_key, key, failsafe: failsafe, failsafe_returning: FAILURE, &block)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def routed_delete(key, failsafe)
|
|
179
|
+
result = routed_write(key, failsafe) { ::SolidCache::Entry.delete_by_key(key) }
|
|
180
|
+
backend_result!(result, :delete, key)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def backend_result!(result, operation, key, nil_is_failure: false)
|
|
184
|
+
failed = result.equal?(FAILURE) || (nil_is_failure && result.nil?)
|
|
185
|
+
return result unless failed
|
|
186
|
+
|
|
187
|
+
raise BackendError,
|
|
188
|
+
"Solid Cache routed #{operation} failed for #{key.inspect}; inspect the cache backend error handler"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|