Skip to content

Commit 1465442

Browse files
authored
mcp: add EventStore (#152)
Introduce an EventStore interface to store events for resumable streams. Provide an in-memory implementation. Still to do: connect to streaming transports. For #10
1 parent 9ddad03 commit 1465442

4 files changed

Lines changed: 474 additions & 17 deletions

File tree

mcp/event.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ package mcp
1010
import (
1111
"bufio"
1212
"bytes"
13+
"context"
1314
"errors"
1415
"fmt"
1516
"io"
1617
"iter"
18+
"maps"
1719
"net/http"
20+
"slices"
1821
"strings"
22+
"sync"
1923
)
2024

25+
// If true, MemoryEventStore will do frequent validation to check invariants, slowing it down.
26+
// Remove when we're confident in the code.
27+
const validateMemoryEventStore = true
28+
2129
// An Event is a server-sent event.
2230
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields.
2331
type Event struct {
@@ -136,3 +144,285 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] {
136144
}
137145
}
138146
}
147+
148+
// An EventStore tracks data for SSE streams.
149+
// A single EventStore suffices for all sessions, since session IDs are
150+
// globally unique. So one EventStore can be created per process, for
151+
// all Servers in the process.
152+
// Such a store is able to bound resource usage for the entire process.
153+
//
154+
// All of an EventStore's methods must be safe for use by multiple goroutines.
155+
type EventStore interface {
156+
// AppendEvent appends data for an outgoing event to given stream, which is part of the
157+
// given session. It returns the index of the event in the stream, suitable for constructing
158+
// an event ID to send to the client.
159+
AppendEvent(_ context.Context, sessionID string, _ StreamID, data []byte) (int, error)
160+
161+
// After returns an iterator over the data for the given session and stream, beginning
162+
// just after the given index.
163+
// Once the iterator yields a non-nil error, it will stop.
164+
// After's iterator must return an error immediately if any data after index was
165+
// dropped; it must not return partial results.
166+
After(_ context.Context, sessionID string, _ StreamID, index int) iter.Seq2[[]byte, error]
167+
168+
// StreamClosed informs the store that the given stream is finished.
169+
// A store cannot rely on this method being called for cleanup. It should institute
170+
// additional mechanisms, such as timeouts, to reclaim storage.
171+
StreamClosed(_ context.Context, sessionID string, streamID StreamID) error
172+
173+
// SessionClosed informs the store that the given session is finished, along
174+
// with all of its streams.
175+
// A store cannot rely on this method being called for cleanup. It should institute
176+
// additional mechanisms, such as timeouts, to reclaim storage.
177+
SessionClosed(_ context.Context, sessionID string) error
178+
}
179+
180+
// A dataList is a list of []byte.
181+
// The zero dataList is ready to use.
182+
type dataList struct {
183+
size int // total size of data bytes
184+
first int // the stream index of the first element in data
185+
data [][]byte
186+
}
187+
188+
func (dl *dataList) appendData(d []byte) {
189+
// If we allowed empty data, we would consume memory without incrementing the size.
190+
// We could of course account for that, but we keep it simple and assume there is no
191+
// empty data.
192+
if len(d) == 0 {
193+
panic("empty data item")
194+
}
195+
dl.data = append(dl.data, d)
196+
dl.size += len(d)
197+
}
198+
199+
// removeFirst removes the first data item in dl, returning the size of the item.
200+
// It panics if dl is empty.
201+
func (dl *dataList) removeFirst() int {
202+
if len(dl.data) == 0 {
203+
panic("empty dataList")
204+
}
205+
r := len(dl.data[0])
206+
dl.size -= r
207+
dl.data[0] = nil // help GC
208+
dl.data = dl.data[1:]
209+
dl.first++
210+
return r
211+
}
212+
213+
// lastIndex returns the index of the last data item in dl.
214+
// It panics if there are none.
215+
func (dl *dataList) lastIndex() int {
216+
if len(dl.data) == 0 {
217+
panic("empty dataList")
218+
}
219+
return dl.first + len(dl.data) - 1
220+
}
221+
222+
// A MemoryEventStore is an [EventStore] backed by memory.
223+
type MemoryEventStore struct {
224+
mu sync.Mutex
225+
maxBytes int // max total size of all data
226+
nBytes int // current total size of all data
227+
store map[string]map[StreamID]*dataList // session ID -> stream ID -> *dataList
228+
}
229+
230+
// MemoryEventStoreOptions are options for a [MemoryEventStore].
231+
type MemoryEventStoreOptions struct{}
232+
233+
// MaxBytes returns the maximum number of bytes that the store will retain before
234+
// purging data.
235+
func (s *MemoryEventStore) MaxBytes() int {
236+
s.mu.Lock()
237+
defer s.mu.Unlock()
238+
return s.maxBytes
239+
}
240+
241+
// SetMaxBytes sets the maximum number of bytes the store will retain before purging
242+
// data. The argument must not be negative. If it is zero, a suitable default will be used.
243+
// SetMaxBytes can be called at any time. The size of the store will be adjusted
244+
// immediately.
245+
func (s *MemoryEventStore) SetMaxBytes(n int) {
246+
s.mu.Lock()
247+
defer s.mu.Unlock()
248+
switch {
249+
case n < 0:
250+
panic("negative argument")
251+
case n == 0:
252+
s.maxBytes = defaultMaxBytes
253+
default:
254+
s.maxBytes = n
255+
}
256+
s.purge()
257+
}
258+
259+
const defaultMaxBytes = 10 << 20 // 10 MiB
260+
261+
// NewMemoryEventStore creates a [MemoryEventStore] with the default value
262+
// for MaxBytes.
263+
func NewMemoryEventStore(opts *MemoryEventStoreOptions) *MemoryEventStore {
264+
return &MemoryEventStore{
265+
maxBytes: defaultMaxBytes,
266+
store: make(map[string]map[StreamID]*dataList),
267+
}
268+
}
269+
270+
// AppendEvent implements [EventStore.AppendEvent] by recording data
271+
// in memory.
272+
func (s *MemoryEventStore) AppendEvent(_ context.Context, sessionID string, streamID StreamID, data []byte) (int, error) {
273+
s.mu.Lock()
274+
defer s.mu.Unlock()
275+
276+
streamMap, ok := s.store[sessionID]
277+
if !ok {
278+
streamMap = make(map[StreamID]*dataList)
279+
s.store[sessionID] = streamMap
280+
}
281+
dl, ok := streamMap[streamID]
282+
if !ok {
283+
dl = &dataList{}
284+
streamMap[streamID] = dl
285+
}
286+
// Purge before adding, so at least the current data item will be present.
287+
// (That could result in nBytes > maxBytes, but we'll live with that.)
288+
s.purge()
289+
dl.appendData(data)
290+
s.nBytes += len(data)
291+
return dl.lastIndex(), nil
292+
}
293+
294+
// After implements [EventStore.After].
295+
func (s *MemoryEventStore) After(_ context.Context, sessionID string, streamID StreamID, index int) iter.Seq2[[]byte, error] {
296+
// Return the data items to yield.
297+
// We must copy, because dataList.removeFirst nils out slice elements.
298+
copyData := func() ([][]byte, error) {
299+
s.mu.Lock()
300+
defer s.mu.Unlock()
301+
streamMap, ok := s.store[sessionID]
302+
if !ok {
303+
return nil, fmt.Errorf("MemoryEventStore.After: unknown session ID %q", sessionID)
304+
}
305+
dl, ok := streamMap[streamID]
306+
if !ok {
307+
return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID)
308+
}
309+
if dl.first > index {
310+
return nil, fmt.Errorf("MemoryEventStore.After: data purged at index %d, stream ID %v, session %q", index, streamID, sessionID)
311+
}
312+
return slices.Clone(dl.data[index-dl.first:]), nil
313+
}
314+
315+
return func(yield func([]byte, error) bool) {
316+
ds, err := copyData()
317+
if err != nil {
318+
yield(nil, err)
319+
return
320+
}
321+
for _, d := range ds {
322+
if !yield(d, nil) {
323+
return
324+
}
325+
}
326+
}
327+
}
328+
329+
// StreamClosed implements [EventStore.StreamClosed].
330+
func (s *MemoryEventStore) StreamClosed(_ context.Context, sessionID string, streamID StreamID) error {
331+
if sessionID == "" {
332+
panic("empty sessionID")
333+
}
334+
335+
s.mu.Lock()
336+
defer s.mu.Unlock()
337+
338+
sm := s.store[sessionID]
339+
dl := sm[streamID]
340+
s.nBytes -= dl.size
341+
delete(sm, streamID)
342+
if len(sm) == 0 {
343+
delete(s.store, sessionID)
344+
}
345+
s.validate()
346+
return nil
347+
}
348+
349+
// SessionClosed implements [EventStore.SessionClosed].
350+
func (s *MemoryEventStore) SessionClosed(_ context.Context, sessionID string) error {
351+
s.mu.Lock()
352+
defer s.mu.Unlock()
353+
for _, dl := range s.store[sessionID] {
354+
s.nBytes -= dl.size
355+
}
356+
delete(s.store, sessionID)
357+
s.validate()
358+
return nil
359+
}
360+
361+
// purge removes data until no more than s.maxBytes bytes are in use.
362+
// It must be called with s.mu held.
363+
func (s *MemoryEventStore) purge() {
364+
// Remove the first element of every dataList until below the max.
365+
for s.nBytes > s.maxBytes {
366+
changed := false
367+
for _, sm := range s.store {
368+
for _, dl := range sm {
369+
if dl.size > 0 {
370+
r := dl.removeFirst()
371+
if r > 0 {
372+
changed = true
373+
s.nBytes -= r
374+
}
375+
}
376+
}
377+
}
378+
if !changed {
379+
panic("no progress during purge")
380+
}
381+
}
382+
s.validate()
383+
}
384+
385+
// validate checks that the store's data structures are valid.
386+
// It must be called with s.mu held.
387+
func (s *MemoryEventStore) validate() {
388+
if !validateMemoryEventStore {
389+
return
390+
}
391+
// Check that we're accounting for the size correctly.
392+
n := 0
393+
for _, sm := range s.store {
394+
for _, dl := range sm {
395+
for _, d := range dl.data {
396+
n += len(d)
397+
}
398+
}
399+
}
400+
if n != s.nBytes {
401+
panic("sizes don't add up")
402+
}
403+
}
404+
405+
// debugString returns a string containing the state of s.
406+
// Used in tests.
407+
func (s *MemoryEventStore) debugString() string {
408+
s.mu.Lock()
409+
defer s.mu.Unlock()
410+
var b strings.Builder
411+
for i, sess := range slices.Sorted(maps.Keys(s.store)) {
412+
if i > 0 {
413+
fmt.Fprintf(&b, "; ")
414+
}
415+
sm := s.store[sess]
416+
for i, sid := range slices.Sorted(maps.Keys(sm)) {
417+
if i > 0 {
418+
fmt.Fprintf(&b, "; ")
419+
}
420+
dl := sm[sid]
421+
fmt.Fprintf(&b, "%s %d first=%d", sess, sid, dl.first)
422+
for _, d := range dl.data {
423+
fmt.Fprintf(&b, " %s", d)
424+
}
425+
}
426+
}
427+
return b.String()
428+
}

0 commit comments

Comments
 (0)