solid_mcp 0.2.2 → 0.5.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/ext/solid_mcp_native/Cargo.toml +12 -0
  3. data/ext/solid_mcp_native/core/Cargo.toml +32 -0
  4. data/ext/solid_mcp_native/core/src/config.rs +133 -0
  5. data/ext/solid_mcp_native/core/src/db/mod.rs +154 -0
  6. data/ext/solid_mcp_native/core/src/db/postgres.rs +242 -0
  7. data/ext/solid_mcp_native/core/src/db/sqlite.rs +276 -0
  8. data/ext/solid_mcp_native/core/src/error.rs +38 -0
  9. data/ext/solid_mcp_native/core/src/lib.rs +25 -0
  10. data/ext/solid_mcp_native/core/src/message.rs +191 -0
  11. data/ext/solid_mcp_native/core/src/pubsub.rs +309 -0
  12. data/ext/solid_mcp_native/core/src/subscriber.rs +298 -0
  13. data/ext/solid_mcp_native/core/src/writer.rs +252 -0
  14. data/ext/solid_mcp_native/extconf.rb +3 -0
  15. data/ext/solid_mcp_native/ffi/Cargo.toml +20 -0
  16. data/ext/solid_mcp_native/ffi/extconf.rb +67 -0
  17. data/ext/solid_mcp_native/ffi/src/lib.rs +224 -0
  18. data/lib/solid_mcp/configuration.rb +5 -2
  19. data/lib/solid_mcp/message_writer.rb +80 -45
  20. data/lib/solid_mcp/native_speedup.rb +140 -0
  21. data/lib/solid_mcp/pub_sub.rb +10 -8
  22. data/lib/solid_mcp/subscriber.rb +18 -7
  23. data/lib/solid_mcp/version.rb +1 -1
  24. data/lib/solid_mcp.rb +3 -0
  25. metadata +58 -20
  26. data/.release-please-manifest.json +0 -1
  27. data/CHANGELOG.md +0 -27
  28. data/Gemfile +0 -11
  29. data/Gemfile.lock +0 -140
  30. data/Rakefile +0 -8
  31. data/app/models/solid_mcp/message.rb +0 -25
  32. data/app/models/solid_mcp/record.rb +0 -10
  33. data/bin/console +0 -11
  34. data/bin/rails +0 -15
  35. data/bin/setup +0 -8
  36. data/bin/test +0 -8
  37. data/db/migrate/20250624000001_create_solid_mcp_messages.rb +0 -28
  38. data/release-please-config.json +0 -8
  39. data/solid_mcp.gemspec +0 -39
@@ -0,0 +1,309 @@
1
+ //! High-level pub/sub API
2
+ //!
3
+ //! This is the main interface for solid-mcp-core, providing:
4
+ //! - Session-based subscriptions
5
+ //! - Non-blocking message broadcasting
6
+ //! - Graceful shutdown
7
+
8
+ use crate::db::{Database, DbPool};
9
+ use crate::subscriber::{MessageCallback, Subscriber};
10
+ use crate::writer::MessageWriter;
11
+ use crate::{Config, Error, Message, Result};
12
+ use std::collections::HashMap;
13
+ use std::sync::Arc;
14
+ use tokio::sync::RwLock;
15
+ use tracing::{debug, info};
16
+
17
+ /// The main pub/sub engine
18
+ pub struct PubSub {
19
+ db: Arc<DbPool>,
20
+ config: Config,
21
+ writer: Arc<MessageWriter>,
22
+ subscribers: RwLock<HashMap<String, Subscriber>>,
23
+ }
24
+
25
+ impl PubSub {
26
+ /// Create a new pub/sub engine
27
+ pub async fn new(config: Config) -> Result<Self> {
28
+ let db = Arc::new(DbPool::new(&config).await?);
29
+ let writer = Arc::new(MessageWriter::new(db.clone(), &config).await?);
30
+
31
+ info!("PubSub engine initialized");
32
+
33
+ Ok(Self {
34
+ db,
35
+ config,
36
+ writer,
37
+ subscribers: RwLock::new(HashMap::new()),
38
+ })
39
+ }
40
+
41
+ /// Create a new pub/sub engine with an existing database pool
42
+ pub async fn with_db(db: Arc<DbPool>, config: Config) -> Result<Self> {
43
+ let writer = Arc::new(MessageWriter::new(db.clone(), &config).await?);
44
+
45
+ Ok(Self {
46
+ db,
47
+ config,
48
+ writer,
49
+ subscribers: RwLock::new(HashMap::new()),
50
+ })
51
+ }
52
+
53
+ /// Broadcast a message to a session (non-blocking)
54
+ ///
55
+ /// Returns `true` if the message was enqueued, `false` if the queue was full.
56
+ pub fn broadcast(
57
+ &self,
58
+ session_id: impl Into<String>,
59
+ event_type: impl Into<String>,
60
+ data: impl Into<String>,
61
+ ) -> Result<bool> {
62
+ let message = Message::new(session_id, event_type, data);
63
+ self.writer.enqueue(message)
64
+ }
65
+
66
+ /// Broadcast a message to a session (async, waits if queue is full)
67
+ pub async fn broadcast_async(
68
+ &self,
69
+ session_id: impl Into<String>,
70
+ event_type: impl Into<String>,
71
+ data: impl Into<String>,
72
+ ) -> Result<()> {
73
+ let message = Message::new(session_id, event_type, data);
74
+ self.writer.enqueue_async(message).await
75
+ }
76
+
77
+ /// Subscribe to messages for a session
78
+ ///
79
+ /// The callback will be invoked for each new message.
80
+ /// Returns an error if already subscribed to this session.
81
+ pub async fn subscribe(
82
+ &self,
83
+ session_id: impl Into<String>,
84
+ callback: MessageCallback,
85
+ ) -> Result<()> {
86
+ let session_id = session_id.into();
87
+
88
+ let mut subscribers = self.subscribers.write().await;
89
+
90
+ if subscribers.contains_key(&session_id) {
91
+ return Err(Error::Config(format!(
92
+ "Already subscribed to session {}",
93
+ session_id
94
+ )));
95
+ }
96
+
97
+ let subscriber =
98
+ Subscriber::new(&session_id, self.db.clone(), &self.config, callback).await?;
99
+ subscribers.insert(session_id, subscriber);
100
+
101
+ Ok(())
102
+ }
103
+
104
+ /// Unsubscribe from a session
105
+ pub async fn unsubscribe(&self, session_id: &str) -> Result<()> {
106
+ let mut subscribers = self.subscribers.write().await;
107
+
108
+ if let Some(subscriber) = subscribers.remove(session_id) {
109
+ subscriber.stop().await?;
110
+ }
111
+
112
+ Ok(())
113
+ }
114
+
115
+ /// Check if subscribed to a session
116
+ pub async fn is_subscribed(&self, session_id: &str) -> bool {
117
+ let subscribers = self.subscribers.read().await;
118
+ subscribers.contains_key(session_id)
119
+ }
120
+
121
+ /// Get the number of active subscriptions
122
+ pub async fn subscription_count(&self) -> usize {
123
+ let subscribers = self.subscribers.read().await;
124
+ subscribers.len()
125
+ }
126
+
127
+ /// Flush all pending messages to the database
128
+ pub async fn flush(&self) -> Result<()> {
129
+ self.writer.flush().await
130
+ }
131
+
132
+ /// Mark messages as delivered
133
+ pub async fn mark_delivered(&self, ids: &[i64]) -> Result<()> {
134
+ self.db.mark_delivered(ids).await
135
+ }
136
+
137
+ /// Cleanup old messages
138
+ pub async fn cleanup(&self) -> Result<(u64, u64)> {
139
+ let delivered = self
140
+ .db
141
+ .cleanup_delivered(self.config.delivered_retention)
142
+ .await?;
143
+ let undelivered = self
144
+ .db
145
+ .cleanup_undelivered(self.config.undelivered_retention)
146
+ .await?;
147
+ debug!(
148
+ "Cleanup complete: {} delivered, {} undelivered messages removed",
149
+ delivered, undelivered
150
+ );
151
+ Ok((delivered, undelivered))
152
+ }
153
+
154
+ /// Shutdown the pub/sub engine gracefully
155
+ pub async fn shutdown(self) -> Result<()> {
156
+ info!("PubSub engine shutting down...");
157
+
158
+ // Stop all subscribers
159
+ let mut subscribers = self.subscribers.write().await;
160
+ for (session_id, subscriber) in subscribers.drain() {
161
+ debug!("Stopping subscriber for session {}", session_id);
162
+ if let Err(e) = subscriber.stop().await {
163
+ tracing::error!("Error stopping subscriber for {}: {}", session_id, e);
164
+ }
165
+ }
166
+ drop(subscribers);
167
+
168
+ // Shutdown writer (flushes remaining messages)
169
+ // Need to unwrap the Arc - this only works if we're the last holder
170
+ match Arc::try_unwrap(self.writer) {
171
+ Ok(writer) => writer.shutdown().await?,
172
+ Err(_) => {
173
+ tracing::warn!("Could not unwrap writer Arc, forcing flush");
174
+ // Best effort - can't fully shutdown
175
+ }
176
+ }
177
+
178
+ info!("PubSub engine shutdown complete");
179
+ Ok(())
180
+ }
181
+ }
182
+
183
+ #[cfg(test)]
184
+ mod tests {
185
+ use super::*;
186
+ use crate::db::sqlite::SqlitePool;
187
+ use std::sync::atomic::{AtomicUsize, Ordering};
188
+ use std::time::Duration;
189
+
190
+ async fn create_test_pubsub(config: Config) -> PubSub {
191
+ let sqlite = SqlitePool::new("sqlite::memory:").await.unwrap();
192
+ sqlite.setup_test_schema().await.unwrap();
193
+ let db = Arc::new(DbPool::Sqlite(sqlite));
194
+ PubSub::with_db(db, config).await.unwrap()
195
+ }
196
+
197
+ #[tokio::test]
198
+ async fn test_pubsub_basic() {
199
+ let config = Config::new("sqlite::memory:")
200
+ .batch_size(10)
201
+ .polling_interval(Duration::from_millis(10));
202
+
203
+ let pubsub = create_test_pubsub(config).await;
204
+
205
+ let received = Arc::new(AtomicUsize::new(0));
206
+ let received_clone = received.clone();
207
+
208
+ pubsub
209
+ .subscribe(
210
+ "session-1",
211
+ Box::new(move |_| {
212
+ received_clone.fetch_add(1, Ordering::SeqCst);
213
+ }),
214
+ )
215
+ .await
216
+ .unwrap();
217
+
218
+ // Broadcast messages
219
+ for i in 0..5 {
220
+ pubsub
221
+ .broadcast("session-1", "message", format!(r#"{{"i":{}}}"#, i))
222
+ .unwrap();
223
+ }
224
+
225
+ // Flush and wait for delivery
226
+ pubsub.flush().await.unwrap();
227
+ tokio::time::sleep(Duration::from_millis(100)).await;
228
+
229
+ assert_eq!(received.load(Ordering::SeqCst), 5);
230
+
231
+ pubsub.shutdown().await.unwrap();
232
+ }
233
+
234
+ #[tokio::test]
235
+ async fn test_pubsub_multiple_sessions() {
236
+ let config = Config::new("sqlite::memory:").polling_interval(Duration::from_millis(10));
237
+
238
+ let pubsub = create_test_pubsub(config).await;
239
+
240
+ let received1 = Arc::new(AtomicUsize::new(0));
241
+ let received2 = Arc::new(AtomicUsize::new(0));
242
+
243
+ let r1 = received1.clone();
244
+ let r2 = received2.clone();
245
+
246
+ pubsub
247
+ .subscribe(
248
+ "session-1",
249
+ Box::new(move |_| {
250
+ r1.fetch_add(1, Ordering::SeqCst);
251
+ }),
252
+ )
253
+ .await
254
+ .unwrap();
255
+
256
+ pubsub
257
+ .subscribe(
258
+ "session-2",
259
+ Box::new(move |_| {
260
+ r2.fetch_add(1, Ordering::SeqCst);
261
+ }),
262
+ )
263
+ .await
264
+ .unwrap();
265
+
266
+ // Broadcast to different sessions
267
+ pubsub.broadcast("session-1", "msg", "{}").unwrap();
268
+ pubsub.broadcast("session-1", "msg", "{}").unwrap();
269
+ pubsub.broadcast("session-2", "msg", "{}").unwrap();
270
+
271
+ pubsub.flush().await.unwrap();
272
+ tokio::time::sleep(Duration::from_millis(100)).await;
273
+
274
+ assert_eq!(received1.load(Ordering::SeqCst), 2);
275
+ assert_eq!(received2.load(Ordering::SeqCst), 1);
276
+
277
+ pubsub.shutdown().await.unwrap();
278
+ }
279
+
280
+ #[tokio::test]
281
+ async fn test_pubsub_unsubscribe() {
282
+ let config = Config::new("sqlite::memory:").polling_interval(Duration::from_millis(10));
283
+
284
+ let pubsub = create_test_pubsub(config).await;
285
+
286
+ let received = Arc::new(AtomicUsize::new(0));
287
+ let r = received.clone();
288
+
289
+ pubsub
290
+ .subscribe(
291
+ "session-1",
292
+ Box::new(move |_| {
293
+ r.fetch_add(1, Ordering::SeqCst);
294
+ }),
295
+ )
296
+ .await
297
+ .unwrap();
298
+
299
+ assert!(pubsub.is_subscribed("session-1").await);
300
+ assert_eq!(pubsub.subscription_count().await, 1);
301
+
302
+ pubsub.unsubscribe("session-1").await.unwrap();
303
+
304
+ assert!(!pubsub.is_subscribed("session-1").await);
305
+ assert_eq!(pubsub.subscription_count().await, 0);
306
+
307
+ pubsub.shutdown().await.unwrap();
308
+ }
309
+ }
@@ -0,0 +1,298 @@
1
+ //! Async subscriber for session-based message delivery
2
+ //!
3
+ //! Supports two modes:
4
+ //! - PostgreSQL: Uses LISTEN/NOTIFY for real-time delivery (no polling)
5
+ //! - SQLite: Falls back to efficient async polling
6
+
7
+ #[cfg(feature = "postgres")]
8
+ use crate::db::postgres::PostgresPool;
9
+ use crate::db::{Database, DbPool};
10
+ use crate::{Config, Message, Result};
11
+ use std::sync::Arc;
12
+ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
13
+ use std::time::Duration;
14
+ use tokio::task::JoinHandle;
15
+ use tracing::{debug, error, info, warn};
16
+
17
+ /// Callback type for message delivery
18
+ pub type MessageCallback = Box<dyn Fn(Message) + Send + Sync + 'static>;
19
+
20
+ /// A subscriber for a specific session
21
+ pub struct Subscriber {
22
+ session_id: String,
23
+ handle: JoinHandle<()>,
24
+ shutdown: Arc<AtomicBool>,
25
+ }
26
+
27
+ impl Subscriber {
28
+ /// Create a new subscriber for a session
29
+ ///
30
+ /// The callback will be invoked for each new message.
31
+ pub async fn new(
32
+ session_id: impl Into<String>,
33
+ db: Arc<DbPool>,
34
+ config: &Config,
35
+ callback: MessageCallback,
36
+ ) -> Result<Self> {
37
+ let session_id = session_id.into();
38
+ let shutdown = Arc::new(AtomicBool::new(false));
39
+ let shutdown_clone = shutdown.clone();
40
+ let session_clone = session_id.clone();
41
+ let polling_interval = config.polling_interval;
42
+
43
+ // Get initial last_id
44
+ let last_id = Arc::new(AtomicI64::new(db.max_id().await?));
45
+
46
+ let handle = match &*db {
47
+ #[cfg(feature = "postgres")]
48
+ DbPool::Postgres(pg) => {
49
+ // Use LISTEN/NOTIFY for PostgreSQL
50
+ let pg_clone = pg.clone();
51
+ let db_clone = db.clone();
52
+ tokio::spawn(async move {
53
+ postgres_subscriber_loop(
54
+ session_clone,
55
+ pg_clone,
56
+ db_clone,
57
+ last_id,
58
+ shutdown_clone,
59
+ callback,
60
+ )
61
+ .await
62
+ })
63
+ }
64
+ #[cfg(feature = "sqlite")]
65
+ DbPool::Sqlite(_) => {
66
+ // Use polling for SQLite
67
+ tokio::spawn(async move {
68
+ polling_subscriber_loop(
69
+ session_clone,
70
+ db,
71
+ last_id,
72
+ polling_interval,
73
+ shutdown_clone,
74
+ callback,
75
+ )
76
+ .await
77
+ })
78
+ }
79
+ };
80
+
81
+ info!("Subscriber started for session {}", session_id);
82
+
83
+ Ok(Self {
84
+ session_id,
85
+ handle,
86
+ shutdown,
87
+ })
88
+ }
89
+
90
+ /// Stop the subscriber
91
+ pub async fn stop(self) -> Result<()> {
92
+ info!("Stopping subscriber for session {}", self.session_id);
93
+ self.shutdown.store(true, Ordering::SeqCst);
94
+
95
+ // Wait for the task to complete (with timeout)
96
+ tokio::select! {
97
+ _ = self.handle => {
98
+ debug!("Subscriber task completed");
99
+ }
100
+ _ = tokio::time::sleep(Duration::from_secs(5)) => {
101
+ warn!("Subscriber task did not complete in time, aborting");
102
+ }
103
+ }
104
+
105
+ Ok(())
106
+ }
107
+
108
+ /// Get the session ID
109
+ pub fn session_id(&self) -> &str {
110
+ &self.session_id
111
+ }
112
+ }
113
+
114
+ /// Polling-based subscriber loop (for SQLite)
115
+ async fn polling_subscriber_loop(
116
+ session_id: String,
117
+ db: Arc<DbPool>,
118
+ last_id: Arc<AtomicI64>,
119
+ polling_interval: Duration,
120
+ shutdown: Arc<AtomicBool>,
121
+ callback: MessageCallback,
122
+ ) {
123
+ debug!(
124
+ "Starting polling subscriber for session {} (interval: {:?})",
125
+ session_id, polling_interval
126
+ );
127
+
128
+ while !shutdown.load(Ordering::SeqCst) {
129
+ // Fetch new messages
130
+ let current_last_id = last_id.load(Ordering::SeqCst);
131
+ match db.fetch_after(&session_id, current_last_id, 100).await {
132
+ Ok(messages) => {
133
+ for msg in messages {
134
+ let msg_id = msg.id;
135
+
136
+ // Deliver to callback
137
+ callback(msg);
138
+
139
+ // Update last_id
140
+ last_id.store(msg_id, Ordering::SeqCst);
141
+ }
142
+ }
143
+ Err(e) => {
144
+ error!("Error fetching messages for session {}: {}", session_id, e);
145
+ }
146
+ }
147
+
148
+ // Sleep until next poll (interruptible)
149
+ tokio::select! {
150
+ _ = tokio::time::sleep(polling_interval) => {}
151
+ _ = async {
152
+ while !shutdown.load(Ordering::SeqCst) {
153
+ tokio::time::sleep(Duration::from_millis(10)).await;
154
+ }
155
+ } => {
156
+ break;
157
+ }
158
+ }
159
+ }
160
+
161
+ debug!("Polling subscriber for session {} stopped", session_id);
162
+ }
163
+
164
+ /// LISTEN/NOTIFY-based subscriber loop (for PostgreSQL)
165
+ #[cfg(feature = "postgres")]
166
+ async fn postgres_subscriber_loop(
167
+ session_id: String,
168
+ pg: PostgresPool,
169
+ db: Arc<DbPool>,
170
+ last_id: Arc<AtomicI64>,
171
+ shutdown: Arc<AtomicBool>,
172
+ callback: MessageCallback,
173
+ ) {
174
+ debug!(
175
+ "Starting LISTEN/NOTIFY subscriber for session {}",
176
+ session_id
177
+ );
178
+
179
+ // First, catch up on any missed messages
180
+ let current_last_id = last_id.load(Ordering::SeqCst);
181
+ match db.fetch_after(&session_id, current_last_id, 1000).await {
182
+ Ok(messages) => {
183
+ for msg in messages {
184
+ let msg_id = msg.id;
185
+ callback(msg);
186
+ last_id.store(msg_id, Ordering::SeqCst);
187
+ }
188
+ }
189
+ Err(e) => {
190
+ error!(
191
+ "Error catching up messages for session {}: {}",
192
+ session_id, e
193
+ );
194
+ }
195
+ }
196
+
197
+ // Set up LISTEN
198
+ let mut listener = match pg.listen(&session_id).await {
199
+ Ok(l) => l,
200
+ Err(e) => {
201
+ error!(
202
+ "Failed to create listener for session {}: {}",
203
+ session_id, e
204
+ );
205
+ return;
206
+ }
207
+ };
208
+
209
+ // Listen for notifications
210
+ while !shutdown.load(Ordering::SeqCst) {
211
+ tokio::select! {
212
+ notification = listener.recv() => {
213
+ match notification {
214
+ Ok(notif) => {
215
+ // Notification payload is the message ID
216
+ if let Ok(msg_id) = notif.payload().parse::<i64>() {
217
+ // Fetch the specific message
218
+ let current = last_id.load(Ordering::SeqCst);
219
+ if msg_id > current {
220
+ match db.fetch_after(&session_id, current, 100).await {
221
+ Ok(messages) => {
222
+ for msg in messages {
223
+ let id = msg.id;
224
+ callback(msg);
225
+ last_id.store(id, Ordering::SeqCst);
226
+ }
227
+ }
228
+ Err(e) => {
229
+ error!("Error fetching message {}: {}", msg_id, e);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }
235
+ Err(e) => {
236
+ error!("Listener error for session {}: {}", session_id, e);
237
+ // Reconnect logic could go here
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ _ = tokio::time::sleep(Duration::from_secs(1)) => {
243
+ // Periodic check for shutdown
244
+ if shutdown.load(Ordering::SeqCst) {
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ debug!(
252
+ "LISTEN/NOTIFY subscriber for session {} stopped",
253
+ session_id
254
+ );
255
+ }
256
+
257
+ #[cfg(test)]
258
+ mod tests {
259
+ use super::*;
260
+ use crate::db::sqlite::SqlitePool;
261
+ use std::sync::atomic::AtomicUsize;
262
+
263
+ async fn create_test_db() -> Arc<DbPool> {
264
+ let sqlite = SqlitePool::new("sqlite::memory:").await.unwrap();
265
+ sqlite.setup_test_schema().await.unwrap();
266
+ Arc::new(DbPool::Sqlite(sqlite))
267
+ }
268
+
269
+ #[tokio::test]
270
+ async fn test_polling_subscriber() {
271
+ let db = create_test_db().await;
272
+ let config = Config::new("sqlite::memory:").polling_interval(Duration::from_millis(10));
273
+
274
+ let received = Arc::new(AtomicUsize::new(0));
275
+ let received_clone = received.clone();
276
+
277
+ let callback: MessageCallback = Box::new(move |_msg| {
278
+ received_clone.fetch_add(1, Ordering::SeqCst);
279
+ });
280
+
281
+ let subscriber = Subscriber::new("session-1", db.clone(), &config, callback)
282
+ .await
283
+ .unwrap();
284
+
285
+ // Insert some messages
286
+ let messages: Vec<Message> = (0..5)
287
+ .map(|i| Message::new("session-1", "message", format!(r#"{{"i":{}}}"#, i)))
288
+ .collect();
289
+ db.insert_batch(&messages).await.unwrap();
290
+
291
+ // Wait for subscriber to pick them up
292
+ tokio::time::sleep(Duration::from_millis(100)).await;
293
+
294
+ assert_eq!(received.load(Ordering::SeqCst), 5);
295
+
296
+ subscriber.stop().await.unwrap();
297
+ }
298
+ }