Files
2026-08-11 20:42:44 +01:00

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(&current.ID, &current.SKU, &current.Name, &current.Quantity, &current.Location, &current.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 &copy
}
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))
})
}