woods 1.6.1 → 2.0.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
72
|
+
publish_record(membership, normalized_record)
|
|
48
73
|
end
|
|
49
74
|
|
|
50
|
-
# Read
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
READ_ATTEMPTS.times do
|
|
79
|
+
records = read_snapshot(session_id)
|
|
80
|
+
return records if records
|
|
81
|
+
end
|
|
58
82
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
[]
|
|
593
|
+
def index_sequence_key
|
|
594
|
+
"#{INDEX_PREFIX}:sequence"
|
|
118
595
|
end
|
|
119
596
|
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
write_index(index)
|
|
604
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
136
605
|
end
|
|
137
606
|
end
|
|
138
607
|
end
|