fast_mcp_pubsub 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +22 -0
- data/lib/fast_mcp_pubsub/addressing_patch.rb +117 -0
- data/lib/fast_mcp_pubsub/current_client.rb +39 -0
- data/lib/fast_mcp_pubsub/delivery.rb +70 -0
- data/lib/fast_mcp_pubsub/message_store.rb +54 -0
- data/lib/fast_mcp_pubsub/rack_transport_patch.rb +10 -4
- data/lib/fast_mcp_pubsub/railtie.rb +39 -1
- data/lib/fast_mcp_pubsub/service.rb +119 -69
- data/lib/fast_mcp_pubsub/version.rb +1 -1
- data/lib/fast_mcp_pubsub.rb +4 -0
- metadata +7 -8
- data/.mcp.json +0 -30
- data/.rubocop.yml +0 -40
- data/.ruby-version +0 -1
- data/CLAUDE.md +0 -108
- data/lib/.DS_Store +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a955c6fc8f0b93d011d31fd196d7605b4925eb114d1355f99232ee2f8a15efb
|
|
4
|
+
data.tar.gz: 5b5ceaa86be4ecdb6cf74d2d913b0efb12b1f7a0668e0fb171a92d859cff1993
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67bc13d26facefac99906e013bf444805412422690e444037a3d11779703c879f225038e32813614d6f84f58b50f13d77ac88266d67e4729902e69880ccdbb0d
|
|
7
|
+
data.tar.gz: eaa38f519aad1c30c7aaac06c35dff2e120b27623f7e1097728a70f442de167b0f9fe0b91f1b4f36f93af51f70a8301d99c4ae96a7c2f27dc8e15ae50b7b42ae
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.0] - 2026-08-20
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **A JSON-RPC response no longer reaches every connected SSE client.** Every
|
|
12
|
+
response was published as a bare NOTIFY payload and then written into every
|
|
13
|
+
entry of every worker's `@sse_clients`, so one client's answer landed in every
|
|
14
|
+
other client's stream — across projects and across users. The only thing
|
|
15
|
+
keeping clients apart was JSON-RPC id matching on the client side, which
|
|
16
|
+
collides whenever two clients open fresh sessions at the same moment and both
|
|
17
|
+
begin counting ids from 1. Observed in production as a runner installed for
|
|
18
|
+
one project picking up a task belonging to another.
|
|
19
|
+
- `stop_listener` now clears `@listener_thread` when the thread has already died
|
|
20
|
+
instead of leaving a dead reference behind, which callers read as "a listener
|
|
21
|
+
is running".
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `FastMcpPubsub::CurrentClient` — thread-local record of which SSE client the
|
|
25
|
+
request being served belongs to.
|
|
26
|
+
- `FastMcpPubsub::AddressingPatch` — the three pieces that carry FastMcp's
|
|
27
|
+
`client_id` end to end: it is appended to the endpoint URL handed to the client
|
|
28
|
+
on connect, captured from the client's POSTs, and used to deliver a response to
|
|
29
|
+
that one client via the new `send_local_message_to`.
|
|
30
|
+
- `_pubsub_target` on the NOTIFY envelope, so every worker can tell whether a
|
|
31
|
+
message is addressed to a client it holds.
|
|
32
|
+
|
|
33
|
+
### Compatibility
|
|
34
|
+
- A message with no target still fans out, so genuine notifications and clients
|
|
35
|
+
that supply no id behave exactly as before.
|
|
36
|
+
- Bare, un-enveloped payloads from a worker still running an older gem are
|
|
37
|
+
recognised and delivered, so a rolling restart does not drop messages.
|
|
38
|
+
|
|
8
39
|
## [1.0.0] - 2025-08-19
|
|
9
40
|
|
|
10
41
|
### Added
|
data/README.md
CHANGED
|
@@ -104,6 +104,28 @@ FastMcpPubsub::Service.listener_thread&.alive?
|
|
|
104
104
|
3. **Listener threads**: Each worker has a dedicated listener thread
|
|
105
105
|
4. **Local delivery**: Messages are delivered to local SSE clients in each worker
|
|
106
106
|
|
|
107
|
+
### Addressing
|
|
108
|
+
|
|
109
|
+
A JSON-RPC **response** is delivered only to the client that asked for it; a
|
|
110
|
+
**notification** still reaches everyone. FastMcp answers over SSE and writes each
|
|
111
|
+
message to every open stream, so without addressing one client's response lands
|
|
112
|
+
in every other client's session — and the only thing separating them is
|
|
113
|
+
JSON-RPC id matching, which collides as soon as two clients open fresh sessions
|
|
114
|
+
at the same moment and both start counting ids from 1.
|
|
115
|
+
|
|
116
|
+
The gem closes that by carrying FastMcp's own `client_id` end to end:
|
|
117
|
+
|
|
118
|
+
- the endpoint URL handed to a client on connect carries its `client_id`
|
|
119
|
+
- the client's POSTs come back with it, and it is held in `CurrentClient` for the
|
|
120
|
+
length of the request
|
|
121
|
+
- `send_message` stamps it onto the NOTIFY envelope as `_pubsub_target`
|
|
122
|
+
- each worker delivers a targeted message only to that client, and ignores one
|
|
123
|
+
addressed to a client another worker holds
|
|
124
|
+
|
|
125
|
+
A message with no target — a genuine notification, or a request from a client
|
|
126
|
+
that supplied no id — falls back to the previous fan-out, so older clients keep
|
|
127
|
+
working.
|
|
128
|
+
|
|
107
129
|
## Configuration Options
|
|
108
130
|
|
|
109
131
|
| Option | Default | Description |
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module FastMcpPubsub
|
|
6
|
+
# The half of the RackTransport patch that makes a response reachable by the one
|
|
7
|
+
# client that asked for it.
|
|
8
|
+
#
|
|
9
|
+
# FastMcp answers a JSON-RPC request over SSE and writes every message to every
|
|
10
|
+
# connected stream, so before this the only thing keeping two clients apart was
|
|
11
|
+
# JSON-RPC id matching — which fails the moment two of them open fresh sessions
|
|
12
|
+
# in the same minute and both start counting ids from 1.
|
|
13
|
+
#
|
|
14
|
+
# Correlating a response with its client takes three pieces, and none of them
|
|
15
|
+
# works alone: the client id has to reach the client (the endpoint URL), come
|
|
16
|
+
# back on its POSTs (the capture), and have somewhere to be delivered to (the
|
|
17
|
+
# targeted send).
|
|
18
|
+
module AddressingPatch
|
|
19
|
+
def self.apply!
|
|
20
|
+
add_targeted_send
|
|
21
|
+
add_sse_writer
|
|
22
|
+
add_client_id_capture
|
|
23
|
+
add_endpoint_client_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Delivery to one named SSE client, the counterpart of FastMcp's own
|
|
27
|
+
# send_message. Returns false when this worker does not hold the client,
|
|
28
|
+
# which is the normal answer on every worker but one.
|
|
29
|
+
def self.add_targeted_send
|
|
30
|
+
FastMcp::Transports::RackTransport.class_eval do
|
|
31
|
+
return if method_defined?(:send_local_message_to)
|
|
32
|
+
|
|
33
|
+
define_method(:send_local_message_to) do |client_id, message|
|
|
34
|
+
client = @sse_clients[client_id]
|
|
35
|
+
return false unless client
|
|
36
|
+
|
|
37
|
+
write_sse_payload(client, message.is_a?(String) ? message : JSON.generate(message))
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
FastMcpPubsub.logger.info "RackTransport: Client #{client_id} unreachable (#{e.message}), unregistering"
|
|
40
|
+
unregister_sse_client(client_id)
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.add_sse_writer
|
|
47
|
+
FastMcp::Transports::RackTransport.class_eval do
|
|
48
|
+
return if method_defined?(:write_sse_payload)
|
|
49
|
+
|
|
50
|
+
define_method(:write_sse_payload) do |client, json_message|
|
|
51
|
+
stream = client[:stream]
|
|
52
|
+
mutex = client[:mutex]
|
|
53
|
+
return false if stream.nil? || mutex.nil? || (stream.respond_to?(:closed?) && stream.closed?)
|
|
54
|
+
|
|
55
|
+
mutex.synchronize do
|
|
56
|
+
stream.write("data: #{json_message}\n\n")
|
|
57
|
+
stream.flush if stream.respond_to?(:flush)
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
private :write_sse_payload
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Records which client the POST belongs to, so send_message can address the
|
|
66
|
+
# response FastMcp is about to write.
|
|
67
|
+
#
|
|
68
|
+
# request.GET rather than request.params on purpose: params merges the POST
|
|
69
|
+
# body, and reading the body here would leave nothing for the JSON parse that
|
|
70
|
+
# follows.
|
|
71
|
+
def self.add_client_id_capture
|
|
72
|
+
FastMcp::Transports::RackTransport.class_eval do
|
|
73
|
+
return unless private_method_defined?(:handle_message_request_with_server)
|
|
74
|
+
return if private_method_defined?(:handle_message_request_without_pubsub)
|
|
75
|
+
|
|
76
|
+
alias_method :handle_message_request_without_pubsub, :handle_message_request_with_server
|
|
77
|
+
|
|
78
|
+
define_method(:handle_message_request_with_server) do |request, server|
|
|
79
|
+
FastMcpPubsub::CurrentClient.with(request.GET["client_id"]) do
|
|
80
|
+
handle_message_request_without_pubsub(request, server)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private :handle_message_request_with_server
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Puts the client id into the endpoint URL FastMcp hands the client on
|
|
88
|
+
# connect, so the client's POSTs come back carrying it.
|
|
89
|
+
#
|
|
90
|
+
# FastMcp echoes the SSE request's own query string into that endpoint and
|
|
91
|
+
# never adds the id it just generated, so without this the capture above has
|
|
92
|
+
# nothing to read and every response falls back to the fan-out.
|
|
93
|
+
def self.add_endpoint_client_id
|
|
94
|
+
FastMcp::Transports::RackTransport.class_eval do
|
|
95
|
+
return unless private_method_defined?(:setup_sse_connection)
|
|
96
|
+
return if private_method_defined?(:setup_sse_connection_without_pubsub)
|
|
97
|
+
|
|
98
|
+
alias_method :setup_sse_connection_without_pubsub, :setup_sse_connection
|
|
99
|
+
|
|
100
|
+
define_method(:setup_sse_connection) do |client_id, io, env|
|
|
101
|
+
query = FastMcpPubsub::AddressingPatch.query_with_client_id(env["QUERY_STRING"], client_id)
|
|
102
|
+
setup_sse_connection_without_pubsub(client_id, io, env.merge("QUERY_STRING" => query))
|
|
103
|
+
end
|
|
104
|
+
private :setup_sse_connection
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Appends client_id to an SSE query string, leaving a client that named its
|
|
109
|
+
# own id alone — FastMcp honours that one, so overriding it would hand back an
|
|
110
|
+
# endpoint pointing at a different session than the stream it arrived on.
|
|
111
|
+
def self.query_with_client_id(query_string, client_id)
|
|
112
|
+
return query_string if query_string.to_s.match?(/(\A|&)client_id=/)
|
|
113
|
+
|
|
114
|
+
[query_string, "client_id=#{CGI.escape(client_id.to_s)}"].reject { |part| part.to_s.empty? }.join("&")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastMcpPubsub
|
|
4
|
+
# Thread-local record of which SSE client the request being served belongs to.
|
|
5
|
+
#
|
|
6
|
+
# FastMcp answers a JSON-RPC request over the client's SSE stream rather than in
|
|
7
|
+
# the POST body, and MCP::Server#send_response has no idea which stream that is.
|
|
8
|
+
# The transport does know: the client id arrives as a query parameter on the
|
|
9
|
+
# POST. Stashing it here for the length of the request is what lets the response
|
|
10
|
+
# be addressed instead of broadcast.
|
|
11
|
+
#
|
|
12
|
+
# A thread local is the right scope because FastMcp handles the POST
|
|
13
|
+
# synchronously — handle_request calls send_response on the very thread that
|
|
14
|
+
# entered handle_message_request_with_server, so the value set on the way in is
|
|
15
|
+
# still there on the way out.
|
|
16
|
+
module CurrentClient
|
|
17
|
+
KEY = :fast_mcp_pubsub_client_id
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The client id of the request being served, or nil outside one — which is
|
|
21
|
+
# what a genuine notification looks like, and those really are for everyone.
|
|
22
|
+
def id
|
|
23
|
+
Thread.current[KEY]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the block with client_id in scope, restoring whatever was there
|
|
27
|
+
# before. Restoring rather than clearing keeps nesting honest; Puma reuses
|
|
28
|
+
# its threads, and a stale id left behind would address the next request's
|
|
29
|
+
# response to the previous request's client.
|
|
30
|
+
def with(client_id)
|
|
31
|
+
previous = Thread.current[KEY]
|
|
32
|
+
Thread.current[KEY] = client_id
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[KEY] = previous
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastMcpPubsub
|
|
4
|
+
# What a worker does with a message that arrives on the channel.
|
|
5
|
+
#
|
|
6
|
+
# Every worker receives every NOTIFY, so this is where a message is matched to
|
|
7
|
+
# the clients it is actually for: an addressed one reaches the single client
|
|
8
|
+
# that asked, and only on the worker holding it, while an unaddressed one — a
|
|
9
|
+
# genuine notification — still fans out. See AddressingPatch for how the target
|
|
10
|
+
# gets onto the envelope in the first place.
|
|
11
|
+
module Delivery
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def handle_notification(pid, payload)
|
|
15
|
+
FastMcpPubsub.logger.debug "FastMcpPubsub: Received notification from PID #{pid}: #{payload}"
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
envelope = JSON.parse(payload)
|
|
19
|
+
message = unwrap(envelope)
|
|
20
|
+
return unless message # Reference already expired
|
|
21
|
+
|
|
22
|
+
deliver_to_transports(message, target_of(envelope))
|
|
23
|
+
rescue JSON::ParserError => e
|
|
24
|
+
FastMcpPubsub.logger.error "FastMcpPubsub: Invalid JSON payload: #{e.message}"
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
FastMcpPubsub.logger.error "FastMcpPubsub: Error handling notification: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The client this message is addressed to, or nil for a fan-out.
|
|
31
|
+
def target_of(envelope)
|
|
32
|
+
envelope["_pubsub_target"] if envelope.is_a?(Hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Unwraps the three shapes that arrive on the channel: an inline message, a
|
|
36
|
+
# database reference for one too large to fit in a NOTIFY, and a bare
|
|
37
|
+
# JSON-RPC message with no envelope at all — which is what a worker still
|
|
38
|
+
# running an older gem publishes during a rolling restart.
|
|
39
|
+
def unwrap(envelope)
|
|
40
|
+
return envelope unless envelope.is_a?(Hash)
|
|
41
|
+
return envelope["_pubsub_message"] if envelope.key?("_pubsub_message")
|
|
42
|
+
return envelope unless envelope.key?("_pubsub_ref")
|
|
43
|
+
|
|
44
|
+
stored_payload = MessageStore.fetch(envelope["_pubsub_ref"])
|
|
45
|
+
stored_payload && JSON.parse(stored_payload)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def deliver_to_transports(message, client_id = nil)
|
|
49
|
+
# Find active RackTransport instances and send to local clients
|
|
50
|
+
return unless defined?(FastMcp::Transports::RackTransport)
|
|
51
|
+
|
|
52
|
+
transports = transport_instances
|
|
53
|
+
FastMcpPubsub.logger.debug "FastMcpPubsub: Found #{transports.size} transport instances"
|
|
54
|
+
|
|
55
|
+
transports.each do |transport|
|
|
56
|
+
FastMcpPubsub.logger.debug "FastMcpPubsub: Sending message to transport #{transport.object_id}"
|
|
57
|
+
next transport.send_local_message_to(client_id, message) if client_id
|
|
58
|
+
|
|
59
|
+
transport.send_local_message(message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def transport_instances
|
|
64
|
+
# Find all RackTransport instances - don't filter by running? since it's not reliably implemented
|
|
65
|
+
ObjectSpace.each_object(FastMcp::Transports::RackTransport).to_a
|
|
66
|
+
rescue StandardError
|
|
67
|
+
[]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastMcpPubsub
|
|
4
|
+
class MessageStore
|
|
5
|
+
TABLE_NAME = "fast_mcp_pubsub_messages"
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def store(payload)
|
|
9
|
+
ensure_table_exists
|
|
10
|
+
id = SecureRandom.uuid
|
|
11
|
+
ActiveRecord::Base.connection.execute(
|
|
12
|
+
"INSERT INTO #{TABLE_NAME} (id, payload, created_at) VALUES (#{quote(id)}, #{quote(payload)}, NOW())"
|
|
13
|
+
)
|
|
14
|
+
id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(id)
|
|
18
|
+
ensure_table_exists
|
|
19
|
+
ActiveRecord::Base.connection.select_value(
|
|
20
|
+
"SELECT payload FROM #{TABLE_NAME} WHERE id = #{quote(id)}"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cleanup(older_than: Time.now - 300)
|
|
25
|
+
return unless @table_exists
|
|
26
|
+
|
|
27
|
+
ActiveRecord::Base.connection.execute(
|
|
28
|
+
"DELETE FROM #{TABLE_NAME} WHERE created_at < #{quote(older_than.iso8601)}"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_table_exists
|
|
33
|
+
return if @table_exists
|
|
34
|
+
|
|
35
|
+
unless ActiveRecord::Base.connection.table_exists?(TABLE_NAME)
|
|
36
|
+
ActiveRecord::Base.connection.execute(<<~SQL)
|
|
37
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
38
|
+
id UUID PRIMARY KEY,
|
|
39
|
+
payload TEXT NOT NULL,
|
|
40
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
41
|
+
)
|
|
42
|
+
SQL
|
|
43
|
+
end
|
|
44
|
+
@table_exists = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def quote(value)
|
|
50
|
+
ActiveRecord::Base.connection.quote(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -30,6 +30,7 @@ module FastMcpPubsub
|
|
|
30
30
|
add_basic_methods
|
|
31
31
|
add_send_message_override
|
|
32
32
|
add_fallback_method
|
|
33
|
+
AddressingPatch.apply!
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def self.add_basic_methods
|
|
@@ -46,7 +47,9 @@ module FastMcpPubsub
|
|
|
46
47
|
alias_method :send_message_original, :send_message if method_defined?(:send_message)
|
|
47
48
|
|
|
48
49
|
define_method(:send_message) do |message|
|
|
49
|
-
|
|
50
|
+
return send_local_message(message) unless FastMcpPubsub.config.enabled
|
|
51
|
+
|
|
52
|
+
broadcast_with_fallback(message, FastMcpPubsub::CurrentClient.id)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
alias_method :send_message_with_pubsub, :send_message
|
|
@@ -57,12 +60,15 @@ module FastMcpPubsub
|
|
|
57
60
|
FastMcp::Transports::RackTransport.class_eval do
|
|
58
61
|
return if method_defined?(:broadcast_with_fallback)
|
|
59
62
|
|
|
60
|
-
define_method(:broadcast_with_fallback) do |message|
|
|
63
|
+
define_method(:broadcast_with_fallback) do |message, client_id = nil|
|
|
61
64
|
FastMcpPubsub.logger.debug "RackTransport: Broadcasting message via PostgreSQL PubSub"
|
|
62
|
-
FastMcpPubsub::Service.broadcast(message)
|
|
65
|
+
FastMcpPubsub::Service.broadcast(message, client_id)
|
|
63
66
|
rescue StandardError => e
|
|
64
67
|
FastMcpPubsub.logger.error "RackTransport: Error broadcasting message: #{e.message}"
|
|
65
|
-
|
|
68
|
+
# An addressed message stays addressed even when the cluster hop fails.
|
|
69
|
+
# Falling back to the fan-out would answer every other open session with
|
|
70
|
+
# this client's data, which is the failure the addressing exists to end.
|
|
71
|
+
client_id ? send_local_message_to(client_id, message) : send_local_message(message)
|
|
66
72
|
end
|
|
67
73
|
end
|
|
68
74
|
end
|
|
@@ -27,6 +27,27 @@ module FastMcpPubsub
|
|
|
27
27
|
FastMcpPubsub::RackTransportPatch.apply_patch!
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Handle hot-reload in development mode - stop/restart listener to avoid connection pool issues
|
|
31
|
+
initializer "fast_mcp_pubsub.reloader_hooks" do |app|
|
|
32
|
+
railtie = self
|
|
33
|
+
|
|
34
|
+
# Stop listener before hot-reload to release dedicated connection
|
|
35
|
+
app.reloader.before_class_unload do
|
|
36
|
+
if FastMcpPubsub::Service.listener_thread&.alive?
|
|
37
|
+
Rails.logger.info "FastMcpPubsub: Stopping listener before code reload"
|
|
38
|
+
FastMcpPubsub::Service.stop_listener
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Restart listener after hot-reload completes
|
|
43
|
+
app.reloader.to_complete do
|
|
44
|
+
if railtie.send(:should_restart_listener?)
|
|
45
|
+
Rails.logger.info "FastMcpPubsub: Restarting listener after code reload"
|
|
46
|
+
FastMcpPubsub::Service.start_listener
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
30
51
|
# NOTE: For cluster mode, add FastMcpPubsub::Service.start_listener to your
|
|
31
52
|
# on_worker_boot hook in config/puma/production.rb
|
|
32
53
|
|
|
@@ -40,8 +61,25 @@ module FastMcpPubsub
|
|
|
40
61
|
!instance_variable_get(:@listener_started)
|
|
41
62
|
end
|
|
42
63
|
|
|
64
|
+
def should_restart_listener?
|
|
65
|
+
# For reloader - don't check @listener_started, just basic conditions
|
|
66
|
+
web_server_environment? &&
|
|
67
|
+
!cluster_mode? &&
|
|
68
|
+
FastMcpPubsub.config.enabled &&
|
|
69
|
+
FastMcpPubsub.config.auto_start &&
|
|
70
|
+
!FastMcpPubsub::Service.listener_thread&.alive?
|
|
71
|
+
end
|
|
72
|
+
|
|
43
73
|
def web_server_environment?
|
|
44
|
-
defined?(Rails::Server) ||
|
|
74
|
+
defined?(Rails::Server) || puma_cli_running? || ENV["MCP_SERVER_AUTO_START"] == "true"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def puma_cli_running?
|
|
78
|
+
# defined?(Puma) is true whenever Puma gem is loaded (even in test runner, rake tasks, etc.)
|
|
79
|
+
# Puma.cli_config is only set when Puma was actually started via CLI (rails server, puma command)
|
|
80
|
+
defined?(Puma) &&
|
|
81
|
+
Puma.respond_to?(:cli_config) &&
|
|
82
|
+
!Puma.cli_config.nil?
|
|
45
83
|
end
|
|
46
84
|
|
|
47
85
|
def cluster_mode?
|
|
@@ -6,12 +6,22 @@ module FastMcpPubsub
|
|
|
6
6
|
MAX_PAYLOAD_SIZE = 7800 # PostgreSQL NOTIFY limit is 8000 bytes, leave some margin
|
|
7
7
|
|
|
8
8
|
class << self
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
include Delivery
|
|
10
|
+
|
|
11
|
+
attr_reader :listener_thread, :dedicated_connection
|
|
12
|
+
attr_accessor :shutdown_requested
|
|
13
|
+
|
|
14
|
+
# Publishes one MCP message to the cluster.
|
|
15
|
+
#
|
|
16
|
+
# client_id names the SSE client the message answers; nil means it is for
|
|
17
|
+
# everyone, which is what a genuine notification is. A response carries an
|
|
18
|
+
# id because every worker receives every NOTIFY, and without one the worker
|
|
19
|
+
# holding an unrelated session would write this answer into it.
|
|
20
|
+
def broadcast(message, client_id = nil)
|
|
21
|
+
envelope = envelope_for(message)
|
|
22
|
+
envelope[:_pubsub_target] = client_id if client_id
|
|
23
|
+
|
|
24
|
+
send_payload(envelope.to_json)
|
|
15
25
|
rescue StandardError => e
|
|
16
26
|
FastMcpPubsub.logger.error "FastMcpPubsub: Error broadcasting message: #{e.message}"
|
|
17
27
|
raise
|
|
@@ -40,16 +50,51 @@ module FastMcpPubsub
|
|
|
40
50
|
end
|
|
41
51
|
|
|
42
52
|
def stop_listener
|
|
43
|
-
|
|
53
|
+
# A thread that has already died on its own still leaves its reference
|
|
54
|
+
# behind, and every caller reads that reference as "a listener is
|
|
55
|
+
# running". Clearing it is part of stopping, not a separate errand.
|
|
56
|
+
return @listener_thread = nil unless @listener_thread&.alive?
|
|
44
57
|
|
|
45
58
|
FastMcpPubsub.logger.info "FastMcpPubsub: Stopping listener thread for PID #{Process.pid}"
|
|
46
|
-
@
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
@shutdown_requested = true
|
|
60
|
+
|
|
61
|
+
wake_listener
|
|
62
|
+
join_listener
|
|
63
|
+
discard_dedicated_connection
|
|
64
|
+
@shutdown_requested = false
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
private
|
|
52
68
|
|
|
69
|
+
# Cancels the in-flight wait_for_notify so the thread reaches its next
|
|
70
|
+
# shutdown check instead of sitting out the rest of the timeout.
|
|
71
|
+
def wake_listener
|
|
72
|
+
@dedicated_connection&.cancel
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Five seconds to notice, then it is taken down: a thread stuck inside
|
|
78
|
+
# PG.connect during a reconnect never notices on its own.
|
|
79
|
+
def join_listener
|
|
80
|
+
@listener_thread.join(5)
|
|
81
|
+
|
|
82
|
+
if @listener_thread&.alive?
|
|
83
|
+
@listener_thread.kill
|
|
84
|
+
@listener_thread.join(1)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@listener_thread = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def discard_dedicated_connection
|
|
91
|
+
@dedicated_connection&.close
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
ensure
|
|
95
|
+
@dedicated_connection = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
53
98
|
def send_payload(payload)
|
|
54
99
|
channel = FastMcpPubsub.config.channel_name
|
|
55
100
|
FastMcpPubsub.logger.debug "FastMcpPubsub: Broadcasting message to #{channel}: #{payload.bytesize} bytes"
|
|
@@ -63,83 +108,88 @@ module FastMcpPubsub
|
|
|
63
108
|
payload.bytesize > MAX_PAYLOAD_SIZE
|
|
64
109
|
end
|
|
65
110
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
error: {
|
|
73
|
-
code: -32_001,
|
|
74
|
-
message: "Response too large for PostgreSQL NOTIFY. Try requesting smaller page size."
|
|
75
|
-
}
|
|
76
|
-
}
|
|
111
|
+
# Wraps the message for the wire: inline when it fits in a NOTIFY, a
|
|
112
|
+
# database reference when it does not. The envelope is also what gives the
|
|
113
|
+
# target somewhere to live.
|
|
114
|
+
def envelope_for(message)
|
|
115
|
+
payload = message.to_json
|
|
116
|
+
return { _pubsub_message: message } unless payload_too_large?(payload)
|
|
77
117
|
|
|
78
|
-
|
|
118
|
+
ref_id = MessageStore.store(payload)
|
|
119
|
+
FastMcpPubsub.logger.debug "FastMcpPubsub: Payload too large (#{payload.bytesize} bytes), stored as #{ref_id}"
|
|
120
|
+
{ _pubsub_ref: ref_id }
|
|
79
121
|
end
|
|
80
122
|
|
|
123
|
+
# The listener thread's whole life. `retry` re-enters the begin block
|
|
124
|
+
# without running the ensure, which is what lets a reconnect keep the
|
|
125
|
+
# LISTEN it is about to re-establish — so the split below has to keep the
|
|
126
|
+
# begin/rescue/ensure here rather than push it into a helper.
|
|
81
127
|
def listen_loop
|
|
82
128
|
channel = FastMcpPubsub.config.channel_name
|
|
83
129
|
|
|
84
130
|
begin
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
FastMcpPubsub.logger.info "FastMcpPubsub: Listening on #{channel} for PID #{Process.pid}"
|
|
89
|
-
raw_conn.async_exec("LISTEN #{channel}")
|
|
90
|
-
|
|
91
|
-
begin
|
|
92
|
-
loop do
|
|
93
|
-
raw_conn.wait_for_notify do |channel, pid, payload|
|
|
94
|
-
handle_notification(channel, pid, payload)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
ensure
|
|
98
|
-
begin
|
|
99
|
-
raw_conn.async_exec("UNLISTEN #{channel}")
|
|
100
|
-
rescue StandardError => e
|
|
101
|
-
FastMcpPubsub.logger.error "FastMcpPubsub: Error during UNLISTEN: #{e.message}"
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
131
|
+
open_listener(channel)
|
|
132
|
+
consume_notifications
|
|
105
133
|
rescue StandardError => e
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Restart after error
|
|
110
|
-
sleep 1
|
|
111
|
-
retry
|
|
134
|
+
retry if listener_should_recover?(e)
|
|
135
|
+
ensure
|
|
136
|
+
close_listener(channel)
|
|
112
137
|
end
|
|
113
138
|
end
|
|
114
139
|
|
|
115
|
-
def
|
|
116
|
-
|
|
140
|
+
def open_listener(channel)
|
|
141
|
+
@dedicated_connection = create_dedicated_connection
|
|
117
142
|
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
FastMcpPubsub.logger.info "FastMcpPubsub: Listening on #{channel} for PID #{Process.pid}"
|
|
144
|
+
@dedicated_connection.exec("LISTEN #{channel}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def consume_notifications
|
|
148
|
+
loop do
|
|
149
|
+
break if @shutdown_requested
|
|
120
150
|
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
transports = transport_instances
|
|
124
|
-
FastMcpPubsub.logger.debug "FastMcpPubsub: Found #{transports.size} transport instances"
|
|
151
|
+
@cleanup_counter = (@cleanup_counter || 0) + 1
|
|
152
|
+
MessageStore.cleanup if (@cleanup_counter % 60).zero? # Every ~60s (1s per loop)
|
|
125
153
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
transport.send_local_message(message)
|
|
129
|
-
end
|
|
154
|
+
@dedicated_connection.wait_for_notify(1) do |_channel, pid, payload|
|
|
155
|
+
handle_notification(pid, payload)
|
|
130
156
|
end
|
|
131
|
-
rescue JSON::ParserError => e
|
|
132
|
-
FastMcpPubsub.logger.error "FastMcpPubsub: Invalid JSON payload: #{e.message}"
|
|
133
|
-
rescue StandardError => e
|
|
134
|
-
FastMcpPubsub.logger.error "FastMcpPubsub: Error handling notification: #{e.message}"
|
|
135
157
|
end
|
|
136
158
|
end
|
|
137
159
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
160
|
+
# Reports whether the loop should come back up, having logged and paused if
|
|
161
|
+
# so. A shutdown in progress is not an error to recover from — it is the
|
|
162
|
+
# exit, and the error is the connection being cancelled out from under us.
|
|
163
|
+
def listener_should_recover?(error)
|
|
164
|
+
return false if @shutdown_requested
|
|
165
|
+
|
|
166
|
+
FastMcpPubsub.logger.error "FastMcpPubsub: Listener error: #{error.message}"
|
|
167
|
+
FastMcpPubsub.logger.error error.backtrace.join("\n")
|
|
168
|
+
sleep 1
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def close_listener(channel)
|
|
173
|
+
@dedicated_connection&.exec("UNLISTEN #{channel}")
|
|
174
|
+
@dedicated_connection&.close
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
FastMcpPubsub.logger.error "FastMcpPubsub: Error during cleanup: #{e.message}"
|
|
177
|
+
ensure
|
|
178
|
+
@dedicated_connection = nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def create_dedicated_connection
|
|
182
|
+
db_config = ActiveRecord::Base.connection_db_config.configuration_hash
|
|
183
|
+
|
|
184
|
+
conn_params = {
|
|
185
|
+
host: db_config[:host],
|
|
186
|
+
port: db_config[:port],
|
|
187
|
+
dbname: db_config[:database],
|
|
188
|
+
user: db_config[:username],
|
|
189
|
+
password: db_config[:password]
|
|
190
|
+
}.compact
|
|
191
|
+
|
|
192
|
+
PG.connect(conn_params)
|
|
143
193
|
end
|
|
144
194
|
end
|
|
145
195
|
end
|
data/lib/fast_mcp_pubsub.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "fast_mcp_pubsub/version"
|
|
4
4
|
require_relative "fast_mcp_pubsub/configuration"
|
|
5
|
+
require_relative "fast_mcp_pubsub/current_client"
|
|
6
|
+
require_relative "fast_mcp_pubsub/message_store"
|
|
7
|
+
require_relative "fast_mcp_pubsub/delivery"
|
|
5
8
|
require_relative "fast_mcp_pubsub/service"
|
|
6
9
|
|
|
7
10
|
# PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport.
|
|
@@ -30,5 +33,6 @@ module FastMcpPubsub
|
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Load patch after module is fully defined
|
|
36
|
+
require_relative "fast_mcp_pubsub/addressing_patch"
|
|
33
37
|
require_relative "fast_mcp_pubsub/rack_transport_patch"
|
|
34
38
|
require_relative "fast_mcp_pubsub/railtie" if defined?(Rails)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fast_mcp_pubsub
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- josefchmel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: concurrent-ruby
|
|
@@ -115,17 +115,16 @@ executables: []
|
|
|
115
115
|
extensions: []
|
|
116
116
|
extra_rdoc_files: []
|
|
117
117
|
files:
|
|
118
|
-
- ".mcp.json"
|
|
119
|
-
- ".rubocop.yml"
|
|
120
|
-
- ".ruby-version"
|
|
121
118
|
- CHANGELOG.md
|
|
122
|
-
- CLAUDE.md
|
|
123
119
|
- LICENSE.txt
|
|
124
120
|
- README.md
|
|
125
121
|
- Rakefile
|
|
126
|
-
- lib/.DS_Store
|
|
127
122
|
- lib/fast_mcp_pubsub.rb
|
|
123
|
+
- lib/fast_mcp_pubsub/addressing_patch.rb
|
|
128
124
|
- lib/fast_mcp_pubsub/configuration.rb
|
|
125
|
+
- lib/fast_mcp_pubsub/current_client.rb
|
|
126
|
+
- lib/fast_mcp_pubsub/delivery.rb
|
|
127
|
+
- lib/fast_mcp_pubsub/message_store.rb
|
|
129
128
|
- lib/fast_mcp_pubsub/rack_transport_patch.rb
|
|
130
129
|
- lib/fast_mcp_pubsub/railtie.rb
|
|
131
130
|
- lib/fast_mcp_pubsub/service.rb
|
|
@@ -154,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
154
153
|
- !ruby/object:Gem::Version
|
|
155
154
|
version: '0'
|
|
156
155
|
requirements: []
|
|
157
|
-
rubygems_version:
|
|
156
|
+
rubygems_version: 4.0.6
|
|
158
157
|
specification_version: 4
|
|
159
158
|
summary: PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport
|
|
160
159
|
test_files: []
|
data/.mcp.json
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"mcpServers": {
|
|
3
|
-
"workvector-production": {
|
|
4
|
-
"type": "sse",
|
|
5
|
-
"name": "WorkVector Production",
|
|
6
|
-
"url": "https://workvector.com/mcp/sse",
|
|
7
|
-
"headers": {
|
|
8
|
-
"Authorization": "Bearer ${WORKVECTOR_TOKEN}"
|
|
9
|
-
}
|
|
10
|
-
},
|
|
11
|
-
"filesystem-project": {
|
|
12
|
-
"type": "stdio",
|
|
13
|
-
"name": "Filesystem",
|
|
14
|
-
"command": "npx",
|
|
15
|
-
"args": [
|
|
16
|
-
"-y",
|
|
17
|
-
"@modelcontextprotocol/server-filesystem",
|
|
18
|
-
"${PWD}"
|
|
19
|
-
]
|
|
20
|
-
},
|
|
21
|
-
"llmmn-production": {
|
|
22
|
-
"type": "sse",
|
|
23
|
-
"name": "LLM Memory Notes Production",
|
|
24
|
-
"url": "https://llm-memory.com/mcp/sse",
|
|
25
|
-
"headers": {
|
|
26
|
-
"Authorization": "Bearer ${LLMMN_TOKEN}"
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
data/.rubocop.yml
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
AllCops:
|
|
2
|
-
TargetRubyVersion: 3.1
|
|
3
|
-
NewCops: enable
|
|
4
|
-
SuggestExtensions: false
|
|
5
|
-
|
|
6
|
-
plugins:
|
|
7
|
-
- rubocop-minitest
|
|
8
|
-
|
|
9
|
-
Layout/LineLength:
|
|
10
|
-
Max: 200
|
|
11
|
-
|
|
12
|
-
Style/StringLiterals:
|
|
13
|
-
EnforcedStyle: double_quotes
|
|
14
|
-
|
|
15
|
-
Style/StringLiteralsInInterpolation:
|
|
16
|
-
EnforcedStyle: double_quotes
|
|
17
|
-
|
|
18
|
-
# Relax some metrics for reasonable code
|
|
19
|
-
Metrics/ClassLength:
|
|
20
|
-
Max: 150
|
|
21
|
-
|
|
22
|
-
Metrics/MethodLength:
|
|
23
|
-
Max: 30
|
|
24
|
-
|
|
25
|
-
Metrics/AbcSize:
|
|
26
|
-
Max: 35
|
|
27
|
-
|
|
28
|
-
# Allow development dependencies in gemspec for gems
|
|
29
|
-
Gemspec/DevelopmentDependencies:
|
|
30
|
-
Enabled: false
|
|
31
|
-
|
|
32
|
-
Style/Documentation:
|
|
33
|
-
Enabled: false
|
|
34
|
-
|
|
35
|
-
# Allow longer blocks for configuration and setup
|
|
36
|
-
Metrics/BlockLength:
|
|
37
|
-
Exclude:
|
|
38
|
-
- "test/**/*"
|
|
39
|
-
- "*.gemspec"
|
|
40
|
-
- "lib/fast_mcp_pubsub/railtie.rb" # Debug logging temporarily increases block length
|
data/.ruby-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
ruby-3.4.2
|
data/CLAUDE.md
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code when working with the `fast_mcp_pubsub` gem.
|
|
4
|
-
|
|
5
|
-
## Gem Overview
|
|
6
|
-
|
|
7
|
-
FastMcp PubSub provides PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport, enabling message broadcasting across multiple Puma workers in cluster mode.
|
|
8
|
-
|
|
9
|
-
## Code Conventions
|
|
10
|
-
|
|
11
|
-
### Code Quality
|
|
12
|
-
- Max 200 chars/line (soft limit - prefer readability over strict compliance)
|
|
13
|
-
- breaking Ruby chain calls destroys the natural sentence flow and readability
|
|
14
|
-
- 14 lines/method, 110 lines/class
|
|
15
|
-
- Comments and tests in English
|
|
16
|
-
- KEEP CODE DRY (Don't Repeat Yourself)
|
|
17
|
-
|
|
18
|
-
### Error Handling
|
|
19
|
-
- Use meaningful exception classes (not generic StandardError)
|
|
20
|
-
- Log errors with context using the configured logger
|
|
21
|
-
- Proper error propagation with fallback mechanisms
|
|
22
|
-
- Use `rescue_from` for common exceptions in Rails integration
|
|
23
|
-
|
|
24
|
-
### Performance Considerations
|
|
25
|
-
- Use database connection pooling efficiently
|
|
26
|
-
- Avoid blocking operations in main threads
|
|
27
|
-
- Cache expensive operations
|
|
28
|
-
- Monitor thread lifecycle and cleanup
|
|
29
|
-
|
|
30
|
-
### Thread Safety
|
|
31
|
-
- All operations must be thread-safe for cluster mode
|
|
32
|
-
- Use proper synchronization when accessing shared resources
|
|
33
|
-
- Handle thread lifecycle correctly (creation, monitoring, cleanup)
|
|
34
|
-
- Use connection checkout/checkin pattern for database operations
|
|
35
|
-
|
|
36
|
-
### Gem Specific Guidelines
|
|
37
|
-
|
|
38
|
-
#### Configuration
|
|
39
|
-
- Use configuration object pattern for all settings
|
|
40
|
-
- Provide sensible defaults that work out of the box
|
|
41
|
-
- Make all components configurable but not required
|
|
42
|
-
- Support both programmatic and initializer-based configuration
|
|
43
|
-
|
|
44
|
-
#### Rails Integration
|
|
45
|
-
- Use Railtie for automatic Rails integration
|
|
46
|
-
- Hook into appropriate Rails lifecycle events
|
|
47
|
-
- Respect Rails conventions for logging and error handling
|
|
48
|
-
- Provide manual configuration options for non-Rails usage
|
|
49
|
-
|
|
50
|
-
#### Error Recovery
|
|
51
|
-
- Implement automatic retry with backoff for transient errors
|
|
52
|
-
- Provide fallback mechanisms when PubSub fails
|
|
53
|
-
- Log errors appropriately without flooding logs
|
|
54
|
-
- Handle connection failures gracefully
|
|
55
|
-
|
|
56
|
-
#### Testing
|
|
57
|
-
- Test all public interfaces
|
|
58
|
-
- Mock external dependencies (PostgreSQL, FastMcp)
|
|
59
|
-
- Test error conditions and edge cases
|
|
60
|
-
- Provide test helpers for gem users
|
|
61
|
-
- Test both Rails and non-Rails usage
|
|
62
|
-
|
|
63
|
-
## Architecture
|
|
64
|
-
|
|
65
|
-
### Components
|
|
66
|
-
|
|
67
|
-
1. **FastMcpPubsub::Service** - Core PostgreSQL NOTIFY/LISTEN service
|
|
68
|
-
2. **FastMcpPubsub::Configuration** - Configuration management
|
|
69
|
-
3. **FastMcpPubsub::RackTransportPatch** - Monkey patch for FastMcp transport
|
|
70
|
-
4. **FastMcpPubsub::Railtie** - Rails integration and lifecycle management
|
|
71
|
-
|
|
72
|
-
### Message Flow
|
|
73
|
-
|
|
74
|
-
1. `RackTransport#send_message` → `FastMcpPubsub::Service.broadcast`
|
|
75
|
-
2. `Service.broadcast` → PostgreSQL NOTIFY
|
|
76
|
-
3. Each worker's listener thread receives NOTIFY
|
|
77
|
-
4. Listener calls `RackTransport#send_local_message` for local clients
|
|
78
|
-
|
|
79
|
-
### Thread Management
|
|
80
|
-
|
|
81
|
-
- One listener thread per worker process
|
|
82
|
-
- Thread cleanup on process exit
|
|
83
|
-
- Automatic restart on listener errors
|
|
84
|
-
- Connection pooling for database operations
|
|
85
|
-
|
|
86
|
-
## Dependencies
|
|
87
|
-
|
|
88
|
-
- **Rails** (>= 7.0) - Core framework integration
|
|
89
|
-
- **PostgreSQL** (via pg gem >= 1.0) - Database NOTIFY/LISTEN
|
|
90
|
-
- **ActiveRecord** - Connection pooling and database access
|
|
91
|
-
- **FastMcp** - The transport being patched (development/test dependency)
|
|
92
|
-
|
|
93
|
-
## Development
|
|
94
|
-
|
|
95
|
-
### Running Tests
|
|
96
|
-
```bash
|
|
97
|
-
bundle exec rake test
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
### Linting
|
|
101
|
-
```bash
|
|
102
|
-
bundle exec rubocop
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### Console
|
|
106
|
-
```bash
|
|
107
|
-
bundle exec rake console
|
|
108
|
-
```
|
data/lib/.DS_Store
DELETED
|
Binary file
|