632 lines
15 KiB
Go
632 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/nats-io/nats.go"
|
|
)
|
|
|
|
//go:embed internal/web/templates/index.html
|
|
var templateFS embed.FS
|
|
|
|
//go:embed internal/web/static
|
|
var staticFS embed.FS
|
|
|
|
type Config struct {
|
|
HTTPAddr string
|
|
DatabaseURL string
|
|
NATSURL string
|
|
NATSSubject string
|
|
}
|
|
|
|
const maxInventoryItems = 4
|
|
|
|
type InventoryItem struct {
|
|
ID int `json:"id"`
|
|
SKU string `json:"sku"`
|
|
Name string `json:"name"`
|
|
Quantity int `json:"quantity"`
|
|
Location string `json:"location"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type AcceptedResponse struct {
|
|
Status string `json:"status"`
|
|
CRUD string `json:"crud"`
|
|
Command string `json:"command"`
|
|
}
|
|
|
|
type CDCEvent struct {
|
|
CRUD string `json:"crud"`
|
|
Op string `json:"op"`
|
|
Table string `json:"table"`
|
|
Before any `json:"before"`
|
|
After any `json:"after"`
|
|
Raw json.RawMessage `json:"raw"`
|
|
Inventory []InventoryItem `json:"inventory"`
|
|
Received time.Time `json:"received_at"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
type Server struct {
|
|
db *pgxpool.Pool
|
|
broker *Broker
|
|
templates *template.Template
|
|
}
|
|
|
|
type Broker struct {
|
|
mu sync.Mutex
|
|
clients map[chan CDCEvent]struct{}
|
|
last *CDCEvent
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
db, err := connectDB(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
log.Fatalf("connect postgres: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
tpl, err := template.ParseFS(templateFS, "internal/web/templates/index.html")
|
|
if err != nil {
|
|
log.Fatalf("parse templates: %v", err)
|
|
}
|
|
|
|
server := &Server{
|
|
db: db,
|
|
broker: NewBroker(),
|
|
templates: tpl,
|
|
}
|
|
|
|
go consumeNATS(ctx, cfg, db, server.broker)
|
|
|
|
mux := http.NewServeMux()
|
|
server.routes(mux)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.HTTPAddr,
|
|
Handler: logRequests(mux),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = httpServer.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
log.Printf("starting live inventory CDC demo on %s", cfg.HTTPAddr)
|
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("http server: %v", err)
|
|
}
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
return Config{
|
|
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
|
DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable"),
|
|
NATSURL: env("NATS_URL", "nats://localhost:4222"),
|
|
NATSSubject: env("NATS_SUBJECT", "postgres.public.inventory"),
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func connectDB(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
|
var lastErr error
|
|
for attempt := 1; attempt <= 30; attempt++ {
|
|
db, err := pgxpool.New(ctx, databaseURL)
|
|
if err == nil {
|
|
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
err = db.Ping(pingCtx)
|
|
cancel()
|
|
if err == nil {
|
|
return db, nil
|
|
}
|
|
db.Close()
|
|
}
|
|
lastErr = err
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (s *Server) routes(mux *http.ServeMux) {
|
|
static, err := fs.Sub(staticFS, "internal/web/static")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
|
|
mux.HandleFunc("GET /", s.handleIndex)
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /events", s.handleEvents)
|
|
mux.HandleFunc("GET /api/inventory", s.handleListInventory)
|
|
mux.HandleFunc("POST /api/inventory", s.handleCreateInventory)
|
|
mux.HandleFunc("PATCH /api/inventory/{id}", s.handleUpdateInventory)
|
|
mux.HandleFunc("PATCH /api/inventory/{id}/sell", s.handleSellInventory)
|
|
mux.HandleFunc("PATCH /api/inventory/{id}/restock", s.handleRestockInventory)
|
|
mux.HandleFunc("DELETE /api/inventory/{id}", s.handleDeleteInventory)
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
data := map[string]string{"Version": "dev"}
|
|
if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
client := s.broker.Subscribe()
|
|
defer s.broker.Unsubscribe(client)
|
|
|
|
if last := s.broker.Last(); last != nil {
|
|
writeSSE(w, *last)
|
|
flusher.Flush()
|
|
}
|
|
|
|
ticker := time.NewTicker(20 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case event := <-client:
|
|
writeSSE(w, event)
|
|
flusher.Flush()
|
|
case <-ticker.C:
|
|
_, _ = fmt.Fprint(w, ": keepalive\n\n")
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleListInventory(w http.ResponseWriter, r *http.Request) {
|
|
items, err := listInventory(r.Context(), s.db)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleCreateInventory(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
SKU string `json:"sku"`
|
|
Name string `json:"name"`
|
|
Quantity int `json:"quantity"`
|
|
Location string `json:"location"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
req.SKU = strings.TrimSpace(req.SKU)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Location = strings.TrimSpace(req.Location)
|
|
if req.SKU == "" || req.Name == "" || req.Location == "" {
|
|
writeError(w, http.StatusBadRequest, errors.New("sku, name, and location are required"))
|
|
return
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{IsoLevel: pgx.Serializable})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
if _, err := tx.Exec(r.Context(), `lock table inventory in exclusive mode`); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRow(r.Context(), `select count(*) from inventory`).Scan(&count); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if count >= maxInventoryItems {
|
|
writeError(w, http.StatusConflict, fmt.Errorf("inventory is limited to %d products", maxInventoryItems))
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(r.Context(), `
|
|
insert into inventory (sku, name, quantity, location, updated_at)
|
|
values ($1, $2, $3, $4, now())`,
|
|
req.SKU, req.Name, req.Quantity, req.Location,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
|
Status: "command accepted",
|
|
CRUD: "CREATE",
|
|
Command: fmt.Sprintf("CREATE inventory %s", req.SKU),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSellInventory(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(r.Context(), `
|
|
update inventory
|
|
set quantity = greatest(quantity - 1, 0), updated_at = now()
|
|
where id = $1`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
|
Status: "command accepted",
|
|
CRUD: "UPDATE",
|
|
Command: "UPDATE inventory quantity -1",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRestockInventory(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Amount int `json:"amount"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.Amount <= 0 {
|
|
req.Amount = 5
|
|
}
|
|
_, err := s.db.Exec(r.Context(), `
|
|
update inventory
|
|
set quantity = quantity + $2, updated_at = now()
|
|
where id = $1`,
|
|
id, req.Amount,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
|
Status: "command accepted",
|
|
CRUD: "UPDATE",
|
|
Command: fmt.Sprintf("UPDATE inventory quantity +%d", req.Amount),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUpdateInventory(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
Location *string `json:"location"`
|
|
Quantity *int `json:"quantity"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
current := InventoryItem{}
|
|
err = tx.QueryRow(r.Context(), `
|
|
select id, sku, name, quantity, location, updated_at
|
|
from inventory
|
|
where id = $1
|
|
for update`, id,
|
|
).Scan(¤t.ID, ¤t.SKU, ¤t.Name, ¤t.Quantity, ¤t.Location, ¤t.UpdatedAt)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
|
|
if req.Name != nil && strings.TrimSpace(*req.Name) != "" {
|
|
current.Name = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.Location != nil && strings.TrimSpace(*req.Location) != "" {
|
|
current.Location = strings.TrimSpace(*req.Location)
|
|
}
|
|
if req.Quantity != nil {
|
|
current.Quantity = *req.Quantity
|
|
}
|
|
|
|
_, err = tx.Exec(r.Context(), `
|
|
update inventory
|
|
set name = $2, quantity = $3, location = $4, updated_at = now()
|
|
where id = $1`,
|
|
current.ID, current.Name, current.Quantity, current.Location,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
|
Status: "command accepted",
|
|
CRUD: "UPDATE",
|
|
Command: fmt.Sprintf("UPDATE inventory %s", current.SKU),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDeleteInventory(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(r.Context(), `delete from inventory where id = $1`, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
|
Status: "command accepted",
|
|
CRUD: "DELETE",
|
|
Command: fmt.Sprintf("DELETE inventory id=%d", id),
|
|
})
|
|
}
|
|
|
|
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
|
|
id, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil || id <= 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("valid numeric id is required"))
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func consumeNATS(ctx context.Context, cfg Config, db *pgxpool.Pool, broker *Broker) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
nc, err := nats.Connect(
|
|
cfg.NATSURL,
|
|
nats.Name("live-inventory-cdc-demo"),
|
|
nats.RetryOnFailedConnect(true),
|
|
nats.MaxReconnects(-1),
|
|
nats.ReconnectWait(2*time.Second),
|
|
)
|
|
if err != nil {
|
|
log.Printf("connect nats: %v", err)
|
|
sleepOrDone(ctx, 2*time.Second)
|
|
continue
|
|
}
|
|
|
|
sub, err := nc.Subscribe(cfg.NATSSubject, func(msg *nats.Msg) {
|
|
event, err := normalizeCDC(ctx, db, msg.Data)
|
|
if err != nil {
|
|
log.Printf("normalize cdc event: %v", err)
|
|
return
|
|
}
|
|
broker.Publish(event)
|
|
})
|
|
if err != nil {
|
|
log.Printf("subscribe nats: %v", err)
|
|
nc.Close()
|
|
sleepOrDone(ctx, 2*time.Second)
|
|
continue
|
|
}
|
|
log.Printf("subscribed to nats subject %s", cfg.NATSSubject)
|
|
|
|
<-ctx.Done()
|
|
_ = sub.Unsubscribe()
|
|
nc.Close()
|
|
return
|
|
}
|
|
}
|
|
|
|
func normalizeCDC(ctx context.Context, db *pgxpool.Pool, payload []byte) (CDCEvent, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
|
return CDCEvent{}, err
|
|
}
|
|
|
|
op, _ := raw["op"].(string)
|
|
table := ""
|
|
if source, ok := raw["source"].(map[string]any); ok {
|
|
table, _ = source["table"].(string)
|
|
}
|
|
if table == "" {
|
|
table = "inventory"
|
|
}
|
|
|
|
items, err := listInventory(ctx, db)
|
|
if err != nil {
|
|
return CDCEvent{}, err
|
|
}
|
|
|
|
return CDCEvent{
|
|
CRUD: crudLabel(op),
|
|
Op: op,
|
|
Table: table,
|
|
Before: raw["before"],
|
|
After: raw["after"],
|
|
Raw: append(json.RawMessage(nil), payload...),
|
|
Inventory: items,
|
|
Received: time.Now().UTC(),
|
|
Source: "Debezium -> NATS JetStream -> SSE",
|
|
}, nil
|
|
}
|
|
|
|
func crudLabel(op string) string {
|
|
switch op {
|
|
case "c":
|
|
return "CREATE"
|
|
case "r":
|
|
return "SNAPSHOT"
|
|
case "u":
|
|
return "UPDATE"
|
|
case "d":
|
|
return "DELETE"
|
|
default:
|
|
return strings.ToUpper(op)
|
|
}
|
|
}
|
|
|
|
func listInventory(ctx context.Context, db *pgxpool.Pool) ([]InventoryItem, error) {
|
|
rows, err := db.Query(ctx, `
|
|
select id, sku, name, quantity, location, updated_at
|
|
from inventory
|
|
order by id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]InventoryItem, 0)
|
|
for rows.Next() {
|
|
var item InventoryItem
|
|
if err := rows.Scan(&item.ID, &item.SKU, &item.Name, &item.Quantity, &item.Location, &item.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func NewBroker() *Broker {
|
|
return &Broker{clients: make(map[chan CDCEvent]struct{})}
|
|
}
|
|
|
|
func (b *Broker) Subscribe() chan CDCEvent {
|
|
ch := make(chan CDCEvent, 8)
|
|
b.mu.Lock()
|
|
b.clients[ch] = struct{}{}
|
|
b.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
func (b *Broker) Unsubscribe(ch chan CDCEvent) {
|
|
b.mu.Lock()
|
|
delete(b.clients, ch)
|
|
close(ch)
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *Broker) Publish(event CDCEvent) {
|
|
b.mu.Lock()
|
|
b.last = &event
|
|
for ch := range b.clients {
|
|
select {
|
|
case ch <- event:
|
|
default:
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *Broker) Last() *CDCEvent {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.last == nil {
|
|
return nil
|
|
}
|
|
copy := *b.last
|
|
return ©
|
|
}
|
|
|
|
func writeSSE(w http.ResponseWriter, event CDCEvent) {
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintf(w, "event: cdc\n")
|
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, err error) {
|
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
func sleepOrDone(ctx context.Context, d time.Duration) {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(d):
|
|
}
|
|
}
|
|
|
|
func logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|