Added an events bus, and pushed OpportunitiesUpdated to WebSocket clients when the fetcher stores new opportunities
internal/events decouples the parts of JobSearch that cause events from
the parts that react to them. bus.go has Bus: Subscribe, and a synchronous
Publish that calls each handler in turn on the publisher's goroutine, so
handlers mustn't block (the hub doesn't: it queues per client). events.go
has the event types and their payloads, starting with OpportunitiesUpdated.
With fetcher.WithEvents, a save that stores new opportunities, or new
releases of stored ones, publishes OpportunitiesUpdated with their UUIDs and
counts. An opportunity whose row is only rewritten, as when it is fetched
again from the newest stored release, isn't counted as updated, and nothing
is published when nothing changed. store.SaveResult now reports
NewOpportunity for each opportunity saved.
job-search serve subscribes its WebSocket hub to the bus, so every client
gets {"type":"event","eventType":"OpportunitiesUpdated","payload":{"new":
[...],"updated":[...],"summary":{"new":n,"updated":n}}}. The Envelope gains
an eventType field, and Hub.PushEvent and Server.PushEvent send events.
This commit is contained in:
1 parent
284a589627
commit
6e101d2b4d
13 files changed
+448
-26
No files matched your search
@@ -0,0 +1,40 @@
|
||||
package events
|
||||
|
||||
import "sync"
|
||||
|
||||
// Bus passes each published Event to every subscriber. Its zero value is
|
||||
// ready to use, and it is safe for concurrent use.
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
handlers []func(Event)
|
||||
}
|
||||
|
||||
// Event is something that happened, for subscribers to react to. Type names
|
||||
// it (e.g. OpportunitiesUpdated) and says what Payload is.
|
||||
type Event struct {
|
||||
Type string
|
||||
Payload any
|
||||
}
|
||||
|
||||
// Subscribe makes handler receive every event published from now on.
|
||||
// Subscribe before anything publishes, so no event is missed.
|
||||
func (b *Bus) Subscribe(handler func(Event)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.handlers = append(b.handlers, handler)
|
||||
}
|
||||
|
||||
// Publish passes event to every subscriber in turn, in the order they
|
||||
// subscribed, and returns once they all have. Handlers are called on the
|
||||
// publisher's goroutine, so they mustn't block; one with slow work to do
|
||||
// should queue it. Publish may be called from several goroutines at once.
|
||||
func (b *Bus) Publish(event Event) {
|
||||
b.mu.RLock()
|
||||
handlers := b.handlers
|
||||
b.mu.RUnlock()
|
||||
|
||||
// Called outside the lock, so a handler may itself subscribe or publish.
|
||||
for _, handler := range handlers {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every subscriber gets every event, in the order they subscribed.
|
||||
func TestPublish(t *testing.T) {
|
||||
var bus Bus
|
||||
var got []string
|
||||
for _, name := range []string{"first", "second"} {
|
||||
bus.Subscribe(func(e Event) {
|
||||
got = append(got, name+":"+e.Type)
|
||||
})
|
||||
}
|
||||
|
||||
bus.Publish(Event{
|
||||
Type: "a",
|
||||
})
|
||||
bus.Publish(Event{
|
||||
Type: "b",
|
||||
})
|
||||
|
||||
want := []string{"first:a", "second:a", "first:b", "second:b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v; want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publishing with no subscribers does nothing.
|
||||
func TestPublishNoSubscribers(t *testing.T) {
|
||||
var bus Bus
|
||||
bus.Publish(Event{
|
||||
Type: OpportunitiesUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
// A handler may subscribe or publish without deadlocking.
|
||||
func TestHandlerReenters(t *testing.T) {
|
||||
var bus Bus
|
||||
var inner atomic.Int32
|
||||
bus.Subscribe(func(e Event) {
|
||||
if e.Type == "outer" {
|
||||
bus.Subscribe(func(Event) {
|
||||
inner.Add(1)
|
||||
})
|
||||
bus.Publish(Event{
|
||||
Type: "inner",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
bus.Publish(Event{
|
||||
Type: "outer",
|
||||
})
|
||||
if inner.Load() != 1 {
|
||||
t.Errorf("inner handler ran %d times; want 1", inner.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Publish and Subscribe are safe from several goroutines at once (run with
|
||||
// -race).
|
||||
func TestConcurrent(t *testing.T) {
|
||||
var bus Bus
|
||||
var count atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
bus.Subscribe(func(Event) {
|
||||
count.Add(1)
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
bus.Publish(Event{
|
||||
Type: "x",
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package events passes events between the parts of JobSearch that cause
|
||||
// them, such as the fetcher, and the parts that react to them, such as the
|
||||
// WebSocket hub, so neither depends on the other. It holds the event types
|
||||
// and their payloads.
|
||||
package events
|
||||
|
||||
// OpportunitiesUpdated is published when a save stores new opportunities or
|
||||
// new releases of stored ones. Its payload is an OpportunitiesUpdatedPayload.
|
||||
const OpportunitiesUpdated = "OpportunitiesUpdated"
|
||||
|
||||
// ---------- OpportunitiesUpdated ----------
|
||||
|
||||
// OpportunitiesUpdatedPayload lists the opportunities a save changed by UUID:
|
||||
// New were stored for the first time, and Updated were already stored and
|
||||
// gained at least one new release. Summary counts them.
|
||||
type OpportunitiesUpdatedPayload struct {
|
||||
New []string `json:"new"`
|
||||
Updated []string `json:"updated"`
|
||||
Summary OpportunitiesSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// OpportunitiesSummary counts the opportunities in an
|
||||
// OpportunitiesUpdatedPayload.
|
||||
type OpportunitiesSummary struct {
|
||||
New int `json:"new"`
|
||||
Updated int `json:"updated"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The payload marshals with the keys clients read, and empty lists as [].
|
||||
func TestOpportunitiesUpdatedPayloadJSON(t *testing.T) {
|
||||
payload := OpportunitiesUpdatedPayload{
|
||||
New: []string{"ocds-1"},
|
||||
Updated: []string{},
|
||||
Summary: OpportunitiesSummary{
|
||||
New: 1,
|
||||
},
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"new":["ocds-1"],"updated":[],"summary":{"new":1,"updated":0}}`
|
||||
if string(encoded) != want {
|
||||
t.Errorf("JSON = %s; want %s", encoded, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user