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:
bwilliams committed 2026-10-16 11:59:26 +01:00
1 parent 284a589627
commit 6e101d2b4d
13 files changed
+448 -26

No files matched your search

+55 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
"git.avsdev.uk/Go/job-search/internal/util"
@@ -37,6 +38,15 @@ func WithStore(opportunityStore *store.Store) Option {
}
}
// WithEvents makes the workers publish an events.OpportunitiesUpdated event
// to bus whenever a save stores new opportunities or new releases. It needs
// WithStore: without a store nothing is saved, so nothing is published.
func WithEvents(bus *events.Bus) Option {
return func(f *Fetcher) {
f.events = bus
}
}
// saveFunc saves the opportunities a worker fetched; see Fetcher.save.
type saveFunc func(
ctx context.Context,
@@ -66,6 +76,7 @@ type Fetcher struct {
conf *config.JSCSources
log *slog.Logger
store *store.Store // nil: nothing is persisted
events *events.Bus // nil: nothing is published
started time.Time
// backgroundCtx is the context the workers run under. backgroundCancel
@@ -199,9 +210,10 @@ func (f *Fetcher) startFrom(
return latest, nil
}
// save saves opportunities to the store given WithStore, and logs what
// changed. Without a store it does nothing. A failure fails the worker's run,
// so its checkpoint isn't moved on and the next run fetches them again.
// save saves opportunities to the store given WithStore, logs what changed,
// and publishes it (see WithEvents). Without a store it does nothing. A
// failure fails the worker's run, so its checkpoint isn't moved on and the
// next run fetches them again.
func (f *Fetcher) save(
ctx context.Context,
logger *slog.Logger,
@@ -223,5 +235,45 @@ func (f *Fetcher) save(
"new_releases", result.NewReleases,
)
f.publishUpdates(opportunities, result.OpportunityResults)
return nil
}
// publishUpdates publishes an events.OpportunitiesUpdated event for the
// opportunities that results, from saving them, show were new or gained new
// releases, if there were any and a bus was given WithEvents. An opportunity
// whose row was only rewritten, such as one fetched again from the newest
// stored release, isn't counted as updated.
func (f *Fetcher) publishUpdates(
opportunities []*types.Opportunity, results []store.SaveResult,
) {
if f.events == nil {
return
}
payload := events.OpportunitiesUpdatedPayload{
New: []string{},
Updated: []string{},
}
for i, result := range results {
switch {
case result.NewOpportunity:
payload.New = append(payload.New, opportunities[i].UUID)
case result.NewReleases > 0:
payload.Updated = append(payload.Updated, opportunities[i].UUID)
}
}
payload.Summary = events.OpportunitiesSummary{
New: len(payload.New),
Updated: len(payload.Updated),
}
if payload.Summary.New == 0 && payload.Summary.Updated == 0 {
return
}
f.events.Publish(events.Event{
Type: events.OpportunitiesUpdated,
Payload: payload,
})
}
+78
View File
@@ -9,12 +9,14 @@ import (
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
"git.avsdev.uk/Go/job-search/internal/util"
@@ -659,3 +661,79 @@ func checkAboutADayAgo(t *testing.T, updatedFrom string) {
t.Errorf("updatedFrom = %s; want about a day ago", updatedFrom)
}
}
// fetched returns the opportunities converted from body, a release package.
func fetched(t *testing.T, body string) []*types.Opportunity {
t.Helper()
srv, _ := ftsServer(t, http.StatusOK, body)
opportunities, err := fetchers["FindATender"](
context.Background(), srv.URL, time.Now().Add(-time.Hour),
)
if err != nil {
t.Fatal(err)
}
return opportunities
}
// A save publishes the new opportunities and those with new releases, and
// nothing when an opportunity is only fetched again.
func TestSavePublishesUpdates(t *testing.T) {
var bus events.Bus
var published []events.OpportunitiesUpdatedPayload
bus.Subscribe(func(event events.Event) {
if event.Type != events.OpportunitiesUpdated {
t.Errorf("event type = %q", event.Type)
}
payload := event.Payload.(events.OpportunitiesUpdatedPayload)
published = append(published, payload)
})
f := New(
&config.JSCSources{},
WithStore(openTestStore(t)),
WithEvents(&bus),
)
save := func(body string) {
t.Helper()
err := f.save(context.Background(), slog.Default(), fetched(t, body))
if err != nil {
t.Fatal(err)
}
}
newRelease := strings.NewReplacer(
`"id": "r1"`, `"id": "r2"`,
"2026-10-01T12:00:00Z", "2026-10-02T12:00:00Z",
).Replace(onePackage)
save(onePackage) // new
save(onePackage) // fetched again: nothing published
save(newRelease) // a new release of a stored opportunity
want := []events.OpportunitiesUpdatedPayload{
{
New: []string{"ocds-1"},
Updated: []string{},
Summary: events.OpportunitiesSummary{
New: 1,
},
},
{
New: []string{},
Updated: []string{"ocds-1"},
Summary: events.OpportunitiesSummary{
Updated: 1,
},
},
}
if !reflect.DeepEqual(published, want) {
t.Errorf("published %+v; want %+v", published, want)
}
}
// Without WithEvents, a save publishes nothing and still succeeds.
func TestSaveWithoutEvents(t *testing.T) {
f := New(&config.JSCSources{}, WithStore(openTestStore(t)))
err := f.save(context.Background(), slog.Default(), fetched(t, onePackage))
if err != nil {
t.Fatal(err)
}
}