more checkout
This commit is contained in:
+36
-8
@@ -13,31 +13,59 @@ import (
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// rabbitPublisher implements order.Publisher for the amqp_emit flow hook.
|
||||
// rabbitPublisher is the broker-delivery side used by the outbox relay. It dials
|
||||
// lazily and reconnects on failure, so the relay can be started even when the
|
||||
// broker is down at boot: messages accumulate durably in the outbox and drain
|
||||
// once the broker is reachable.
|
||||
type rabbitPublisher struct {
|
||||
url string
|
||||
mu sync.Mutex
|
||||
conn *amqp.Connection
|
||||
ch *amqp.Channel
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newRabbitPublisher(url string) (*rabbitPublisher, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
func newRabbitPublisher(url string) *rabbitPublisher {
|
||||
return &rabbitPublisher{url: url}
|
||||
}
|
||||
|
||||
func (p *rabbitPublisher) connect() error {
|
||||
conn, err := amqp.Dial(p.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return &rabbitPublisher{conn: conn, ch: ch}, nil
|
||||
p.conn, p.ch = conn, ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *rabbitPublisher) reset() {
|
||||
if p.ch != nil {
|
||||
_ = p.ch.Close()
|
||||
}
|
||||
if p.conn != nil {
|
||||
_ = p.conn.Close()
|
||||
}
|
||||
p.ch, p.conn = nil, nil
|
||||
}
|
||||
|
||||
func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.ch.PublishWithContext(context.Background(), exchange, routingKey, false, false,
|
||||
if p.ch == nil {
|
||||
if err := p.connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := p.ch.PublishWithContext(context.Background(), exchange, routingKey, false, false,
|
||||
amqp.Publishing{ContentType: "application/json", Body: body})
|
||||
if err != nil {
|
||||
p.reset() // force a reconnect on the next attempt
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// This ingester lets the order service supersede the legacy go-order-manager:
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
@@ -38,14 +37,6 @@ type fromCheckoutReq struct {
|
||||
} `json:"payment"`
|
||||
}
|
||||
|
||||
// idempotencyStore guards against duplicate order creation for the same
|
||||
// idempotency key. In-memory for step 1; a durable store (Redis, bolt) would
|
||||
// survive restarts. The map is keyed by idempotencyKey → orderId.
|
||||
var (
|
||||
idempotencyMu sync.Mutex
|
||||
idempotencyKeys = map[string]uint64{}
|
||||
)
|
||||
|
||||
// handleFromCheckout creates an event-sourced order from a settled checkout.
|
||||
// It runs the place-and-pay flow with a passthrough provider that records the
|
||||
// already-completed authorization and capture, bypassing any external payment
|
||||
@@ -65,10 +56,13 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ── Idempotency check ─────────────────────────────────────────────
|
||||
// Lock the key for the whole check→create→record sequence so a concurrent
|
||||
// retry with the same key can't race past the check and create a second
|
||||
// order. The store is durable, so this also holds across a restart.
|
||||
if req.IdempotencyKey != "" {
|
||||
idempotencyMu.Lock()
|
||||
if existingID, ok := idempotencyKeys[req.IdempotencyKey]; ok {
|
||||
idempotencyMu.Unlock()
|
||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||
defer unlock()
|
||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||
// The order already exists — return it.
|
||||
g, err := s.applier.Get(r.Context(), existingID)
|
||||
if err != nil {
|
||||
@@ -76,13 +70,12 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(existingID).String(),
|
||||
"order": g,
|
||||
"orderId": order.OrderId(existingID).String(),
|
||||
"order": g,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
idempotencyMu.Unlock()
|
||||
}
|
||||
|
||||
// ── Create the order grain ────────────────────────────────────────
|
||||
@@ -141,11 +134,14 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
||||
}
|
||||
|
||||
// Record the idempotency key so retries are safe.
|
||||
// Record the idempotency key durably so retries (incl. after a restart) are
|
||||
// safe. We record even on a failed flow: the failed order grain exists, and a
|
||||
// blind retry with the same key should return it rather than place a second
|
||||
// order — the caller is told to retry with a fresh key (402 contract).
|
||||
if req.IdempotencyKey != "" {
|
||||
idempotencyMu.Lock()
|
||||
idempotencyKeys[req.IdempotencyKey] = ordID
|
||||
idempotencyMu.Unlock()
|
||||
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
||||
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
|
||||
+20
-4
@@ -21,7 +21,9 @@ import (
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
@@ -39,6 +41,7 @@ type server struct {
|
||||
provider order.PaymentProvider
|
||||
defaultFlow string
|
||||
dataDir string
|
||||
idem *idempotency.Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -94,10 +97,15 @@ func main() {
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
var emitPub order.Publisher
|
||||
if amqpURL != "" {
|
||||
if pub, err := newRabbitPublisher(amqpURL); err != nil {
|
||||
logger.Warn("amqp_emit hook: publisher unavailable", "err", err)
|
||||
// The amqp_emit hook writes to a durable outbox rather than the broker
|
||||
// directly; a relay goroutine drains it to RabbitMQ (reconnecting), so an
|
||||
// event survives a broker outage or a crash after the state change.
|
||||
box, err := outbox.Open(filepath.Join(dataDir, "outbox.log"))
|
||||
if err != nil {
|
||||
logger.Warn("outbox disabled: open failed", "err", err)
|
||||
} else {
|
||||
emitPub = pub
|
||||
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
|
||||
emitPub = box
|
||||
}
|
||||
}
|
||||
freg := flow.NewRegistry()
|
||||
@@ -115,10 +123,18 @@ func main() {
|
||||
log.Fatalf("load flows: %v", err)
|
||||
}
|
||||
|
||||
// Durable idempotency store for order creation — survives a restart, so a
|
||||
// client retry after a crash returns the existing order instead of creating
|
||||
// a duplicate.
|
||||
idem, err := idempotency.Open(filepath.Join(dataDir, "idempotency.log"))
|
||||
if err != nil {
|
||||
log.Fatalf("open idempotency store: %v", err)
|
||||
}
|
||||
|
||||
s := &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||
defaultFlow: def.Name, dataDir: dataDir, logger: logger,
|
||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
Reference in New Issue
Block a user