solid_mcp 0.0.1 → 0.2.1
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/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +140 -0
- data/README.md +303 -14
- data/app/models/solid_mcp/message.rb +25 -0
- data/app/models/solid_mcp/record.rb +10 -0
- data/bin/rails +15 -0
- data/bin/test +8 -0
- data/db/migrate/20250624000001_create_solid_mcp_messages.rb +28 -0
- data/lib/generators/solid_mcp/install/install_generator.rb +28 -0
- data/lib/generators/solid_mcp/install/templates/create_solid_mcp_messages.rb.erb +26 -0
- data/lib/generators/solid_mcp/install/templates/solid_mcp.rb +20 -0
- data/lib/solid_mcp/cleanup_job.rb +12 -0
- data/lib/solid_mcp/configuration.rb +37 -0
- data/lib/solid_mcp/engine.rb +35 -0
- data/lib/solid_mcp/logger.rb +35 -0
- data/lib/solid_mcp/message_writer.rb +156 -0
- data/lib/solid_mcp/pub_sub.rb +58 -0
- data/lib/solid_mcp/subscriber.rb +95 -0
- data/lib/solid_mcp/test_pub_sub.rb +41 -0
- data/lib/solid_mcp/version.rb +2 -2
- data/lib/solid_mcp.rb +33 -2
- data/release-please-config.json +8 -0
- data/sig/solid_mcp.rbs +1 -1
- data/solid_mcp.gemspec +9 -4
- metadata +86 -11
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SolidMCP
|
4
|
+
class CleanupJob < ActiveJob::Base
|
5
|
+
def perform
|
6
|
+
SolidMCP::Message.cleanup(
|
7
|
+
delivered_retention: SolidMCP.configuration.delivered_retention_seconds,
|
8
|
+
undelivered_retention: SolidMCP.configuration.undelivered_retention_seconds
|
9
|
+
)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SolidMCP
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :batch_size, :flush_interval, :delivered_retention,
|
6
|
+
:undelivered_retention, :polling_interval, :max_wait_time, :logger
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@batch_size = 200
|
10
|
+
@flush_interval = 0.05 # 50ms
|
11
|
+
@polling_interval = 0.1 # 100ms
|
12
|
+
@max_wait_time = 30 # 30 seconds
|
13
|
+
@delivered_retention = 3600 # 1 hour in seconds
|
14
|
+
@undelivered_retention = 86400 # 24 hours in seconds
|
15
|
+
@logger = default_logger
|
16
|
+
end
|
17
|
+
|
18
|
+
def delivered_retention_seconds
|
19
|
+
@delivered_retention.seconds
|
20
|
+
end
|
21
|
+
|
22
|
+
def undelivered_retention_seconds
|
23
|
+
@undelivered_retention.seconds
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def default_logger
|
29
|
+
if defined?(Rails) && Rails.respond_to?(:logger)
|
30
|
+
Rails.logger
|
31
|
+
else
|
32
|
+
require 'active_support/tagged_logging'
|
33
|
+
ActiveSupport::TaggedLogging.new(::Logger.new($stdout))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SolidMCP
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace SolidMCP
|
6
|
+
|
7
|
+
config.generators do |g|
|
8
|
+
g.test_framework :minitest
|
9
|
+
end
|
10
|
+
|
11
|
+
# Ensure app/models is in the autoload paths
|
12
|
+
config.autoload_paths << root.join("app/models")
|
13
|
+
|
14
|
+
# Don't automatically add migrations - use the generator instead
|
15
|
+
# initializer "solid_mcp.migrations" do
|
16
|
+
# config.paths["db/migrate"].expanded.each do |expanded_path|
|
17
|
+
# Rails.application.config.paths["db/migrate"] << expanded_path
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
|
21
|
+
initializer "solid_mcp.configuration" do
|
22
|
+
# Set default configuration if not already configured
|
23
|
+
SolidMCP.configuration ||= Configuration.new
|
24
|
+
end
|
25
|
+
|
26
|
+
initializer "solid_mcp.start_message_writer" do
|
27
|
+
# Start the message writer in non-test environments
|
28
|
+
unless Rails.env.test?
|
29
|
+
Rails.application.config.to_prepare do
|
30
|
+
SolidMCP::MessageWriter.instance
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SolidMCP
|
4
|
+
module Logger
|
5
|
+
class << self
|
6
|
+
def logger
|
7
|
+
SolidMCP.configuration.logger
|
8
|
+
end
|
9
|
+
|
10
|
+
def tagged(*tags, &block)
|
11
|
+
logger.tagged(*tags, &block)
|
12
|
+
end
|
13
|
+
|
14
|
+
def debug(message = nil, &block)
|
15
|
+
logger.debug(message, &block)
|
16
|
+
end
|
17
|
+
|
18
|
+
def info(message = nil, &block)
|
19
|
+
logger.info(message, &block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def warn(message = nil, &block)
|
23
|
+
logger.warn(message, &block)
|
24
|
+
end
|
25
|
+
|
26
|
+
def error(message = nil, &block)
|
27
|
+
logger.error(message, &block)
|
28
|
+
end
|
29
|
+
|
30
|
+
def fatal(message = nil, &block)
|
31
|
+
logger.fatal(message, &block)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "singleton"
|
4
|
+
require "concurrent"
|
5
|
+
|
6
|
+
module SolidMCP
|
7
|
+
class MessageWriter
|
8
|
+
include Singleton
|
9
|
+
|
10
|
+
# Reset the singleton (for testing only)
|
11
|
+
def self.reset!
|
12
|
+
@singleton__instance__ = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@queue = Queue.new
|
17
|
+
@shutdown = Concurrent::AtomicBoolean.new(false)
|
18
|
+
@executor = Concurrent::ThreadPoolExecutor.new(
|
19
|
+
min_threads: 1,
|
20
|
+
max_threads: 1, # Single thread for ordered writes
|
21
|
+
max_queue: 0, # Unbounded queue
|
22
|
+
fallback_policy: :caller_runs
|
23
|
+
)
|
24
|
+
start_worker
|
25
|
+
end
|
26
|
+
|
27
|
+
# Called by publish API - non-blocking
|
28
|
+
def enqueue(session_id, event_type, data)
|
29
|
+
@queue << {
|
30
|
+
session_id: session_id,
|
31
|
+
event_type: event_type,
|
32
|
+
data: data.is_a?(String) ? data : data.to_json,
|
33
|
+
created_at: Time.current
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
# Blocks until executor has flushed everything
|
38
|
+
def shutdown
|
39
|
+
# Process any remaining messages in the queue
|
40
|
+
flush if @executor.running?
|
41
|
+
|
42
|
+
@shutdown.make_true
|
43
|
+
@executor.shutdown
|
44
|
+
@executor.wait_for_termination(10) # Wait up to 10 seconds
|
45
|
+
end
|
46
|
+
|
47
|
+
# Force flush any pending messages (useful for tests)
|
48
|
+
def flush
|
49
|
+
return unless @executor.running?
|
50
|
+
|
51
|
+
# Add a marker and wait for it to be processed
|
52
|
+
processed = Concurrent::CountDownLatch.new(1)
|
53
|
+
@queue << { flush_marker: processed }
|
54
|
+
|
55
|
+
# Wait up to 1 second for flush to complete
|
56
|
+
processed.wait(1)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def start_worker
|
62
|
+
@executor.post do
|
63
|
+
begin
|
64
|
+
SolidMCP::Logger.debug "MessageWriter worker thread started" if ENV["DEBUG_SOLID_MCP"]
|
65
|
+
run_loop
|
66
|
+
rescue => e
|
67
|
+
SolidMCP::Logger.error "MessageWriter worker thread crashed: #{e.message}"
|
68
|
+
SolidMCP::Logger.error e.backtrace.join("\n")
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def run_loop
|
74
|
+
loop do
|
75
|
+
break if @shutdown.true? && @queue.empty?
|
76
|
+
|
77
|
+
batch = drain_batch
|
78
|
+
if batch.any?
|
79
|
+
SolidMCP::Logger.debug "MessageWriter processing batch of #{batch.size} messages" if ENV["DEBUG_SOLID_MCP"]
|
80
|
+
write_batch(batch)
|
81
|
+
else
|
82
|
+
sleep SolidMCP.configuration.flush_interval
|
83
|
+
end
|
84
|
+
end
|
85
|
+
rescue => e
|
86
|
+
SolidMCP::Logger.error "SolidMCP::MessageWriter error: #{e.message}"
|
87
|
+
retry unless @shutdown.true?
|
88
|
+
end
|
89
|
+
|
90
|
+
def drain_batch
|
91
|
+
batch = []
|
92
|
+
batch_size = SolidMCP.configuration.batch_size
|
93
|
+
flush_markers = []
|
94
|
+
|
95
|
+
# Try to get first item (non-blocking)
|
96
|
+
begin
|
97
|
+
item = @queue.pop(true)
|
98
|
+
# Handle flush markers
|
99
|
+
if item.is_a?(Hash) && item[:flush_marker]
|
100
|
+
flush_markers << item[:flush_marker]
|
101
|
+
else
|
102
|
+
batch << item
|
103
|
+
end
|
104
|
+
rescue ThreadError
|
105
|
+
# Signal any flush markers we've collected
|
106
|
+
flush_markers.each(&:count_down)
|
107
|
+
return batch
|
108
|
+
end
|
109
|
+
|
110
|
+
# Get remaining items up to batch size
|
111
|
+
while batch.size < batch_size
|
112
|
+
begin
|
113
|
+
item = @queue.pop(true)
|
114
|
+
# Handle flush markers
|
115
|
+
if item.is_a?(Hash) && item[:flush_marker]
|
116
|
+
flush_markers << item[:flush_marker]
|
117
|
+
else
|
118
|
+
batch << item
|
119
|
+
end
|
120
|
+
rescue ThreadError
|
121
|
+
break
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Signal any flush markers we've collected
|
126
|
+
flush_markers.each(&:count_down)
|
127
|
+
batch
|
128
|
+
end
|
129
|
+
|
130
|
+
def write_batch(batch)
|
131
|
+
return if batch.empty?
|
132
|
+
|
133
|
+
# Use raw SQL for maximum performance
|
134
|
+
SolidMCP::Message.connection_pool.with_connection do |conn|
|
135
|
+
values = batch.map do |msg|
|
136
|
+
[
|
137
|
+
conn.quote(msg[:session_id]),
|
138
|
+
conn.quote(msg[:event_type]),
|
139
|
+
conn.quote(msg[:data]),
|
140
|
+
conn.quote(msg[:created_at].utc.to_fs(:db))
|
141
|
+
].join(",")
|
142
|
+
end
|
143
|
+
|
144
|
+
sql = <<-SQL
|
145
|
+
INSERT INTO solid_mcp_messages (session_id, event_type, data, created_at)
|
146
|
+
VALUES #{values.map { |v| "(#{v})" }.join(",")}
|
147
|
+
SQL
|
148
|
+
|
149
|
+
conn.execute(sql)
|
150
|
+
end
|
151
|
+
rescue => e
|
152
|
+
SolidMCP::Logger.error "SolidMCP::MessageWriter batch write error: #{e.message}"
|
153
|
+
# Could implement retry logic or dead letter queue here
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent/map"
|
4
|
+
require "concurrent/array"
|
5
|
+
|
6
|
+
module SolidMCP
|
7
|
+
class PubSub
|
8
|
+
def initialize(options = {})
|
9
|
+
@options = options
|
10
|
+
@subscriptions = Concurrent::Map.new
|
11
|
+
@listeners = Concurrent::Map.new
|
12
|
+
end
|
13
|
+
|
14
|
+
# Subscribe to messages for a specific session
|
15
|
+
def subscribe(session_id, &block)
|
16
|
+
@subscriptions[session_id] ||= Concurrent::Array.new
|
17
|
+
@subscriptions[session_id] << block
|
18
|
+
|
19
|
+
# Start a listener for this session if not already running
|
20
|
+
ensure_listener_for(session_id)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Unsubscribe from a session
|
24
|
+
def unsubscribe(session_id)
|
25
|
+
@subscriptions.delete(session_id)
|
26
|
+
stop_listener_for(session_id)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Broadcast a message to a session (uses MessageWriter for batching)
|
30
|
+
def broadcast(session_id, event_type, data)
|
31
|
+
MessageWriter.instance.enqueue(session_id, event_type, data)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Shutdown all listeners
|
35
|
+
def shutdown
|
36
|
+
@listeners.each do |_, listener|
|
37
|
+
listener.stop
|
38
|
+
end
|
39
|
+
@listeners.clear
|
40
|
+
MessageWriter.instance.shutdown
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ensure_listener_for(session_id)
|
46
|
+
return if @listeners[session_id]
|
47
|
+
|
48
|
+
listener = Subscriber.new(session_id, @subscriptions[session_id])
|
49
|
+
listener.start
|
50
|
+
@listeners[session_id] = listener
|
51
|
+
end
|
52
|
+
|
53
|
+
def stop_listener_for(session_id)
|
54
|
+
listener = @listeners.delete(session_id)
|
55
|
+
listener&.stop
|
56
|
+
end
|
57
|
+
end # class PubSub
|
58
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent/atomic/atomic_boolean"
|
4
|
+
require "concurrent/timer_task"
|
5
|
+
|
6
|
+
module SolidMCP
|
7
|
+
class Subscriber
|
8
|
+
def initialize(session_id, callbacks)
|
9
|
+
@session_id = session_id
|
10
|
+
@callbacks = callbacks
|
11
|
+
@running = Concurrent::AtomicBoolean.new(false)
|
12
|
+
@last_message_id = 0
|
13
|
+
@timer_task = nil
|
14
|
+
@max_retries = ENV["RAILS_ENV"] == "test" ? 3 : Float::INFINITY
|
15
|
+
@retry_count = 0
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
return if @running.true?
|
20
|
+
|
21
|
+
@running.make_true
|
22
|
+
@retry_count = 0
|
23
|
+
|
24
|
+
@timer_task = Concurrent::TimerTask.new(
|
25
|
+
execution_interval: SolidMCP.configuration.polling_interval,
|
26
|
+
run_now: true
|
27
|
+
) do
|
28
|
+
poll_once
|
29
|
+
end
|
30
|
+
|
31
|
+
@timer_task.execute
|
32
|
+
end
|
33
|
+
|
34
|
+
def stop
|
35
|
+
@running.make_false
|
36
|
+
@timer_task&.shutdown
|
37
|
+
@timer_task&.wait_for_termination(5)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def poll_once
|
43
|
+
return unless @running.true?
|
44
|
+
|
45
|
+
# Ensure connection in thread
|
46
|
+
SolidMCP::Message.connection_pool.with_connection do
|
47
|
+
messages = fetch_new_messages
|
48
|
+
if messages.any?
|
49
|
+
process_messages(messages)
|
50
|
+
mark_delivered(messages)
|
51
|
+
@retry_count = 0
|
52
|
+
end
|
53
|
+
end
|
54
|
+
rescue => e
|
55
|
+
@retry_count += 1
|
56
|
+
SolidMCP::Logger.error "SolidMCP::Subscriber error for session #{@session_id}: #{e.message} (retry #{@retry_count}/#{@max_retries})"
|
57
|
+
|
58
|
+
if @retry_count >= @max_retries && @max_retries != Float::INFINITY
|
59
|
+
SolidMCP::Logger.error "SolidMCP::Subscriber max retries reached for session #{@session_id}, stopping"
|
60
|
+
stop
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def fetch_new_messages
|
65
|
+
SolidMCP::Message
|
66
|
+
.for_session(@session_id)
|
67
|
+
.undelivered
|
68
|
+
.after_id(@last_message_id)
|
69
|
+
.order(:id)
|
70
|
+
.limit(100)
|
71
|
+
.to_a
|
72
|
+
end
|
73
|
+
|
74
|
+
def process_messages(messages)
|
75
|
+
messages.each do |message|
|
76
|
+
@callbacks.each do |callback|
|
77
|
+
begin
|
78
|
+
callback.call({
|
79
|
+
event_type: message.event_type,
|
80
|
+
data: message.data, # data is already a JSON string from the database
|
81
|
+
id: message.id
|
82
|
+
})
|
83
|
+
rescue => e
|
84
|
+
SolidMCP::Logger.error "SolidMCP callback error: #{e.message}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
@last_message_id = message.id
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def mark_delivered(messages)
|
92
|
+
SolidMCP::Message.mark_delivered(messages.map(&:id))
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent/map"
|
4
|
+
require "concurrent/array"
|
5
|
+
|
6
|
+
module SolidMCP
|
7
|
+
# Test implementation of PubSub for use in tests
|
8
|
+
class TestPubSub
|
9
|
+
attr_reader :subscriptions, :messages
|
10
|
+
|
11
|
+
def initialize(options = {})
|
12
|
+
@options = options
|
13
|
+
@subscriptions = Concurrent::Map.new
|
14
|
+
@messages = Concurrent::Array.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def subscribe(session_id, &block)
|
18
|
+
@subscriptions[session_id] ||= Concurrent::Array.new
|
19
|
+
@subscriptions[session_id] << block
|
20
|
+
end
|
21
|
+
|
22
|
+
def unsubscribe(session_id)
|
23
|
+
@subscriptions.delete(session_id)
|
24
|
+
end
|
25
|
+
|
26
|
+
def broadcast(session_id, event_type, data)
|
27
|
+
message = { session_id: session_id, event_type: event_type, data: data }
|
28
|
+
@messages << message
|
29
|
+
|
30
|
+
callbacks = @subscriptions[session_id] || []
|
31
|
+
callbacks.each do |callback|
|
32
|
+
callback.call({ event_type: event_type, data: data })
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def shutdown
|
37
|
+
@subscriptions.clear
|
38
|
+
@messages.clear
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/solid_mcp/version.rb
CHANGED
data/lib/solid_mcp.rb
CHANGED
@@ -1,8 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "solid_mcp/version"
|
4
|
+
require_relative "solid_mcp/configuration"
|
5
|
+
require_relative "solid_mcp/logger"
|
6
|
+
require_relative "solid_mcp/engine" if defined?(Rails)
|
4
7
|
|
5
|
-
|
8
|
+
# Always load core components
|
9
|
+
require_relative "solid_mcp/message_writer"
|
10
|
+
require_relative "solid_mcp/subscriber"
|
11
|
+
require_relative "solid_mcp/cleanup_job"
|
12
|
+
|
13
|
+
# Load test components in test environment
|
14
|
+
if ENV["RAILS_ENV"] == "test"
|
15
|
+
require_relative "solid_mcp/test_pub_sub"
|
16
|
+
end
|
17
|
+
|
18
|
+
# Always require pub_sub after environment-specific components
|
19
|
+
require_relative "solid_mcp/pub_sub"
|
20
|
+
|
21
|
+
module SolidMCP
|
6
22
|
class Error < StandardError; end
|
7
|
-
|
23
|
+
|
24
|
+
class << self
|
25
|
+
attr_accessor :configuration
|
26
|
+
|
27
|
+
def configure
|
28
|
+
self.configuration ||= Configuration.new
|
29
|
+
yield(configuration) if block_given?
|
30
|
+
end
|
31
|
+
|
32
|
+
def configured?
|
33
|
+
configuration.present?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Initialize with default configuration
|
38
|
+
self.configuration = Configuration.new
|
8
39
|
end
|
data/sig/solid_mcp.rbs
CHANGED
data/solid_mcp.gemspec
CHANGED
@@ -4,7 +4,7 @@ require_relative "lib/solid_mcp/version"
|
|
4
4
|
|
5
5
|
Gem::Specification.new do |spec|
|
6
6
|
spec.name = "solid_mcp"
|
7
|
-
spec.version =
|
7
|
+
spec.version = SolidMCP::VERSION
|
8
8
|
spec.authors = ["Abdelkader Boudih"]
|
9
9
|
spec.email = ["terminale@gmail.com"]
|
10
10
|
|
@@ -28,7 +28,12 @@ Gem::Specification.new do |spec|
|
|
28
28
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
29
|
spec.require_paths = ["lib"]
|
30
30
|
|
31
|
-
spec.add_dependency "
|
32
|
-
spec.add_dependency "
|
33
|
-
spec.add_dependency "
|
31
|
+
spec.add_dependency "activerecord", ">= 8.0"
|
32
|
+
spec.add_dependency "railties", ">= 8.0"
|
33
|
+
spec.add_dependency "activejob", ">= 8.0"
|
34
|
+
spec.add_dependency "concurrent-ruby", "~> 1.0"
|
35
|
+
|
36
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
37
|
+
spec.add_development_dependency "sqlite3", "~> 2.0"
|
38
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
34
39
|
end
|