Repository: rqlite/rqlite (2025-08-05T19:25:19Z)

Issue:
Replace Blocking Dequeue with Channel-Based FIFO Queue
Replace Blocking Dequeue with Channel-Based FIFO Queue
Background
The current CDC Queue in rqlite uses a blocking Dequeue() method to retrieve events
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L236-L244)
. This means the caller must invoke Dequeue() and possibly block each time until an item is available
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L236-L244)
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L343-L351)
. In practice, this pull-based approach is inconvenient for continuously streaming change events. It's better to have the queue push events to the consumer via a Go channel, allowing the consumer to range over incoming events. This would integrate more naturally with Go's concurrency (e.g. using select on multiple channels) and avoid explicit blocking calls in client code. Moreover, the current design requires managing a list of waiting dequeuers internally
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L198-L206)
, which can be simplified by using a channel to deliver events as they arrive.
Proposal
We will replace the Queue.Dequeue() method with a new channel-based interface for consuming events. Instead of calling Dequeue() repeatedly, clients will receive events from a channel returned by the queue. The main tasks and considerations for this change are outlined below.
Add a new method to Queue that returns an event channel: For example, func (q *Queue) Events() <-chan QueueEvent. This method will set up a Go channel on which the queued events can be read (the QueueEvent type can hold the Index and Data for each item, akin to the current dequeueResp structure). The channel will serve as the single source of dequeued events for the consumer. The first call to Events() creates the channel (perhaps stored internally in the Queue) and returns it. Calling it subsequent times could return the same channel or an error if multiple subscribers are not supported (we will support only one consumer to keep design simple). This channel-based design turns the FIFO queue into a push-based stream.
Remove or deprecate the Dequeue() method: The existing Queue.Dequeue() will no longer be needed or used. It currently blocks until an item is available
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L236-L244)
, using an internal request/response channel pair to get one item at a time. We will eliminate the dequeueChan request path and the dequeueReq/dequeueResp structs from the implementation, since the channel approach will handle event delivery. All references to Dequeue() in the code (primarily unit tests) will be updated to use the new channel. This is a breaking change to the Queue API, but since the CDC module is new and internal to rqlite (not a public API yet), this should be acceptable.
Modify the internal run loop to push events to the channel: In the Queue.run() goroutine (which serializes access to the Bolt DB), we will introduce logic to send queued items into the event channel, instead of waiting for Dequeue requests. Specifically:
When a new item is enqueued via enqueueChan (in the case req := <-q.enqueueChan section), if this is the first item in an empty queue, the code already sets nextKey = key for that item
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L139-L147)
. After enqueuing (and responding to the enqueuer), the loop should emit the item to the event channel if a consumer is present. In fact, we should handle any situation where nextKey is not nil as an opportunity to send the next available item out.
We will remove the waitingDequeues slice and related logic
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L198-L206)
. Instead, treat the single channel consumer as an always-present “waiting dequeue.” That is, whenever there is an item available (nextKey != nil), the run loop should fetch that item from the Bolt DB and send it over the output channel.
Use the same Bolt cursor logic currently in the fulfillment loop to retrieve the item and advance the pointer
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L213-L221)
. For each item sent:
Fetch the value at nextKey from the DB.
Send the (idx, value) through the event channel.
Advance nextKey to the next key in the DB (using Cursor.Next() as done now
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L213-L219)
). If there is a next item, keep it as the new nextKey. If not (queue becomes empty), set nextKey = nil (so the loop will then wait for new enqueues).
Only emit events as items become available: We will not busy-wait or continuously poll. The run loop can use the arrival of new enqueue requests as the trigger to send data. In practice, this likely means after processing any enqueue (or after reopening the queue with existing items, see below), we enter a loop to flush available items to the channel until the queue is empty (nextKey becomes nil). This ensures we emit items promptly when present, then wait when there are none. This behavior mirrors the current logic where Dequeue() callers are fulfilled immediately if data exists, or else wait
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L198-L206)
, except now the waiting is encapsulated by the channel blocking.
Channel blocking and buffering: If the channel is unbuffered (or its buffer is full), the send operation in the run loop will block. This provides natural backpressure: the queue’s goroutine will pause until the consumer reads an item. This is desirable to avoid overwhelming the consumer, but we must be careful since it means the enqueueChan (and possibly other operations) won’t be processed while we’re blocked on sending. We should document this. Alternatively, we might decide to create the channel with a small buffer (e.g., buffer size 0 or 1) to allow one item to be queued without blocking, but letting the consumer mostly dictate the pace. Another idea is to let the client pass in a channel to Events() so they can control buffering (e.g., they could pass a buffered channel of their desired size). Passing in a channel is less common in Go APIs; a more idiomatic approach is to return a channel and perhaps allow configuration of its buffer. For now, we can return a channel with a modest buffer (maybe reuse the queueBufferSize constant which is 100
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L22-L25)
, or even a smaller one) to balance throughput and blocking. Good Go style typically favors returning a channel unless the caller truly needs to supply one, so we'll implement returning a channel and document its buffer behavior.
No data loss: Every item enqueued must be sent exactly once over the channel (unless the system is shut down). We will ensure the logic does not drop items. Blocking on the channel send is acceptable to guarantee delivery. If the queue is closed before some items are delivered, see the next point on shutdown handling.
Handle queue closure and channel shutdown: When Queue.Close() is called, the run loop exits via the q.done channel
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L191-L199)
. Currently it replies to any waiting dequeue requests with an ErrQueueClosed
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L191-L199)
. In the new design, there won’t be explicit waiting requests, so instead we will close the events channel to signal termination. Specifically, in the case <-q.done: branch of the select, after performing any necessary final steps, do close(eventsChan) (where eventsChan is the channel we created for output). Closing a channel is the idiomatic way to signal to a receiver that no more values will be sent. The consumer of the channel can detect this (e.g., a range loop will end). We should ensure that any remaining items in the queue at shutdown are either sent out before closing or intentionally dropped with notification. It might be wise to flush out available items on close, but since Close() likely means the process is stopping, we could also just close the channel and not guarantee delivery of leftovers (however, dropping events might not be acceptable for CDC; perhaps the service ensures to send all before calling Close). In any case, after closing the channel, proceed to close the Bolt DB as usual. The Queue.Close() method will wait for run() to finish via wg.Wait()
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L98-L101)
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/service.go#L222-L225)
, so we have a guarantee that no sends happen after the channel is closed.
Channel should emit persisted backlog on startup: If the queue is opened and there are already items in it (from a previous run that were never deleted), the consumer should receive those items via the channel as well. In the current implementation, a reopened queue will still have nextKey pointing to the first existing item
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L76-L84)
. With the new design, as soon as a consumer subscribes (calls Events()), we should start sending any existing items one-by-one until the queue is empty (or we run into blocking). This ensures the behavior remains the same as before: for example, as shown in the tests, an item that was enqueued before a restart and not deleted can be dequeued again after reopening
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L421-L429)
. With the channel, the item would simply appear in the stream after reopen. We will implement this by starting the internal sender loop immediately if nextKey != nil when the channel is created (i.e. on Events() call or when run() begins). Concretely, we might initiate an event send loop at the end of NewQueue (or the beginning of run) that flushes existing items to the channel. Alternatively, we integrate it into the main select loop: e.g., treat the initial state as if an enqueue had occurred or just check at the bottom of each iteration if nextKey is set and no other operations are pending, then send. We must be careful to avoid a busy loop; perhaps simply entering the same sending loop we use after enqueues would work.
Preserve ordering and at-least-once semantics: We must ensure events are delivered in FIFO order (the smallest index first) exactly as the queue is intended. Using nextKey as described guarantees this, since we always send the item at nextKey and then advance it
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L213-L221)
. Also, since items remain in the Bolt DB until explicitly deleted by DeleteRange, a crash or restart could lead to re-reading and re-sending some events (which is already the case, as the test shows by dequeuing the same item twice across reopen
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L421-L429)
). Our channel implementation will naturally do the same – it will not remove items, only stream them – so it stays consistent with at-least-once delivery. The consumer (CDC service) is responsible for calling DeleteRange() after an event (or a batch of events) has been fully processed and acknowledged, in order to remove them from the queue. We will not change DeleteRange behavior.
Update unit tests: All tests in cdc/fifo_test.go must be rewritten to use the new channel interface instead of Dequeue(). Key updates include:
In tests like Test_EnqueueDequeue_Simple and ..._Multi, obtain the events channel (e.g., events := q.Events()) and then read from it to get the dequeued item. For example, instead of gotIdx, gotItem, err := q.Dequeue(), we will do something like evt := <-events (where evt contains Index and Data). We will then verify evt.Index == idx1 and evt.Data matches item1
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L64-L72)
. We should also check that reading from the channel does not produce additional events when the queue is empty (similar to how HasNext() is used to check emptiness).
The Test_DequeueBlocking test currently spawns a goroutine that calls q.Dequeue() and waits until an item is enqueued to unblock
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L343-L351)
. In the new approach, reading from the events channel will naturally block if no events are available. We can test blocking by trying to receive from the channel with a timeout. For example, start a goroutine that does <-eventsChan and sends the result to a separate channel, then after a short sleep, call q.Enqueue(...) to add an item, and finally assert that the goroutine received the item (and that a timeout would occur if we hadn’t enqueued). This mirrors the intent of the original test
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L354-L362)
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L363-L370)
 using channels instead of Dequeue.
Update persistence tests: in Test_QueuePersistence, after reopening a queue (q2 or q3), instead of calling Dequeue() to get the item
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L398-L406)
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L421-L429)
, we will likely create a new events channel for the reopened queue (call q2.Events()) and read from it. We should get the previously enqueued item delivered. After reading it, the test can close the queue and reopen again to ensure that if no deletion happened, the item can appear again (or if we already drained it, perhaps in this scenario, we might consider that on reopen, if we call Events() fresh, it will again replay from disk – but careful: if Events() was already called on q2 and drained the item, then closing and reopening as q3 will find the item still on disk because DeleteRange wasn’t called, thus q3.Events() should deliver it once more, which matches the current test logic). We need to ensure the channel consumption in tests aligns with how many times an item is expected to be seen. Probably, the simplest way is to open the queue and immediately read one item from Events() for each expected Dequeue in the test flow.
Adjust any uses of HasNext() or Empty() in tests. These can still be used to check queue state (they still query the DB), but now that we have a channel, tests might alternatively just try to read from or check if the channel is closed. We might leave the HasNext checks in place to verify internal state consistency. For instance, after reading all items from the channel, HasNext() should return false (queue empty). The test expectations around HasNext()
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L113-L119)
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo_test.go#L91-L99)
 and Empty() remain valid.
Ensure all tests pass with the new approach, meaning the channel yields items in correct order, blocks when appropriate, and is closed on queue close.
Go style and buffering decision: To reiterate, we choose to return a channel from the Events() method, rather than requiring the caller to pass one in. This is idiomatic and keeps usage simple (events := q.Events(); for evt := range events { ... }). We will likely create this channel with a small buffer. Good practice is to document the buffering: e.g., "The channel has a buffer of N to accommodate bursts of events." If the user needs more control, we could later allow an optional parameter for buffer size or expose a way to provide a custom channel, but that seems unnecessary now. We'll implement the straightforward approach and document that the consumer should read from the channel promptly to avoid stalling the queue's internal goroutine.
Documentation updates: Update the comments in fifo.go to reflect the new design. The comment for Queue struct and its properties should mention the channel-based consumption. The Dequeue() comment will be removed, and a new comment for the Events() method will explain how to use it (e.g., "Returns a channel that the caller can range over to receive queued events as they are available. The channel will be closed when the queue is closed or an unrecoverable error occurs."). We should also mention that Dequeue() is replaced by this channel (if someone encounters the old method in docs or code, they should know it's obsolete). Additionally, ensure the README or any design docs for CDC (there is a cdc/DESIGN.md) are updated if they mention the dequeuing mechanism.
By implementing these changes, the CDC FIFO queue will become easier to use and integrate. Rather than the service or any consumer having to call a blocking method in a loop, they will get a continuous stream of events. This aligns with typical Go patterns and should make the CDC service logic cleaner (e.g., it can select on the FIFO events channel and leader changes, etc., without dedicated goroutines for Dequeue). The core FIFO properties (persistent storage, explicit deletion, ordered delivery) remain the same. We will verify all unit tests pass and that performance remains acceptable (the overhead of channel send/receive should be minimal and the design still uses a single goroutine for DB access). Checklist of Tasks:
 Implement Queue.Events() method returning a <-chan QueueEvent (define QueueEvent struct with fields for index, value, and perhaps error if needed). Create an internal channel (possibly buffered) to store outgoing events.
 Remove or refactor Queue.Dequeue(): eliminate the dequeueChan and associated request/response structs. Calls to Dequeue() in code will be replaced by reads from the new channel.
 Update Queue.run() loop: Remove waitingDequeues. Add logic to send events to the channel whenever nextKey is set. Use the Bolt cursor to fetch values and advance nextKey as in the current code
[GitHub](https://github.com/rqlite/rqlite/blob/486d2ffc26da7fba8ef5a4ea14392bdbfdf9cd89/cdc/fifo.go#L213-L221)
. Manage blocking on channel send (this will naturally happen if consumer is slow; ensure this doesn’t deadlock other parts by relying on the single goroutine model and possibly a channel buffer). Close the channel on q.done.
 Adjust Queue.Close(): ensure it triggers channel closure and waits for goroutine completion as it does now. No goroutine should be sending after the channel is closed.
 Revise unit tests in cdc/fifo_test.go: use Events() channel to receive data. Remove usage of Dequeue(). Test for blocking behavior using channel operations and timeouts. Test channel closure by closing the queue in a test and verifying that the channel is closed (e.g., a loop over channel ends, or a second read returns no data). Maintain the persistence tests by reading from the channel after reopen to verify old events reappear.
 Documentation and examples: Update code comments (especially replacing the Dequeue comments with channel usage guidance). If any external docs (like cdc/DESIGN.md or project README) refer to how CDC events are pulled from the queue, update them to the new push model.
 Run all tests and ensure functionality: All existing tests (modified as above) should pass. The behavior should be validated: events come through in order, no events lost or duplicated (except intentional replays after reopen without deletion), and performance is reasonable. If needed, add new tests for multiple rapid enqueues to ensure the channel delivers all of them, and for proper closure behavior.
By completing these steps, we'll have a cleaner and more idiomatic FIFO queue for CDC, which will emit events through a channel and improve the integration with the rest of the system.


Discussion:

Patch:
diff --git a/cdc/fifo.go b/cdc/fifo.go
index 7b30b31a2..718e55f27 100644
--- a/cdc/fifo.go
+++ b/cdc/fifo.go
@@ -21,14 +21,22 @@ var ErrQueueClosed = errors.New("queue is closed")
 
 var queueBufferSize = 100 // Size of the buffered channels for enqueue requests
 
+// Event represents a dequeued item from the queue with its index and data.
+type Event struct {
+	Index uint64
+	Data  []byte
+}
+
 // Queue is a persistent, disk-backed FIFO queue managed by a single goroutine.
 //
 // It is safe for concurrent use. It has some particular properties that make it
 // suitable for the CDC service.
 //   - The queue is persistent and can be used to recover from crashes or restarts.
-//   - Dequeuing an item does not remove it from the queue. Only when DeleteRange is called
-//     will items be removed from the queue. This allows the CDC service to explicitly
-//     delete items only when it is sure they have been successfully transmitted.
+//   - The queue will emit items as events on the Events channel.
+//   - Reading an item from the Events channel does not remove it from the queue.
+//     Only when DeleteRange is called will items be removed from the queue. This
+//     allows the CDC service to explicitly delete items only when it is sure they
+//     have been successfully transmitted.
 //   - The queue remembers -- even after restarts -- the highest index of any item ever
 //     enqueued. Since this queue is to be used to store changes associated with Raft
 //     log entries, once a given index has been written to the queue any further
@@ -39,11 +47,13 @@ type Queue struct {
 
 	// Channels for communicating with the managing goroutine
 	enqueueChan     chan enqueueReq
-	dequeueChan     chan dequeueReq
 	deleteRangeChan chan deleteRangeReq
 	queryChan       chan queryReq
 	done            chan struct{}
 
+	// Events channel for consuming queue events (created at initialization)
+	eventsChan chan Event
+
 	wg sync.WaitGroup
 }
 
@@ -75,7 +85,11 @@ func NewQueue(path string) (*Queue, error) {
 		// Initialize state from the DB.
 		var innerErr error
 		c := tx.Bucket(bucketName).Cursor()
-		nextKey, _ = c.First()
+		nk, _ := c.First()
+		if nk != nil {
+			nextKey = make([]byte, 8)
+			copy(nextKey, nk)
+		}
 		highestKey, innerErr = getHighestKey(tx)
 		if innerErr != nil {
 			return fmt.Errorf("failed to get highest key: %w", innerErr)
@@ -89,10 +103,10 @@ func NewQueue(path string) (*Queue, error) {
 	q := &Queue{
 		db:              db,
 		enqueueChan:     make(chan enqueueReq, queueBufferSize),
-		dequeueChan:     make(chan dequeueReq),
 		deleteRangeChan: make(chan deleteRangeReq),
 		queryChan:       make(chan queryReq),
 		done:            make(chan struct{}),
+		eventsChan:      make(chan Event, 10), // Buffered channel for events
 	}
 
 	q.wg.Add(1)
@@ -102,7 +116,13 @@ func NewQueue(path string) (*Queue, error) {
 
 // Close gracefully shuts down the queue, ensuring all pending operations are finished.
 func (q *Queue) Close() {
-	close(q.done)
+	select {
+	case <-q.done:
+		// Already closed
+		return
+	default:
+		close(q.done)
+	}
 	q.wg.Wait()
 }
 
@@ -110,8 +130,57 @@ func (q *Queue) Close() {
 func (q *Queue) run(nextKey []byte, highestKey uint64) {
 	defer q.wg.Done()
 	defer q.db.Close()
+	defer close(q.eventsChan) // Close events channel when queue shuts down
+
+	// Helper function to try sending available events
+	tryServeEvents := func() {
+		for nextKey != nil {
+			var event Event
+			err := q.db.View(func(tx *bbolt.Tx) error {
+				c := tx.Bucket(bucketName).Cursor()
+				_, val := c.Seek(nextKey)
+				if val == nil {
+					return fmt.Errorf("item not found for key %x", nextKey)
+				}
+				event.Index = btouint64(nextKey)
+				event.Data = make([]byte, len(val))
+				copy(event.Data, val)
+				return nil
+			})
+
+			if err != nil {
+				break
+			}
+
+			// Try to send to events channel (non-blocking)
+			select {
+			case q.eventsChan <- event:
+				// Successfully sent, advance to next item
+				err := q.db.View(func(tx *bbolt.Tx) error {
+					c := tx.Bucket(bucketName).Cursor()
+					c.Seek(nextKey)
+					nk, _ := c.Next()
+					if nk != nil {
+						nextKey = make([]byte, len(nk))
+						copy(nextKey, nk)
+					} else {
+						nextKey = nil // No more items available
+					}
+					return nil
+				})
+				if err != nil {
+					return
+				}
+			default:
+				// Events channel is full, stop processing
+				return
+			}
+		}
+	}
+
+	// Load any on-disk events into the channel
+	tryServeEvents()
 
-	var waitingDequeues []dequeueReq // A list of callers waiting for an item.
 	for {
 		select {
 		case req := <-q.enqueueChan:
@@ -137,15 +206,15 @@ func (q *Queue) run(nextKey []byte, highestKey uint64) {
 			}
 
 			// If this is the first item added to a previously empty queue,
-			// it becomes the next item to be dequeued.
+			// it becomes the next item to be sent to events channel.
 			if nextKey == nil {
 				nextKey = key
 			}
 
 			req.respChan <- enqueueResp{err: err}
 
-		case req := <-q.dequeueChan:
-			waitingDequeues = append(waitingDequeues, req)
+			// Try to send any available events to the events channel
+			tryServeEvents()
 
 		case req := <-q.deleteRangeChan:
 			err := q.db.Update(func(tx *bbolt.Tx) error {
@@ -175,53 +244,24 @@ func (q *Queue) run(nextKey []byte, highestKey uint64) {
 
 		case req := <-q.queryChan:
 			var isEmpty bool
+			var hasNext bool
 			err := q.db.View(func(tx *bbolt.Tx) error {
 				c := tx.Bucket(bucketName).Cursor()
 				k, _ := c.First()
 				isEmpty = k == nil // If no items, isEmpty is true
+				hasNext = k != nil // If there are any items, hasNext is true
 				return nil
 			})
 			req.respChan <- queryResp{
 				err:        err,
-				hasNext:    nextKey != nil,
+				hasNext:    hasNext,
 				isEmpty:    isEmpty,
 				highestKey: highestKey,
 			}
 
 		case <-q.done:
-			for _, waiter := range waitingDequeues {
-				waiter.respChan <- dequeueResp{err: ErrQueueClosed}
-			}
 			return
 		}
-
-		// Fulfill any waiting dequeue requests.
-		for len(waitingDequeues) > 0 && nextKey != nil {
-			waiter := waitingDequeues[0]
-			waitingDequeues = waitingDequeues[1:] // Pop from waitlist
-
-			var resp dequeueResp
-			err := q.db.View(func(tx *bbolt.Tx) error {
-				c := tx.Bucket(bucketName).Cursor()
-				_, val := c.Seek(nextKey)
-				if val == nil {
-					return fmt.Errorf("item not found for key %x", nextKey)
-				}
-				resp.idx = btouint64(nextKey)
-				resp.val = make([]byte, len(val))
-				copy(resp.val, val)
-
-				nk, _ := c.Next()
-				if nk != nil {
-					copy(nextKey, nk)
-				} else {
-					nextKey = nil // No more items available
-				}
-				return nil
-			})
-			resp.err = err
-			waiter.respChan <- resp
-		}
 	}
 }
 
@@ -233,14 +273,11 @@ func (q *Queue) Enqueue(idx uint64, item []byte) error {
 	return resp.err
 }
 
-// Dequeue removes and returns the next available item from the queue.
-// If the queue is empty, Dequeue blocks until an item is available.
-// Do not call Dequeue on a closed queue.
-func (q *Queue) Dequeue() (uint64, []byte, error) {
-	req := dequeueReq{respChan: make(chan dequeueResp)}
-	q.dequeueChan <- req
-	resp := <-req.respChan
-	return resp.idx, resp.val, resp.err
+// Events returns a channel that will receive events from the queue as they become available.
+// This provides a push-based interface for consuming events. The returned channel will be
+// closed when the queue is closed. This method always returns the same channel instance.
+func (q *Queue) Events() <-chan Event {
+	return q.eventsChan
 }
 
 // DeleteRange deletes all items in the queue with indices less than or equal to idx.
@@ -299,16 +336,6 @@ type enqueueResp struct {
 	err error
 }
 
-type dequeueReq struct {
-	respChan chan dequeueResp
-}
-
-type dequeueResp struct {
-	idx uint64
-	val []byte
-	err error
-}
-
 type deleteRangeReq struct {
 	idx      uint64
 	respChan chan error


Test patch:
diff --git a/cdc/fifo_test.go b/cdc/fifo_test.go
index 638916979..08f2612f4 100644
--- a/cdc/fifo_test.go
+++ b/cdc/fifo_test.go
@@ -2,6 +2,7 @@ package cdc
 
 import (
 	"bytes"
+	"fmt"
 	"os"
 	"path/filepath"
 	"testing"
@@ -36,11 +37,15 @@ func Test_NewQueue(t *testing.T) {
 	}
 }
 
-func Test_EnqueueDequeue_Simple(t *testing.T) {
+// Test_EnqueueEvents_Simple tests a single Enqueue operation and receiving the event.
+func Test_EnqueueEvents_Simple(t *testing.T) {
 	q, _, cleanup := newTestQueue(t)
 	defer cleanup()
 
-	// Enqueue a single item then dequeue it.
+	// Get the events channel
+	eventsCh := q.Events()
+
+	// Enqueue a single item then receive it from events channel.
 	item1 := []byte("hello world")
 	idx1 := uint64(10)
 	if err := q.Enqueue(idx1, item1); err != nil {
@@ -56,143 +61,75 @@ func Test_EnqueueDequeue_Simple(t *testing.T) {
 		t.Fatal("Queue should not be empty")
 	}
 
-	// Should be items to dequeue.
+	// Should be items to receive from events channel.
 	if !q.HasNext() {
-		t.Fatalf("HasNext should be false after dequeuing last item")
+		t.Fatalf("HasNext should be true after enqueuing an item")
 	}
 
-	gotIdx, gotItem, err := q.Dequeue()
-	if err != nil {
-		t.Fatalf("First() failed after enqueue: %v", err)
-	}
-	if gotIdx != idx1 {
-		t.Errorf("Expected first index to be %d, got %d", idx1, gotIdx)
-	}
-	if !bytes.Equal(gotItem, item1) {
-		t.Errorf("Expected first item to be '%s', got '%s'", item1, gotItem)
+	// Receive the event from the channel
+	select {
+	case event := <-eventsCh:
+		if event.Index != idx1 {
+			t.Errorf("Expected event index to be %d, got %d", idx1, event.Index)
+		}
+		if !bytes.Equal(event.Data, item1) {
+			t.Errorf("Expected event data to be '%s', got '%s'", item1, event.Data)
+		}
+	case <-time.After(1 * time.Second):
+		t.Fatal("Timed out waiting for event from channel")
 	}
 }
 
-// Test_EnqueueDequeue_Multi tests multiple Enqueue and Dequeue operations.
-func Test_EnqueueDequeue_Multi(t *testing.T) {
+// Test_EnqueueEvents_Multi tests multiple Enqueue operations and receiving events.
+func Test_EnqueueEvents_Multi(t *testing.T) {
 	q, _, cleanup := newTestQueue(t)
 	defer cleanup()
 
-	// Enqueue a single item then dequeue it.
-	item1 := []byte("hello world")
-	idx1 := uint64(10)
-	if err := q.Enqueue(idx1, item1); err != nil {
-		t.Fatalf("Enqueue failed: %v", err)
-	}
-
-	// Ensure the queue is not empty
-	e, err := q.Empty()
-	if err != nil {
-		t.Fatalf("Queue Empty check failed: %v", err)
-	}
-	if e {
-		t.Fatal("Queue should not be empty")
-	}
+	// Get the events channel
+	eventsCh := q.Events()
 
-	// Should be items to dequeue.
-	if !q.HasNext() {
-		t.Fatalf("HasNext should be false after dequeuing last item")
+	// Enqueue multiple items
+	items := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{10, []byte("first item")},
+		{20, []byte("second item")},
+		{30, []byte("third item")},
 	}
 
-	// Dequeue the first item.
-	gotIdx, gotItem, err := q.Dequeue()
-	if err != nil {
-		t.Fatalf("First() failed after enqueue: %v", err)
-	}
-	if gotIdx != idx1 {
-		t.Errorf("Expected first index to be %d, got %d", idx1, gotIdx)
-	}
-	if !bytes.Equal(gotItem, item1) {
-		t.Errorf("Expected first item to be '%s', got '%s'", item1, gotItem)
+	for _, item := range items {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
 	}
 
-	// Should be no more items to dequeue.
-	if q.HasNext() {
-		t.Fatalf("HasNext should be false after enqueueing an item")
+	// Receive all events in order
+	for i, expectedItem := range items {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("Event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("Event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for event %d from channel", i)
+		}
 	}
 
-	// check highest key
+	// Check highest key
 	hi, err := q.HighestKey()
 	if err != nil {
 		t.Fatalf("HighestKey failed after enqueue: %v", err)
 	}
-	if hi != idx1 {
-		t.Errorf("Expected highest key to be %d, got %d", idx1, hi)
-	}
-
-	// Next enqueue two items, and dequeue each, ensuring we get them in the
-	// expected order.
-	item2 := []byte("second item")
-	idx2 := uint64(20)
-	if err := q.Enqueue(idx2, item2); err != nil {
-		t.Fatalf("Enqueue failed for second item: %v", err)
-	}
-
-	// Should be more items to dequeue.
-	if !q.HasNext() {
-		t.Fatalf("HasNext should be true after enqueueing an item")
-	}
-
-	item3 := []byte("third item")
-	idx3 := uint64(30)
-	if err := q.Enqueue(idx3, item3); err != nil {
-		t.Fatalf("Enqueue failed for third item: %v", err)
-	}
-
-	// Should be more items to dequeue.
-	if !q.HasNext() {
-		t.Fatalf("HasNext should be true after enqueueing an item")
-	}
-
-	// Dequeue the second item.
-	gotIdx, gotItem, err = q.Dequeue()
-	if err != nil {
-		t.Fatalf("Dequeue failed for second item: %v", err)
-	}
-	if gotIdx != idx2 {
-		t.Fatalf("Expected second index to be %d, got %d", idx2, gotIdx)
-	}
-	if !bytes.Equal(gotItem, item2) {
-		t.Fatalf("Expected second item to be '%s', got '%s'", item2, gotItem)
-	}
-
-	// Should be more items to dequeue since we only removed one.
-	if !q.HasNext() {
-		t.Fatalf("HasNext should be true after enqueueing an item")
-	}
-
-	// Dequeue the third item.
-	gotIdx, gotItem, err = q.Dequeue()
-	if err != nil {
-		t.Fatalf("Dequeue failed for third item: %v", err)
-	}
-	if gotIdx != idx3 {
-		t.Fatalf("Expected third index to be %d, got %d", idx3, gotIdx)
-	}
-	if !bytes.Equal(gotItem, item3) {
-		t.Fatalf("Expected third item to be '%s', got '%s'", item3, gotItem)
-	}
-
-	// check highest key
-	hi, err = q.HighestKey()
-	if err != nil {
-		t.Fatalf("HighestKey failed after enqueue: %v", err)
-	}
-	if hi != idx3 {
-		t.Errorf("Expected highest key to be %d, got %d", idx1, hi)
-	}
-
-	// Should be no items to dequeue.
-	if q.HasNext() {
-		t.Fatalf("HasNext should be false after dequeuing last item")
+	if hi != 30 {
+		t.Errorf("Expected highest key to be 30, got %d", hi)
 	}
 }
 
+// Test_EnqueueHighest tests that enqueuing an older item does not change the highest key.
 func Test_EnqueueHighest(t *testing.T) {
 	q, _, cleanup := newTestQueue(t)
 	defer cleanup()
@@ -221,6 +158,7 @@ func Test_EnqueueHighest(t *testing.T) {
 	}
 }
 
+// Test_DeleteRange tests deleting a range of items from the queue.
 func Test_DeleteRange(t *testing.T) {
 	q, _, cleanup := newTestQueue(t)
 	defer cleanup()
@@ -230,6 +168,9 @@ func Test_DeleteRange(t *testing.T) {
 		t.Fatalf("DeleteRange on empty queue should not error: %v", err)
 	}
 
+	// Get the events channel before enqueuing
+	eventsCh := q.Events()
+
 	// Enqueue a few items.
 	items := []struct {
 		idx  uint64
@@ -245,34 +186,43 @@ func Test_DeleteRange(t *testing.T) {
 		}
 	}
 
+	// Consume all events that were sent during enqueue
+	for i := 0; i < len(items); i++ {
+		select {
+		case event := <-eventsCh:
+			// Verify the events are in order
+			expectedItem := items[i]
+			if event.Index != expectedItem.idx {
+				t.Fatalf("Event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Fatalf("Event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for event %d", i)
+		}
+	}
+
 	// Delete a range of items.
 	if err := q.DeleteRange(2); err != nil {
 		t.Fatalf("DeleteRange failed: %v", err)
 	}
 
-	// Should still have another item to dequeue.
+	// Should still have item 3 in the database
 	if !q.HasNext() {
-		t.Fatalf("HasNext failed after DeleteRange")
-	}
-
-	// Dequeue next item, should be 3.
-	gotIdx, gotItem, err := q.Dequeue()
-	if err != nil {
-		t.Fatalf("Dequeue failed after DeleteRange: %v", err)
-	}
-	if gotIdx != 3 {
-		t.Fatalf("Expected index 3 after DeleteRange, got %d", gotIdx)
-	}
-	if !bytes.Equal(gotItem, []byte("three")) {
-		t.Fatalf("Expected item 'three' after DeleteRange, got '%s'", gotItem)
+		t.Fatalf("HasNext should be true, item 3 should still exist after DeleteRange")
 	}
 
-	// Should be no more items to dequeue.
-	if q.HasNext() {
-		t.Fatalf("HasNext should be false after dequeuing last item")
+	// No more events should be available since all were consumed
+	select {
+	case event := <-eventsCh:
+		t.Fatalf("Unexpected event received after DeleteRange: index=%d", event.Index)
+	case <-time.After(100 * time.Millisecond):
+		// Expected - no more events
 	}
 }
 
+// Test_QueueHighestKey tests that HighestKey returns the correct value after various operations.
 func Test_QueueHighestKey(t *testing.T) {
 	q, path, _ := newTestQueue(t)
 
@@ -331,55 +281,36 @@ func Test_QueueHighestKey(t *testing.T) {
 	q.Close()
 }
 
-// Test_DequeueBlocking tests that Dequeue blocks when the queue is empty
-// and unblocks when an item is added.
-func Test_DequeueBlocking(t *testing.T) {
-	q, _, cleanup := newTestQueue(t)
-	defer cleanup()
+// Test_Events_ChannelCloseOnQueueClose tests that the events channel is closed when the queue is closed.
+func Test_Events_ChannelCloseOnQueueClose(t *testing.T) {
+	q, _, _ := newTestQueue(t) // Don't call cleanup automatically
 
-	item := []byte("unblock me")
-	itemChan := make(chan []byte)
+	// Get the events channel
+	eventsCh := q.Events()
 
-	// This goroutine will call Dequeue and block until an item is available.
-	go func() {
-		_, dequeuedItem, err := q.Dequeue()
-		if err != nil {
-			t.Errorf("Dequeue in goroutine failed: %v", err)
-			close(itemChan)
-			return
-		}
-		itemChan <- dequeuedItem
-	}()
-
-	// Give the goroutine a moment to start and call Dequeue, which should block.
-	time.Sleep(100 * time.Millisecond)
-
-	// Now, enqueue an item. This should unblock the waiting goroutine.
-	if err := q.Enqueue(1, item); err != nil {
-		t.Fatalf("Enqueue failed: %v", err)
-	}
+	// Close the queue
+	q.Close()
 
-	// Wait for the dequeued item from the channel, with a timeout.
+	// The events channel should be closed
 	select {
-	case receivedItem := <-itemChan:
-		if !bytes.Equal(receivedItem, item) {
-			t.Errorf("Expected dequeued item to be '%s', got '%s'", item, receivedItem)
+	case _, ok := <-eventsCh:
+		if ok {
+			t.Error("Events channel should be closed when queue is closed")
 		}
 	case <-time.After(1 * time.Second):
-		t.Fatal("Timed out waiting for Dequeue to unblock")
+		t.Fatal("Timed out waiting for events channel to close")
 	}
 }
 
 // Test_QueuePersistence ensures that items remain in the queue after closing and reopening it.
 func Test_QueuePersistence(t *testing.T) {
-	// This test cannot run in parallel because it relies on a specific file path.
 	dir := t.TempDir()
 	dbPath := filepath.Join(dir, "persist_test.db")
 
 	item := []byte("survivor")
 	idx := uint64(42)
 
-	// reate a queue, add an item, and close it.
+	// create a queue, add an item, and close it.
 	q1, err := NewQueue(dbPath)
 	if err != nil {
 		t.Fatalf("Failed to create initial queue: %v", err)
@@ -395,18 +326,18 @@ func Test_QueuePersistence(t *testing.T) {
 		t.Fatalf("Failed to reopen queue: %v", err)
 	}
 
-	// Dequeue the item and verify it's the one we saved.
-	_, dequeuedItem, err := q2.Dequeue()
-	if err != nil {
-		t.Fatalf("Failed to dequeue from reopened queue: %v", err)
-	}
-	if !bytes.Equal(dequeuedItem, item) {
-		t.Errorf("Expected item '%s' after reopening, got '%s'", item, dequeuedItem)
-	}
-
-	// Queue should not have any next items.
-	if q2.HasNext() {
-		t.Fatal("Queue should not have next items after dequeuing last item")
+	// Get the events channel and receive the item to verify it's the one we saved.
+	eventsCh := q2.Events()
+	select {
+	case event := <-eventsCh:
+		if !bytes.Equal(event.Data, item) {
+			t.Errorf("Expected item '%s' after reopening, got '%s'", item, event.Data)
+		}
+		if event.Index != idx {
+			t.Errorf("Expected index %d after reopening, got %d", idx, event.Index)
+		}
+	case <-time.After(1 * time.Second):
+		t.Fatal("Timed out waiting for event from reopened queue")
 	}
 
 	// Close the queue again.
@@ -418,15 +349,20 @@ func Test_QueuePersistence(t *testing.T) {
 		t.Fatalf("Failed to reopen queue: %v", err)
 	}
 
-	// Dequeue the item again, ensuring it's still available after reopening. This
+	// Receive the item again from events channel, ensuring it's still available after reopening. This
 	// tests that the queue's state is persistent across closures because no deletion
 	// has occurred.
-	_, dequeuedItem, err = q3.Dequeue()
-	if err != nil {
-		t.Fatalf("Failed to dequeue from reopened queue: %v", err)
-	}
-	if !bytes.Equal(dequeuedItem, item) {
-		t.Errorf("Expected item '%s' after reopening, got '%s'", item, dequeuedItem)
+	eventsCh = q3.Events()
+	select {
+	case event := <-eventsCh:
+		if !bytes.Equal(event.Data, item) {
+			t.Errorf("Expected item '%s' after reopening, got '%s'", item, event.Data)
+		}
+		if event.Index != idx {
+			t.Errorf("Expected index %d after reopening, got %d", idx, event.Index)
+		}
+	case <-time.After(1 * time.Second):
+		t.Fatal("Timed out waiting for event from reopened queue")
 	}
 
 	// Now, let's actually delete the item and ensure it is gone, even after reopening.
@@ -460,6 +396,356 @@ func Test_QueuePersistence(t *testing.T) {
 	q4.Close()
 }
 
+// Test_Events_SameChannelReturned tests that calling Events() multiple times returns the same channel.
+func Test_Events_SameChannelReturned(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	ch1 := q.Events()
+	ch2 := q.Events()
+
+	if ch1 != ch2 {
+		t.Error("Events() should return the same channel when called multiple times")
+	}
+}
+
+// Test_Events_NoReader tests that events can be enqueued when no one is reading from the Events channel.
+func Test_Events_NoReader(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	// Enqueue several items without reading from Events channel
+	items := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{1, []byte("first")},
+		{2, []byte("second")},
+		{3, []byte("third")},
+		{4, []byte("fourth")},
+		{5, []byte("fifth")},
+	}
+
+	for _, item := range items {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
+	}
+
+	// Verify queue is not empty
+	if empty, err := q.Empty(); err != nil {
+		t.Fatalf("Empty check failed: %v", err)
+	} else if empty {
+		t.Fatal("Queue should not be empty after enqueuing items")
+	}
+
+	// Verify highest key
+	if hi, err := q.HighestKey(); err != nil {
+		t.Fatalf("HighestKey failed: %v", err)
+	} else if hi != 5 {
+		t.Errorf("Expected highest key to be 5, got %d", hi)
+	}
+
+	// Now get the events channel and verify all events are available
+	eventsCh := q.Events()
+	for i, expectedItem := range items {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("Event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("Event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for event %d", i)
+		}
+	}
+}
+
+// Test_Events_ReaderAppearsLater tests when a reader appears after events have already been queued.
+func Test_Events_ReaderAppearsLater(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	// Enqueue items before getting Events channel
+	items := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{10, []byte("pre-reader-1")},
+		{20, []byte("pre-reader-2")},
+		{30, []byte("pre-reader-3")},
+	}
+
+	for _, item := range items {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
+	}
+
+	// Wait a bit to ensure enqueue operations complete
+	time.Sleep(50 * time.Millisecond)
+
+	// Now get the events channel - this should make all queued events available
+	eventsCh := q.Events()
+
+	// Receive all pre-queued events
+	for i, expectedItem := range items {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("Event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("Event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for pre-queued event %d", i)
+		}
+	}
+
+	// Enqueue more items after reader is active
+	postItems := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{40, []byte("post-reader-1")},
+		{50, []byte("post-reader-2")},
+	}
+
+	for _, item := range postItems {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for post-reader index %d: %v", item.idx, err)
+		}
+	}
+
+	// Receive post-reader events
+	for i, expectedItem := range postItems {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("Post-reader event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("Post-reader event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for post-reader event %d", i)
+		}
+	}
+}
+
+// Test_Events_BufferedChannelBehavior tests the behavior when the events channel buffer fills up.
+func Test_Events_BufferedChannelBehavior(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	// Get events channel but don't read from it initially
+	eventsCh := q.Events()
+
+	// Enqueue items up to and beyond the buffer size (which is 10)
+	const numItems = 15
+	items := make([]struct {
+		idx  uint64
+		data []byte
+	}, numItems)
+
+	for i := 0; i < numItems; i++ {
+		items[i] = struct {
+			idx  uint64
+			data []byte
+		}{
+			idx:  uint64(i + 1),
+			data: []byte(fmt.Sprintf("item-%d", i+1)),
+		}
+	}
+
+	// Enqueue all items
+	for _, item := range items {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
+	}
+
+	// Wait a bit for processing
+	time.Sleep(100 * time.Millisecond)
+
+	// Start reading events - should get at least the buffered events
+	// Note: Due to the non-blocking send in the implementation, we may not get all events
+	// if the buffer was full, but we should get the buffered ones in order
+	receivedEvents := make([]Event, 0, numItems)
+
+	// Try to receive events with timeout
+	for i := 0; i < numItems; i++ {
+		select {
+		case event := <-eventsCh:
+			receivedEvents = append(receivedEvents, event)
+		case <-time.After(100 * time.Millisecond):
+			// No more events available immediately
+		}
+	}
+
+	// Verify we received events in order and they match expectations
+	if len(receivedEvents) == 0 {
+		t.Fatal("Should have received at least some events")
+	}
+
+	for i, event := range receivedEvents {
+		expectedIdx := uint64(i + 1)
+		expectedData := []byte(fmt.Sprintf("item-%d", i+1))
+
+		if event.Index != expectedIdx {
+			t.Errorf("Event %d: expected index %d, got %d", i, expectedIdx, event.Index)
+		}
+		if !bytes.Equal(event.Data, expectedData) {
+			t.Errorf("Event %d: expected data '%s', got '%s'", i, expectedData, event.Data)
+		}
+	}
+
+	t.Logf("Received %d out of %d events", len(receivedEvents), numItems)
+}
+
+// Test_Events_InterruptedReader tests when a reader stops and resumes reading.
+func Test_Events_InterruptedReader(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	eventsCh := q.Events()
+
+	// Enqueue first batch of items
+	firstBatch := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{1, []byte("batch1-item1")},
+		{2, []byte("batch1-item2")},
+	}
+
+	for _, item := range firstBatch {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
+	}
+
+	// Read first batch
+	for i, expectedItem := range firstBatch {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("First batch event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("First batch event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for first batch event %d", i)
+		}
+	}
+
+	// Stop reading (simulate interrupted reader) and enqueue more items
+	secondBatch := []struct {
+		idx  uint64
+		data []byte
+	}{
+		{3, []byte("batch2-item1")},
+		{4, []byte("batch2-item2")},
+		{5, []byte("batch2-item3")},
+	}
+
+	for _, item := range secondBatch {
+		if err := q.Enqueue(item.idx, item.data); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", item.idx, err)
+		}
+	}
+
+	// Wait a bit to simulate gap in reading
+	time.Sleep(200 * time.Millisecond)
+
+	// Resume reading - should get all items from second batch
+	for i, expectedItem := range secondBatch {
+		select {
+		case event := <-eventsCh:
+			if event.Index != expectedItem.idx {
+				t.Errorf("Second batch event %d: expected index %d, got %d", i, expectedItem.idx, event.Index)
+			}
+			if !bytes.Equal(event.Data, expectedItem.data) {
+				t.Errorf("Second batch event %d: expected data '%s', got '%s'", i, expectedItem.data, event.Data)
+			}
+		case <-time.After(1 * time.Second):
+			t.Fatalf("Timed out waiting for second batch event %d", i)
+		}
+	}
+}
+
+// Test_Events_ConcurrentReaders tests multiple goroutines reading from the same Events channel.
+func Test_Events_ConcurrentReaders(t *testing.T) {
+	q, _, cleanup := newTestQueue(t)
+	defer cleanup()
+
+	eventsCh := q.Events()
+	const numReaders = 3
+	const numItemsPerReader = 5
+
+	// Channel to collect events from all readers
+	allEvents := make(chan Event, numReaders*numItemsPerReader)
+
+	// Start multiple readers
+	for i := 0; i < numReaders; i++ {
+		go func(readerID int) {
+			for {
+				select {
+				case event, ok := <-eventsCh:
+					if !ok {
+						return // Channel closed
+					}
+					allEvents <- event
+				case <-time.After(2 * time.Second):
+					return // Timeout
+				}
+			}
+		}(i)
+	}
+
+	// Enqueue items
+	const totalItems = numReaders * numItemsPerReader
+	for i := 1; i <= totalItems; i++ {
+		item := []byte(fmt.Sprintf("concurrent-item-%d", i))
+		if err := q.Enqueue(uint64(i), item); err != nil {
+			t.Fatalf("Enqueue failed for index %d: %v", i, err)
+		}
+	}
+
+	// Collect all events
+	receivedEvents := make([]Event, 0, totalItems)
+	for i := 0; i < totalItems; i++ {
+		select {
+		case event := <-allEvents:
+			receivedEvents = append(receivedEvents, event)
+		case <-time.After(2 * time.Second):
+			t.Fatalf("Timed out waiting for event %d", i+1)
+		}
+	}
+
+	// Verify we received all events
+	if len(receivedEvents) != totalItems {
+		t.Errorf("Expected %d events, got %d", totalItems, len(receivedEvents))
+	}
+
+	// Create a map to track which events we received
+	receivedIndices := make(map[uint64]bool)
+	for _, event := range receivedEvents {
+		receivedIndices[event.Index] = true
+	}
+
+	// Verify all indices from 1 to totalItems were received
+	for i := uint64(1); i <= totalItems; i++ {
+		if !receivedIndices[i] {
+			t.Errorf("Missing event with index %d", i)
+		}
+	}
+}
+
 // newTestQueue is a helper function that creates a new Queue for testing.
 // It creates a temporary file for the bbolt database and returns the Queue,
 // the path to the database file, and a cleanup function to be called with defer.