all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+40 -39
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -25,7 +26,7 @@ func newRabbitPublisher(url string) *rabbitPublisher {
}
func (p *rabbitPublisher) connect() error {
conn, err := amqp.Dial(p.url)
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
if err != nil {
return err
}
@@ -91,50 +92,50 @@ func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
}
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
// done.
// done. The connection is reconnecting — on broker blips the caller re-declares
// the queue and re-consumes automatically.
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
conn, err := amqp.Dial(amqpURL)
conn, err := rabbit.Dial(amqpURL, "cart-order-ingest")
if err != nil {
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
return
}
ch, err := conn.Channel()
if err != nil {
s.logger.Warn("order ingest disabled: channel failed", "err", err)
_ = conn.Close()
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest disabled: consume failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
s.logger.Info("ingesting orders from order-queue")
go func() {
defer conn.Close()
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case d, ok := <-msgs:
if !ok {
s.logger.Warn("order ingest: channel closed")
// Reconnecting consumer: re-declare queue and re-consume on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
s.logger.Warn("order ingest: channel on reconnect", "err", err)
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
_ = ch.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: consume on reconnect", "err", err)
_ = ch.Close()
return
}
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
go func() {
defer ch.Close()
for {
select {
case <-ctx.Done():
return
}
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
s.logger.Error("order queue ingest failed", "err", err)
case d, ok := <-msgs:
if !ok {
s.logger.Warn("order ingest: channel closed (will reconnect)")
return
}
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
s.logger.Error("order queue ingest failed", "err", err)
}
}
}
}
}()
}()
})
}
+80 -10
View File
@@ -66,9 +66,33 @@ func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderI
return id, g, true
}
// checkIdempotency checks if the request is an idempotent retry.
// It returns (idemKey, exists, unlockFn).
// If exists is true, the caller should write the current order grain status and return immediately.
// If exists is false, the caller must defer unlockFn() and continue.
func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) {
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
return "", false, func() {}
}
unlock := s.idem.Lock(idemKey)
if _, ok := s.idem.Get(idemKey); ok {
unlock() // Release lock immediately since we are returning cached response
s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String())
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return idemKey, true, nil
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
return idemKey, true, nil
}
return idemKey, false, unlock
}
// applyAndRespond applies a mutation and returns the updated order. A rejected
// state transition (handler error) maps to 409 Conflict.
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message, idemKey string) {
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -80,6 +104,11 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde
return
}
}
if idemKey != "" {
if err := s.idem.Put(idemKey, uint64(id)); err != nil {
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
}
}
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -105,6 +134,12 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req fulfillReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -182,7 +217,7 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
type exchangeReq struct {
@@ -208,6 +243,12 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -236,7 +277,7 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
TotalTax: l.TotalTax,
})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
type editDetailsReq struct {
@@ -250,6 +291,12 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req editDetailsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -261,16 +308,21 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
ShippingPrice: req.ShippingPrice,
AtMs: nowMs(),
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey)
}
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
@@ -278,11 +330,17 @@ func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}, idemKey)
}
type returnReq struct {
@@ -298,6 +356,12 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req returnReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -308,7 +372,7 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
@@ -316,13 +380,19 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Amount int64 `json:"amount"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
}
captureRef := capturedRef(g)
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
@@ -335,7 +405,7 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
})
}, idemKey)
}
// --- saga / flow admin (a) ------------------------------------------------
+3 -1
View File
@@ -10,6 +10,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
)
// fromCheckoutReq is the payload the checkout service sends to create an order
@@ -164,7 +165,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
@@ -176,6 +177,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
Location: l.Location,
})
}
po.TotalAmount = total
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func TestLifecycleIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// ── First refund request (with idempotency key) ────────────────────────
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.Header.Set("Idempotency-Key", "refund-key-123")
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
g1, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
if g1.RefundedAmount != 5000 {
t.Fatalf("refunded amount after first call = %d, want 5000", g1.RefundedAmount)
}
// ── Second refund request (with the same idempotency key) ──────────────
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.Header.Set("Idempotency-Key", "refund-key-123")
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// It should NOT have refunded another 5000 (total refunded remains 5000)
if g2.RefundedAmount != 5000 {
t.Fatalf("refunded amount after second call = %d, want 5000 (idempotency hit failed, did double refund)", g2.RefundedAmount)
}
}
func TestLifecycleWithoutIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// First refund request (no idempotency key)
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
// Second refund request (no idempotency key)
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// Without idempotency, it SHOULD have refunded twice (total 10000)
if g2.RefundedAmount != 10000 {
t.Fatalf("refunded amount after second call without idempotency = %d, want 10000", g2.RefundedAmount)
}
}
+39 -10
View File
@@ -28,6 +28,8 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
@@ -42,7 +44,7 @@ type server struct {
freg *flow.Registry
flows *flowStore
provider order.PaymentProvider
taxProvider order.TaxProvider
taxProvider tax.Provider
defaultFlow string
dataDir string
idem *idempotency.Store
@@ -112,11 +114,37 @@ func main() {
}
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
emitPub = box
// Stream applied order mutations to the shared mutations exchange (routing
// key mutation.order) for the backoffice live feed. Best-effort, separate
// from the durable outbox above.
if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil {
logger.Warn("order: mutation feed disabled", "err", derr)
} else {
feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary)
feed.DefineTopics()
pool.AddListener(feed)
// Declare the "order" topic exchange the outbox relay publishes
// order.created to, so a publish can't hit a missing exchange before
// a consumer declares it. Durable + idempotent.
if ch, cerr := conn.Channel(); cerr != nil {
logger.Warn("order: declare order exchange: channel", "err", cerr)
} else {
if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil {
logger.Warn("order: declare order exchange", "err", eerr)
}
_ = ch.Close()
}
}
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
// order.created domain event → durable outbox → "order" topic exchange.
// Reactors: inventory commit, fulfillment allocation.
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
@@ -248,8 +276,9 @@ type lineReq struct {
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // percent
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
Location string `json:"location,omitempty"` // inventory commit location / store id
}
type checkoutReq struct {
@@ -343,7 +372,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
@@ -442,8 +471,8 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount,
CapturedAmount: g.CapturedAmount,
TotalAmount: g.TotalAmount.Int64(),
CapturedAmount: g.CapturedAmount.Int64(),
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
@@ -457,15 +486,15 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
// NordicTaxProvider with SE as the default country (matching the current
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
// (no country awareness).
func selectTaxProvider() order.TaxProvider {
func selectTaxProvider() tax.Provider {
provider := os.Getenv("TAX_PROVIDER")
switch provider {
case "static":
return order.NewStaticTaxProvider()
return tax.NewStatic()
case "nordic":
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
default:
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
}
}