This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const defaultBuffer = 64
|
||||
|
||||
// Publisher is the site-facing write side of the bus.
|
||||
type Publisher interface {
|
||||
Publish(ctx context.Context, ev any)
|
||||
}
|
||||
|
||||
// Bus is an in-process pub/sub with one worker and a bounded queue.
|
||||
type Bus struct {
|
||||
ch chan any
|
||||
mu sync.Mutex
|
||||
subs []func(context.Context, any)
|
||||
closed sync.Once
|
||||
}
|
||||
|
||||
// New starts a worker that delivers events to subscribers in publish order.
|
||||
func New() *Bus {
|
||||
return newBus(defaultBuffer, true)
|
||||
}
|
||||
|
||||
func newBus(buffer int, start bool) *Bus {
|
||||
if buffer < 1 {
|
||||
buffer = 1
|
||||
}
|
||||
b := &Bus{ch: make(chan any, buffer)}
|
||||
if start {
|
||||
go b.loop()
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Publish enqueues ev. It never blocks the caller; a full buffer is dropped.
|
||||
func (b *Bus) Publish(_ context.Context, ev any) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case b.ch <- ev:
|
||||
default:
|
||||
log.Printf("events: dropped %T", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a handler. Handlers run serially on the worker.
|
||||
func (b *Bus) Subscribe(fn func(context.Context, any)) {
|
||||
if b == nil || fn == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.subs = append(b.subs, fn)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bus) loop() {
|
||||
for ev := range b.ch {
|
||||
b.mu.Lock()
|
||||
subs := append([]func(context.Context, any){}, b.subs...)
|
||||
b.mu.Unlock()
|
||||
for _, fn := range subs {
|
||||
fn(context.Background(), ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the worker. Safe to call more than once.
|
||||
func (b *Bus) Close() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.closed.Do(func() { close(b.ch) })
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPermalink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
base, root, post, want string
|
||||
}{
|
||||
{"", "root-1", "post-2", "/questions/root-1#post-post-2"},
|
||||
{"https://www.askaplumberfirst.com/", "root-1", "post-2", "https://www.askaplumberfirst.com/questions/root-1#post-post-2"},
|
||||
{"https://www.askaplumberfirst.com", "a b", "c/d", "https://www.askaplumberfirst.com/questions/a%20b#post-c%2Fd"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := Permalink(tc.base, tc.root, tc.post); got != tc.want {
|
||||
t.Fatalf("Permalink(%q, %q, %q) = %q, want %q", tc.base, tc.root, tc.post, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNopAndRecording(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Nop{}.Publish(context.Background(), PostCreated{})
|
||||
|
||||
rec := &Recording{}
|
||||
rec.Publish(context.Background(), PostCreated{PostEvent: PostEvent{PostID: "a"}})
|
||||
rec.Publish(context.Background(), PostUpdated{PostEvent: PostEvent{PostID: "b"}})
|
||||
if rec.Len() != 2 {
|
||||
t.Fatalf("len = %d", rec.Len())
|
||||
}
|
||||
got := rec.Snapshot()
|
||||
created, ok := got[0].(PostCreated)
|
||||
if !ok || created.PostID != "a" {
|
||||
t.Fatalf("first = %#v", got[0])
|
||||
}
|
||||
updated, ok := got[1].(PostUpdated)
|
||||
if !ok || updated.PostID != "b" {
|
||||
t.Fatalf("second = %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusDeliversInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bus := New()
|
||||
defer bus.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
done := make(chan struct{})
|
||||
bus.Subscribe(func(_ context.Context, ev any) {
|
||||
mu.Lock()
|
||||
got = append(got, ev.(string))
|
||||
if len(got) == 3 {
|
||||
close(done)
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
bus.Publish(ctx, "one")
|
||||
bus.Publish(ctx, "two")
|
||||
bus.Publish(ctx, "three")
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for events")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 3 || got[0] != "one" || got[1] != "two" || got[2] != "three" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusDropsWhenFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bus := newBus(1, false)
|
||||
bus.Publish(context.Background(), "kept")
|
||||
bus.Publish(context.Background(), "dropped")
|
||||
|
||||
select {
|
||||
case ev := <-bus.ch:
|
||||
if ev != "kept" {
|
||||
t.Fatalf("got %v", ev)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected buffered event")
|
||||
}
|
||||
select {
|
||||
case ev := <-bus.ch:
|
||||
t.Fatalf("unexpected extra event %v", ev)
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Image is a public photo already attached to a site post.
|
||||
type Image struct {
|
||||
URL string
|
||||
Description string
|
||||
}
|
||||
|
||||
// PostEvent is a Discord-free snapshot of a site post after a successful write.
|
||||
type PostEvent struct {
|
||||
PostID string
|
||||
RootID string
|
||||
ParentID string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
AuthorRole string
|
||||
Images []Image
|
||||
Permalink string
|
||||
}
|
||||
|
||||
// PostCreated is emitted after a successful site create.
|
||||
type PostCreated struct {
|
||||
PostEvent
|
||||
}
|
||||
|
||||
// PostUpdated is emitted after a successful site edit.
|
||||
type PostUpdated struct {
|
||||
PostEvent
|
||||
}
|
||||
|
||||
// Permalink builds /questions/{root}#post-{id}, prefixed by baseURL when set.
|
||||
func Permalink(baseURL, rootID, postID string) string {
|
||||
path := "/questions/" + url.PathEscape(rootID) + "#post-" + url.PathEscape(postID)
|
||||
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package events
|
||||
|
||||
import "context"
|
||||
|
||||
// Nop is a Publisher used when nothing is subscribed.
|
||||
type Nop struct{}
|
||||
|
||||
// Publish discards ev.
|
||||
func (Nop) Publish(context.Context, any) {}
|
||||
@@ -0,0 +1,44 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Recording is a test Publisher that records events synchronously.
|
||||
type Recording struct {
|
||||
mu sync.Mutex
|
||||
evs []any
|
||||
}
|
||||
|
||||
// Publish appends ev.
|
||||
func (r *Recording) Publish(_ context.Context, ev any) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.evs = append(r.evs, ev)
|
||||
}
|
||||
|
||||
// Len returns the number of recorded events.
|
||||
func (r *Recording) Len() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.evs)
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of recorded events.
|
||||
func (r *Recording) Snapshot() []any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]any, len(r.evs))
|
||||
copy(out, r.evs)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user