88 Commits

Author SHA1 Message Date
matst80
5e36af2524 wip 2025-12-04 22:09:26 +01:00
matst80
d78685cd8f checkout backoffice
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 21:06:56 +01:00
matst80
f67eeb3c49 major changes
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 20:56:54 +01:00
matst80
6d5358b53b less ping logs
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 19:08:47 +01:00
matst80
d0d604f5f0 strange
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 19:07:15 +01:00
matst80
2b8e692526 better adyen handling
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-04 18:58:10 +01:00
matst80
c842571dbc fix
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:49:30 +01:00
matst80
bd194f2bb8 maybe fix adyen error
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:48:24 +01:00
matst80
94d478ee6a ahh
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:31:51 +01:00
matst80
e9fb00d5c7 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m38s
2025-12-04 18:12:50 +01:00
matst80
3d9d703a76 better
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:08:34 +01:00
matst80
9a61b088c9 s
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-04 18:02:42 +01:00
matst80
3becd1c80c fix
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-12-04 18:02:06 +01:00
matst80
dcc7cc2dd0 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 17:51:00 +01:00
matst80
ce81e294f2 test
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m52s
2025-12-04 17:42:51 +01:00
matst80
fccb0eed06 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-04 17:14:25 +01:00
matst80
978ef3025d test
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-12-04 17:08:31 +01:00
matst80
0ef29596c1 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-04 14:19:00 +01:00
matst80
fbc773dbca more logs
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-12-04 10:35:23 +01:00
matst80
5037c07a47 update
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 10:31:11 +01:00
matst80
e9189364ab handle events
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-04 09:34:49 +01:00
matst80
445858c1e3 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
2025-12-04 09:20:30 +01:00
matst80
82b64ee8d4 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 6m18s
2025-12-04 08:51:59 +01:00
7606c1f9df update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-12-03 23:12:40 +01:00
0e1f9b0d3c update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-12-03 23:04:37 +01:00
95d8e0ffb5 add adyen part sessions
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-03 22:56:30 +01:00
c61fe1bb7b update payment on completed
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-12-03 22:38:14 +01:00
3af837d824 load prev state
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-03 22:30:13 +01:00
401286c56a update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 22:06:24 +01:00
bb4ed5eb23 fix
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-03 21:05:49 +01:00
f828f4809b update stuff
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m15s
2025-12-03 20:54:32 +01:00
825630a91a allow some updates
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-12-03 20:19:37 +01:00
78bc05025d update json
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-03 20:12:41 +01:00
5ad7131df2 clear cookie if failing
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 19:51:22 +01:00
6321ad76a1 update endpoints
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m47s
2025-12-03 19:29:33 +01:00
94a04a154c update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m33s
2025-12-03 19:17:27 +01:00
86fdfc8532 price marchalling
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 19:07:04 +01:00
c57643ab63 bajs
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 19:01:57 +01:00
9fde6d6bd7 by idstring
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-12-03 18:54:11 +01:00
1cca11de35 update internal url
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m6s
2025-12-03 18:48:07 +01:00
1b4e769573 dont reserv other than ps5
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 18:46:47 +01:00
8f2a354f9d update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-12-03 18:27:37 +01:00
4940b61ee0 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-03 18:14:04 +01:00
ad24d503ba update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 17:45:38 +01:00
matst80
ca7d23f122 crash if not correct type
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m52s
2025-12-03 10:22:41 +01:00
matst80
c7049793e2 use correct image
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m4s
2025-12-03 10:02:23 +01:00
matst80
dbdd759e16 fix docker and deployment
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-12-03 09:54:09 +01:00
ee5f54f0dd refactor/checkout (#8)
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m40s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: #8
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-12-03 09:45:48 +01:00
matst80
ebd1508294 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-12-02 09:26:30 +01:00
matst80
d8791788fc update discovery
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-12-02 09:08:36 +01:00
matst80
ee1b96fece slask
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-02 09:04:42 +01:00
matst80
060b3dfbf0 more changes
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m59s
2025-12-01 20:15:48 +01:00
matst80
c227870f13 add more payment related shit
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-12-01 18:35:32 +01:00
matst80
d23bfe62a1 try to update inventory on spawn
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-01 13:47:54 +01:00
matst80
accdc41064 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-12-01 10:18:22 +01:00
matst80
e419cde351 less pooltime
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-01 09:38:29 +01:00
matst80
f0c487b5e1 test a version
Some checks are pending
Build and Publish / BuildAndDeployArm64 (push) Has started running
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
2025-12-01 09:36:59 +01:00
matst80
84268eede4 update url
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-11-29 16:10:31 +01:00
matst80
aaa730e833 change return url
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-11-29 16:01:55 +01:00
matst80
c11161b0ca add result endpoint
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-29 15:43:51 +01:00
matst80
cdb678f018 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-11-29 14:43:34 +01:00
c9695d338d Merge pull request 'feature/pubsub' (#7) from feature/pubsub into main
All checks were successful
Build and Publish / BuildAndDeployArm64 (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 12s
Reviewed-on: #7
2025-11-28 17:45:12 +01:00
matst80
e1072c5ba1 cart items
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-28 17:01:32 +01:00
matst80
aea2adcf92 incorrect reference
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-11-28 16:48:21 +01:00
matst80
90a525bd98 capture and split notification if it has multiple hosts
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 16:44:03 +01:00
matst80
330093bdec more
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-11-28 14:33:26 +01:00
matst80
47e69f18a5 add function to apply mutations over grpc
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-28 14:07:43 +01:00
matst80
2c0f6c160a update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-11-28 08:11:18 +01:00
matst80
23160479b3 add more
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:10:08 +01:00
matst80
d8db3c9289 builder
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:08:11 +01:00
matst80
43b7e749c4 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-28 08:01:55 +01:00
matst80
c3d8773c2e add adyen
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m43s
2025-11-28 00:34:00 +01:00
matst80
c2873ebfc0 fix
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-27 20:28:10 +01:00
matst80
db61413988 update inventory listener
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m31s
2025-11-27 16:01:04 +01:00
matst80
65dc8edf48 fix mutation context
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-27 15:58:31 +01:00
matst80
fd29300314 try to reserve when entering checkout
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Failing after 23s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2m54s
2025-11-27 13:47:48 +01:00
matst80
aea168160e update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m45s
2025-11-27 12:45:34 +01:00
matst80
c2a137d8d4 Merge branch 'feature/pubsub' of git-ssh.tornberg.me:mats/go-cart-actor into feature/pubsub 2025-11-27 12:14:11 +01:00
matst80
51d7de03d6 update 2025-11-27 12:14:09 +01:00
3912135b35 update
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 21:43:18 +01:00
afa79e7b56 handle limited quantity
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 21:35:40 +01:00
a17d5fc570 add the reservationservice
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m56s
2025-11-26 20:42:12 +01:00
fac94acfe1 now we should have logs!
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:35:39 +01:00
beb03a7cd7 logs in pod
Some checks failed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-26 20:34:11 +01:00
87625ffd5d do reservations
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 20:29:14 +01:00
9db4abdb05 update service
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:17:46 +01:00
a6f6d3c190 add service to deployment
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 32s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 19:42:45 +01:00
matst80
400079ec98 add reservations to the model
All checks were successful
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m48s
2025-11-26 19:25:20 +01:00
97 changed files with 8555 additions and 5197 deletions

View File

@@ -1,6 +1,9 @@
name: Build and Publish
run-name: ${{ gitea.actor }} build 🚀
on: [push]
on:
push:
branches:
- main
jobs:
BuildAndDeployAmd64:
@@ -22,6 +25,7 @@ jobs:
run: |
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
kubectl rollout restart deployment/cart-actor-x86 -n cart
kubectl rollout restart deployment/checkout-actor-x86 -n cart
BuildAndDeployArm64:
runs-on: arm64

View File

@@ -71,6 +71,12 @@ RUN go build -trimpath -ldflags="-s -w \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-inventory ./cmd/inventory
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout
############################
# Runtime Stage
############################
@@ -79,6 +85,7 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
WORKDIR /
COPY --from=build /out/go-cart-actor /go-cart-actor
COPY --from=build /out/go-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
COPY --from=build /out/go-cart-inventory /go-cart-inventory

View File

@@ -19,7 +19,10 @@
MODULE_PATH := git.k6n.net/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/control_plane.proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
@@ -69,21 +72,30 @@ check_tools:
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
--go_out=./proto/cart --go_opt=paths=source_relative \
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/cart.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/control --go_opt=paths=source_relative \
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/control_plane.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
@rm -f *.pb.go
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
@echo "$(GREEN)Clean complete.$(RESET)"
verify_proto:
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
@if ls *.pb.go >/dev/null 2>&1; then \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
ls -1 *.pb.go; \
exit 1; \
fi

View File

@@ -18,19 +18,24 @@ import (
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"github.com/gogo/protobuf/proto"
"git.k6n.net/go-cart-actor/pkg/checkout"
"google.golang.org/protobuf/proto"
)
type FileServer struct {
// Define fields here
dataDir string
storage actor.LogStorage[cart.CartGrain]
dataDir string
checkoutDataDir string
storage actor.LogStorage[cart.CartGrain]
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
}
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
return &FileServer{
dataDir: dataDir,
storage: storage,
dataDir: dataDir,
checkoutDataDir: checkoutDataDir,
storage: storage,
checkoutStorage: checkoutStorage,
}
}
@@ -79,6 +84,12 @@ func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
return out
}
func appendCheckoutFileInfo(info fs.FileInfo, out *CheckoutFileInfo) *CheckoutFileInfo {
out.Size = info.Size()
out.Modified = info.ModTime()
return out
}
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
func listCartFiles(dir string) ([]*CartFileInfo, error) {
@@ -112,6 +123,36 @@ func listCartFiles(dir string) ([]*CartFileInfo, error) {
return out, nil
}
func listCheckoutFiles(dir string) ([]*CheckoutFileInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []*CheckoutFileInfo{}, nil
}
return nil, err
}
out := make([]*CheckoutFileInfo, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
id, valid := isValidFileId(e.Name())
if !valid {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, appendCheckoutFileInfo(info, &CheckoutFileInfo{
ID: fmt.Sprintf("%d", id),
CheckoutId: checkout.CheckoutId(id),
}))
}
return out, nil
}
func readRawLogLines(path string) ([]json.RawMessage, error) {
fh, err := os.Open(path)
if err != nil {
@@ -158,6 +199,21 @@ func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
})
}
func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
list, err := listCheckoutFiles(fs.checkoutDataDir)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// sort by modified desc
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
writeJSON(w, http.StatusOK, map[string]any{
"count": len(list),
"checkouts": list,
})
}
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
fileName := filepath.Join(fs.dataDir, "promotions.json")
if r.Method == http.MethodGet {
@@ -315,3 +371,69 @@ func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
},
})
}
func (fs *FileServer) CheckoutHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
return
}
id, ok := isValidId(idStr)
if !ok {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
return
}
// parse query parameters for filtering
query := r.URL.Query()
filterFunction := acceptAll
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
log.Printf("filter maxIndex: %s", maxIndexStr)
maxIndex, err := strconv.Atoi(maxIndexStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
return
}
filterFunction = acceptUntilIndex(maxIndex)
} else if untilStr := query.Get("until"); untilStr != "" {
log.Printf("filter until: %s", untilStr)
until, err := time.Parse(time.RFC3339, untilStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
return
}
filterFunction = acceptUntilTimestamp(until)
}
// reconstruct state from event log if present
grain := checkout.NewCheckoutGrain(id, cart.CartId(id), 0, time.Now(), nil)
err := fs.checkoutStorage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
path := filepath.Join(fs.checkoutDataDir, fmt.Sprintf("%d.events.log", id))
info, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusNotFound, JsonError{Error: "checkout not found"})
return
} else if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
lines, err := readRawLogLines(path)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"checkoutId": checkout.CheckoutId(id).String(),
"state": grain,
"mutations": lines,
"meta": map[string]any{
"size": info.Size(),
"modified": info.ModTime(),
"path": path,
},
})
}

View File

@@ -11,6 +11,7 @@ import (
actor "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
@@ -24,6 +25,13 @@ type CartFileInfo struct {
Modified time.Time `json:"modified"`
}
type CheckoutFileInfo struct {
ID string `json:"id"`
CheckoutId checkout.CheckoutId `json:"checkoutId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -94,10 +102,16 @@ func main() {
_ = os.MkdirAll(dataDir, 0755)
reg := cart.NewCartMultationRegistry()
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
fs := NewFileServer(dataDir, diskStorage)
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
_ = os.MkdirAll(checkoutDataDir, 0755)
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
hub := NewHub()
go hub.Run()
@@ -105,6 +119,8 @@ func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /carts", fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
inventorySku := inventory.SKU(r.PathValue("sku"))

View File

@@ -1,120 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
Terms string
Checkout string
Confirmation string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice.IncVat),
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: meta.Terms,
Checkout: meta.Checkout,
Confirmation: meta.Confirmation,
Notification: "https://cart.k6n.net/notification",
Validation: "https://cart.k6n.net/validate",
Push: "https://cart.k6n.net/push?order_id={checkout.order.id}",
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}

View File

@@ -1,209 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/messages"
"github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
)
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func (a *App) getGrainFromOrder(ctx context.Context, order *CheckoutOrder) (*cart.CartGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.pool.Get(ctx, uint64(cartId))
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
return &CartChangeEvent{
CartId: cart.CartId(id),
Mutations: msg,
}, nil
})
amqpListener.DefineTopics()
a.pool.AddListener(amqpListener)
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
err = inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
a.pool.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
err = confirmOrder(r.Context(), order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(r.Context(), a.server, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
}))
mux.HandleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
a.pool.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/notification", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
_, err = inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory check failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
}
w.WriteHeader(http.StatusOK)
})
}

View File

@@ -3,9 +3,8 @@ package main
import (
"log"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
@@ -24,10 +23,14 @@ func GetDiscovery() discovery.Discovery {
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return discovery.NewK8sDiscovery(client)
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
@@ -15,7 +14,6 @@ import (
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/promotions"
"git.k6n.net/go-cart-actor/pkg/proxy"
"git.k6n.net/go-cart-actor/pkg/voucher"
@@ -39,9 +37,8 @@ func init() {
}
type App struct {
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
klarnaClient *KlarnaClient
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
}
var podIp = os.Getenv("POD_IP")
@@ -69,6 +66,21 @@ type CartChangeEvent struct {
Mutations []actor.ApplyResult `json:"mutations"`
}
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
if string(update.SKU) == item.Sku {
if update.LocationID == "se" && item.StoreId == nil {
return true
}
if item.StoreId == nil {
return false
}
if *item.StoreId == string(update.LocationID) {
return true
}
}
return false
}
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
@@ -83,14 +95,28 @@ func main() {
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
// promotionService := promotions.NewPromotionService(nil)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
reg := cart.NewCartMultationRegistry()
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating inventory reservation service: %v\n", err)
}
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
g.UpdateTotals()
g.Version++
// promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
// _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
// for _, action := range actions {
@@ -113,8 +139,33 @@ func main() {
grainSpawns.Inc()
ret := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
err := diskStorage.LoadEvents(ctx, id, ret)
if err == nil && inventoryService != nil {
refs := make([]*inventory.InventoryReference, 0)
for _, item := range ret.Items {
refs = append(refs, &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
})
}
_, span := tracer.Start(ctx, "update inventory")
defer span.End()
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
if err != nil {
log.Printf("unable to update inventory %v", err)
} else {
for _, update := range res {
for _, item := range ret.Items {
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
// maybe apply an update to give visibility to the cart
item.Stock = uint16(update.Quantity)
}
}
}
}
}
return ret, err
},
@@ -127,10 +178,10 @@ func main() {
return nil
},
SpawnHost: func(host string) (actor.Host, error) {
return proxy.NewRemoteHost(host)
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host)
},
TTL: 15 * time.Minute,
TTL: 5 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
@@ -140,35 +191,17 @@ func main() {
log.Fatalf("Error creating cart pool: %v\n", err)
}
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService, rdb)
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
app := &App{
pool: pool,
server: syncedServer,
klarnaClient: klarnaClient,
pool: pool,
server: syncedServer,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Printf("no connection to amqp defined")
} else {
app.HandleCheckoutRequests(amqpUrl, mux, inventoryService)
}
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
grpcSrv, err := actor.NewControlServer[cart.CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
@@ -258,7 +291,12 @@ func main() {
}
})
go listener.Start()
go func() {
err := listener.Start()
if err != nil {
log.Fatalf("Unable to start inventory listener: %v", err)
}
}()
log.Print("Server started at port 8080")
@@ -275,31 +313,3 @@ func main() {
}
}
func triggerOrderCompleted(ctx context.Context, syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
}
cid, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.Apply(ctx, uint64(cid), mutation)
return applyErr
}
func confirmOrder(ctx context.Context, order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
orderToSend, err := json.Marshal(order)
if err != nil {
return err
}
err = orderHandler.OrderCompleted(orderToSend)
if err != nil {
return err
}
return nil
}

View File

@@ -1,10 +1,9 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
@@ -13,15 +12,15 @@ import (
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/gogo/protobuf/proto"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"go.opentelemetry.io/otel"
@@ -42,25 +41,24 @@ var (
)
type PoolServer struct {
actor.GrainPool[*cart.CartGrain]
actor.GrainPool[cart.CartGrain]
pod_name string
klarnaClient *KlarnaClient
inventoryService inventory.InventoryService
reservationService inventory.CartReservationService
}
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService, inventoryRedisClient *redis.Client) *PoolServer {
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
srv := &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
inventoryService: inventoryService,
GrainPool: pool,
pod_name: pod_name,
inventoryService: inventoryService,
reservationService: inventoryReservationService,
}
return srv
}
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
@@ -79,6 +77,7 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
@@ -118,71 +117,6 @@ func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, i
return s.WriteResult(w, data)
}
type SetDeliveryRequest struct {
Provider string `json:"provider"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
@@ -253,7 +187,9 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
return err
}
reply, err := s.ApplyLocal(r.Context(), id, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
if err != nil {
return err
}
@@ -267,48 +203,6 @@ type AddRequest struct {
StoreId *string `json:"storeId"`
}
func (s *PoolServer) GetReservationTime(item *messages.AddItem) time.Duration {
return time.Minute * 15
//return nil
}
func (s *PoolServer) HandleReservations(ctx context.Context, cartId cart.CartId, msgs ...*messages.AddItem) error {
if s.reservationService == nil {
return nil
}
for _, item := range msgs {
timeout := s.GetReservationTime(item)
if timeout == 0 {
continue
}
span := trace.SpanFromContext(ctx)
locationId := inventory.LocationID("se")
if item.StoreId != nil {
locationId = inventory.LocationID(*item.StoreId)
}
span.AddEvent("reserving item", trace.WithAttributes(attribute.String("sku", item.Sku), attribute.String("locationId", string(locationId))))
end := time.Now().Add(timeout)
err := s.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
CartID: inventory.CartID(cartId.String()),
InventoryReference: &inventory.InventoryReference{
LocationID: locationId,
SKU: inventory.SKU(item.Sku),
},
Quantity: uint32(item.Quantity),
TTL: 15 * time.Minute,
})
if err != nil {
return err
}
logger.InfoContext(ctx, "reserved item", "sku", item.Sku, "location", string(locationId), "expires at", end.String())
span.End()
item.ReservationEndsTime = end
}
return nil
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
addRequest := AddRequest{Quantity: 1}
err := json.NewDecoder(r.Body).Decode(&addRequest)
@@ -319,9 +213,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
if err != nil {
return err
}
if s.reservationService != nil {
}
reply, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
@@ -349,100 +241,6 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
// return json.NewEncoder(w).Encode(order)
// }
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func (s *PoolServer) CreateOrUpdateCheckout(r *http.Request, id cart.CartId) (*CheckoutOrder, error) {
host := getOriginalHost(r)
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return nil, err
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
failingRequest, err := s.inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
return nil, err
}
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(r.Context(), grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(ctx, id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
@@ -456,51 +254,6 @@ func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *Chec
// }
//
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
@@ -518,34 +271,6 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
return nil
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
@@ -556,7 +281,7 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
@@ -572,13 +297,10 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
}
var (
tracer = otel.Tracer(name)
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
// rollCnt metric.Int64Counter
)
func init() {
@@ -602,10 +324,6 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
s.ApplyLocal(r.Context(), cartId, &messages.PreConditionFailed{
Operation: "AddVoucher",
Error: err.Error(),
})
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
@@ -654,26 +372,6 @@ func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.R
return nil
}
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
orderId := r.URL.Query().Get("order_id")
if orderId == "" {
order, err := s.CreateOrUpdateCheckout(r, cartId)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
s.ApplyCheckoutStarted(r.Context(), order, cartId)
return fn(order, w)
}
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
return err
}
return fn(order, w)
}))
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
idStr := r.PathValue("voucherId")
@@ -738,19 +436,45 @@ func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http
return s.WriteResult(w, reply)
}
func (s *PoolServer) CreateCheckoutOrderHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
createCheckoutOrder := messages.CreateCheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(&createCheckoutOrder)
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &createCheckoutOrder)
mutation := &messages.Mutation{}
err = proto.Unmarshal(data, mutation)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
func (s *PoolServer) Serve(mux *http.ServeMux) {
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
@@ -782,37 +506,25 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
handleFunc("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("POST /cart/checkout-order", CookieCartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("POST /cart/byid/{id}/checkout-order", CartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
}

View File

@@ -7,7 +7,8 @@ import (
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
"github.com/matst80/slask-finder/pkg/index"
)

138
cmd/cart/utils.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}

View File

@@ -0,0 +1,264 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type SessionRequest struct {
SessionId *string `json:"sessionId,omitempty"`
SessionResult string `json:"sessionResult"`
SessionData *string `json:"sessionData,omitempty"`
}
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// grain, err := s.Get(r.Context(), uint64(cartId))
// if err != nil {
// return err
// }
// if r.Method == http.MethodGet {
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// } else {
// payload := &SessionRequest{}
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
// return err
// }
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// }
// }
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
cartId, ok := cart.ParseCartId(item.MerchantReference)
if !ok {
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
return nil, errors.New("invalid cart id")
}
return &cartId, nil
}
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
var notificationRequest webhook.Webhook
service := s.adyenClient.Checkout()
if err := json.NewDecoder(r.Body).Decode(&notificationRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, notificationItem := range *notificationRequest.NotificationItems {
item := notificationItem.NotificationRequestItem
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
if !isValid {
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
return
} else {
// Marshal item data for PaymentEvent
dataBytes, err := json.Marshal(item)
if err != nil {
log.Printf("error marshaling item: %v", err)
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
return
}
switch item.EventCode {
case "CAPTURE":
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Capture status: %v", item.Success)
isSuccess := item.Success == "true"
// If successful, apply payment completed
//if isSuccess {
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
Currency: item.Amount.Currency,
ProcessorReference: &item.PspReference,
CompletedAt: timestamppb.New(time.Now()),
}); err != nil {
http.Error(w, "Message not parsed", http.StatusInternalServerError)
return
}
//}
case "AUTHORISATION":
isSuccess := item.Success == "true"
log.Printf("Handling auth: %+v", item)
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msgs := []proto.Message{
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
},
}
if isSuccess {
msgs = append(msgs, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
CompletedAt: timestamppb.Now(),
})
} else {
msgs = append(msgs, &messages.PaymentDeclined{
PaymentId: item.PspReference,
Message: item.Reason,
})
}
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
log.Printf("error applying authorization event: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// If successful authorization, trigger capture
if isSuccess {
pspReference := item.PspReference
uid := uuid.New().String()
ref := checkoutId.String()
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
Amount: adyenCheckout.Amount(item.Amount),
MerchantAccount: "ElgigantenECOM",
Reference: &ref,
})
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
if err != nil {
log.Printf("Error capturing payment: %v", err)
} else {
log.Printf("Payment captured successfully: %+v", res)
s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
OrderId: res.PaymentPspReference,
Status: item.EventCode,
})
}
}
default:
log.Printf("Unknown event code: %s", item.EventCode)
log.Printf("Item data: %+v", item)
isSuccess := item.Success == "true"
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
} else {
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}); err != nil {
log.Printf("error applying payment event: %v", err)
}
}
}
}
}
w.WriteHeader(http.StatusAccepted)
}
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received")
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
log.Printf("got payment session %+v", res)
dreq := service.PaymentsApi.PaymentsDetailsInput()
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
Details: adyenCheckout.PaymentCompletionDetails{
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
Payload: common.PtrString(r.URL.Query().Get("payload")),
},
})
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Payment details response: %+v", dres)
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
var redirectURL string
// Conditionally handle different result codes for the shopper
switch *dres.ResultCode {
case "Authorised":
redirectURL = "/result/success"
case "Pending", "Received":
redirectURL = "/result/pending"
case "Refused":
redirectURL = "/result/failed"
default:
reason := ""
if dres.RefusalReason != nil {
reason = *dres.RefusalReason
} else {
reason = *dres.ResultCode
}
log.Printf("Payment failed: %s", reason)
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpRes.StatusCode)
json.NewEncoder(w).Encode(httpRes.Status)
}

View File

@@ -0,0 +1,67 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
type CartClient struct {
httpClient *http.Client
baseUrl string
}
func NewCartClient(baseUrl string) *CartClient {
return &CartClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseUrl: baseUrl,
}
}
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
// data, err := proto.Marshal(mutation)
// if err != nil {
// return err
// }
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
// if err != nil {
// return err
// }
// req.Header.Set("Content-Type", "application/protobuf")
// resp, err := c.httpClient.Do(req)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
// }
// return nil
// }
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
// Call cart service to get grain
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("request to %s failed with status %s", url, resp.Status)
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
}
var grain cart.CartGrain
err = json.NewDecoder(resp.Body).Decode(&grain)
return &grain, err
}

View File

@@ -0,0 +1,31 @@
package main
import (
"context"
"testing"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
func TestGetCartGrain_RealService(t *testing.T) {
t.Skip("Skipping integration test against real service")
client := NewCartClient("https://cart.k6n.net")
// You would need a real cart ID that exists in the system
// For example: cartId := cart.NewCartId(123, 456)
cartId := cart.MustParseCartId("JkfG6bRNLMy")
grain, err := client.getCartGrain(context.Background(), cartId)
if err != nil {
t.Fatalf("Failed to get cart grain: %v", err)
}
if grain == nil {
t.Fatal("Expected grain to be non-nil")
}
t.Logf("Successfully retrieved cart grain: %+v", grain)
}

View File

@@ -0,0 +1,208 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
SiteUrl string
// Terms string
// Checkout string
// Confirmation string
ClientIp string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
//total.Add(d.Price)
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(total.IncVat),
OrderTaxAmount: int(total.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Notification: "https://cart.k6n.net/payment/klarna/notification",
Validation: "https://cart.k6n.net/payment/klarna/validate",
Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}",
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r)
country := getCountryFromHost(host)
return &CheckoutMeta{
ClientIp: getClientIp(r),
SiteUrl: fmt.Sprintf("https://%s", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
}
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
country := meta.Country
if country == "" {
country = "SE"
}
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(int64(it.Quantity)),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
Description: common.PtrString(it.Meta.Name),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
TaxPercentage: common.PtrInt64(25),
})
}
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
Value: total.IncVat,
Currency: currency,
},
CountryCode: common.PtrString(country),
MerchantAccount: "ElgigantenECOM",
Channel: common.PtrString("Web"),
ShopperIP: common.PtrString(meta.ClientIp),
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
LineItems: lineItems,
}, nil
}

View File

@@ -0,0 +1,63 @@
package main
import (
"log"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}

View File

@@ -80,7 +80,7 @@ func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*Chec
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -101,7 +101,7 @@ func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader i
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -119,7 +119,7 @@ func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
req = req.WithContext(spanCtx)
req.SetBasicAuth(k.UserName, k.Password)
_, err = http.DefaultClient.Do(req)
_, err = k.client.Do(req)
return err
}
@@ -137,6 +137,6 @@ func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) err
req.SetBasicAuth(k.UserName, k.Password)
req.Header.Add("Klarna-Idempotency-Key", id.String())
_, err = http.DefaultClient.Do(req)
_, err = k.client.Do(req)
return err
}

View File

@@ -0,0 +1,277 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"google.golang.org/protobuf/types/known/timestamppb"
)
/*
*
*
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
})
*/
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
// w.WriteHeader(http.StatusOK)
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
// return err
// }
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
// return json.NewEncoder(w).Encode(order)
// }
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
s.reserveInventory(r.Context(), grain)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
PaymentId: orderId,
Status: "completed",
ProcessorReference: &order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
CompletedAt: timestamppb.Now(),
})
// err = confirmOrder(r.Context(), order, orderHandler)
// if err != nil {
// log.Printf("Error confirming order: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
// err = triggerOrderCompleted(r.Context(), a.server, order)
// if err != nil {
// log.Printf("Error processing cart message: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
}
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.GetAnywhere(ctx, cartId)
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}

201
cmd/checkout/main.go Normal file
View File

@@ -0,0 +1,201 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/checkout"
"git.k6n.net/go-cart-actor/pkg/proxy"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_spawned_total",
Help: "The total number of spawned checkout grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
server *CheckoutPoolServer
klarnaClient *KlarnaClient
cartClient *CartClient // For internal communication to cart
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
g.Version++
return nil
}),
)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
defer span.End()
grainSpawns.Inc()
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
// Load persisted events/state for this checkout if present
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
// Return the grain along with error (e.g., not found) so callers can decide
return ret, err
}
return ret, nil
},
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
return nil
},
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
},
TTL: 1 * time.Hour, // Longer TTL for checkout
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating checkout pool: %v\n", err)
}
adyenClient := adyen.NewClient(&common.Config{
ApiKey: os.Getenv("ADYEN_API_KEY"),
Environment: common.TestEnv,
})
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
cartClient := NewCartClient(cartInternalUrl)
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
syncedServer.inventoryService = inventoryService
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Fatalf("no connection to amqp defined")
}
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Checkout server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}

117
cmd/checkout/otel.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}

543
cmd/checkout/pool-server.go Normal file
View File

@@ -0,0 +1,543 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strconv"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_lookups_total",
Help: "The total number of lookups",
})
)
type CheckoutPoolServer struct {
actor.GrainPool[checkout.CheckoutGrain]
pod_name string
klarnaClient *KlarnaClient
adyenClient *adyen.APIClient
cartClient *CartClient
inventoryService *inventory.RedisInventoryService
}
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
srv := &CheckoutPoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
cartClient: cartClient,
adyenClient: adyenClient,
}
return srv
}
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetDelivery
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
deliveryId := r.PathValue("id")
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
if err != nil {
return err
}
msg := &messages.RemoveDelivery{
Id: uint32(uintDeliveryId),
}
result, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetPickupPoint
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InitializeCheckout
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InventoryReserved
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.OrderCreated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ConfirmationViewed
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ContactDetailsUpdated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
cartIdStr := r.PathValue("cartid")
if cartIdStr == "" {
http.Error(w, "cart id required", http.StatusBadRequest)
return
}
cartId, ok := cart.ParseCartId(cartIdStr)
if !ok {
http.Error(w, "invalid cart id", http.StatusBadRequest)
return
}
// Fetch cart state from cart service
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
if err != nil {
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Serialize cart state to Any
cartStateBytes, err := json.Marshal(cartGrain)
if err != nil {
logger.Error("failed to marshal cart state", "error", err)
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
return
}
// Create checkout with same ID as cart
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
cookie, err := r.Cookie(checkoutCookieName)
if err == nil {
parsed, ok := cart.ParseCartId(cookie.Value)
if ok {
checkoutId = parsed
}
}
// Initialize checkout with cart state wrapped in Any
cartStateAny := &messages.InitializeCheckout{
OrderId: "",
CartId: uint64(cartId),
Version: uint32(cartGrain.Version),
CartState: &anypb.Any{
TypeUrl: "type.googleapis.com/cart.CartGrain",
Value: cartStateBytes,
},
}
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
if err != nil {
setCheckoutCookie(w, 0, r.TLS != nil)
logger.Error("failed to initialize checkout", "error", err)
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
return
}
// Set checkout cookie
setCheckoutCookie(w, checkoutId, r.TLS != nil)
if err := s.WriteResult(w, &result.Result); err != nil {
logger.Error("failed to write result", "error", err)
}
}
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
var payment *checkout.Payment
if orderId != nil {
payment, _ = grain.FindPayment(*orderId)
}
if payment != nil && payment.PaymentId != "" {
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
method := "checkout"
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
PaymentId: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Provider: "klarna",
Method: &method,
StartedAt: timestamppb.New(time.Now()),
})
}
var (
tracer = otel.Tracer(name)
hmacKey = os.Getenv("ADYEN_HMAC")
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
)
func init() {
var err error
proxyCalls, err = meter.Int64Counter("proxy.calls",
metric.WithDescription("Number of proxy calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
type StartPayment struct {
Provider string `json:"provider"`
Method string `json:"method,omitempty"`
}
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payment, ok := grain.FindPayment(paymentId)
if !ok {
http.Error(w, "payment not found", http.StatusNotFound)
return nil
}
switch payment.Provider {
case "adyen":
payload := &SessionRequest{
SessionResult: "",
}
if r.Method != http.MethodGet {
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
if err != nil {
return err
}
if res.Status != nil && *res.Status == "completed" {
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
PaymentId: paymentId,
Status: *res.Status,
ProcessorReference: res.Id,
CompletedAt: timestamppb.Now(),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
}
return s.WriteResult(w, res)
case "klarna":
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
if err != nil {
return err
}
return s.WriteResult(w, current)
}
return errors.New("unsupported payment provider")
}
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payload := &StartPayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
switch payload.Provider {
case "adyen":
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
if err != nil {
logger.Error("unable to build adyen session", "error", err)
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
if err != nil {
logger.Error("unable to create adyen session", "error", err)
return err
}
sessionBytes, err := json.Marshal(session)
if err != nil {
logger.Error("unable to marshal session", "error", err)
return err
}
// Apply PaymentStarted mutation
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: session.Id,
Amount: session.Amount.Value,
Currency: session.Amount.Currency,
Provider: "adyen",
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: sessionBytes,
},
Method: &payload.Method,
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
case "klarna":
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
orderBytes, err := json.Marshal(order)
if err != nil {
logger.Error("unable to marshal order", "error", err)
return err
}
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
Provider: "klarna",
Method: &payload.Method,
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: orderBytes,
},
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
default:
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
return nil
}
}
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
//handleFunc("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
}

144
cmd/checkout/utils.go Normal file
View File

@@ -0,0 +1,144 @@
package main
import (
"context"
"log"
"net/http"
"strings"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
if a.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
if err != nil {
logger.WarnContext(ctx, "placeorder inventory check failed")
return err
}
}
return nil
}
const checkoutCookieName = "checkoutid"
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
if checkoutId == 0 {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
} else {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
}
}
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id checkout.CheckoutId
cookie, err := r.Cookie(checkoutCookieName)
if err != nil || cookie.Value == "" {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
id = parsed
}
}
err = fn(w, r, id)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
defer span.End()
return fn(w, r, checkoutId)
}
}

View File

@@ -38,7 +38,7 @@ spec:
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor
path: /i-data/7a8af061/nfs/
server: 10.10.1.10
serviceAccountName: default
containers:
@@ -76,12 +76,26 @@ spec:
memory: "70Mi"
cpu: "1200m"
env:
- name: DATA_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
@@ -170,6 +184,10 @@ spec:
memory: "70Mi"
cpu: "1200m"
env:
- name: DATA_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
@@ -185,6 +203,16 @@ spec:
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
@@ -262,14 +290,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 30
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
@@ -290,6 +318,16 @@ spec:
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
@@ -327,6 +365,140 @@ spec:
ports:
- name: web
port: 8080
- name: internal
port: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: checkout-actor
arch: amd64
name: checkout-actor-x86
spec:
replicas: 3
selector:
matchLabels:
app: checkout-actor
arch: amd64
template:
metadata:
labels:
app: checkout-actor
actor-pool: checkout
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/checkout-actor
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: checkout-actor-amd64
imagePullPolicy: Always
command: ["/go-checkout-actor"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 1
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=checkout,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: CART_INTERNAL_URL
value: "http://cart-actor:8080"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: checkout-actor
annotations:
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: checkout-actor
ports:
- name: web
port: 8080
---
kind: Service
apiVersion: v1
@@ -367,6 +539,20 @@ spec:
name: cart-actor
port:
number: 8080
- path: /api/checkout
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
- path: /payment
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -462,3 +648,14 @@ spec:
value: "redis.home:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
---
kind: Service
apiVersion: v1
metadata:
name: inventory
spec:
selector:
app: cart-inventory
ports:
- name: web
port: 8080

3
go.mod
View File

@@ -3,7 +3,7 @@ module git.k6n.net/go-cart-actor
go 1.25.4
require (
github.com/gogo/protobuf v1.3.2
github.com/adyen/adyen-go-api-library/v21 v21.1.0
github.com/google/uuid v1.6.0
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
@@ -58,6 +58,7 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect

10
go.sum
View File

@@ -1,5 +1,9 @@
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
@@ -94,6 +98,8 @@ github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4F
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -113,8 +119,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matst80/go-redis-inventory v0.0.0-20251125181530-7ebbc97e3841 h1:CCHa+LuxwGOPPbIpuFYNT7VtC5rHjSulB337Y+Yyys8=
github.com/matst80/go-redis-inventory v0.0.0-20251125181530-7ebbc97e3841/go.mod h1:d42+bHxhmMg73OQM+5i5U7vyEQfuVLJkt7QPsEbr8kI=
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
@@ -193,6 +197,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=

View File

@@ -18,10 +18,10 @@ This directory contains a k6 script (`cart_load_test.js`) to stress and observe
The script exercises (per iteration):
1. `GET /cart/` ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
2. `POST /cart/` add item mutation (random SKU & quantity)
3. `GET /cart/` fetch after mutations
4. `GET /cart/checkout` occasionally (~2% of iterations) to simulate checkout start
1. `GET /cart` ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
2. `POST /cart` add item mutation (random SKU & quantity)
3. `GET /cart` fetch after mutations
4. `GET /checkout` occasionally (~2% of iterations) to simulate checkout start
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
@@ -40,9 +40,9 @@ Example run:
```bash
k6 run \
-e BASE_URL=https://cart.prod.example.com/cart \
-e BASE_URL=https://cart.k6n.net/cart \
-e VUS=40 \
-e DURATION=10m \
-e DURATION=1m \
-e RAMP_TARGET=120 \
k6/cart_load_test.js
```
@@ -171,4 +171,4 @@ Feel free to request:
- WebSocket / long poll integration (if added later)
- Synthetic error injection harness
Happy load testing!
Happy load testing!

View File

@@ -4,43 +4,39 @@ import { Counter, Trend } from "k6/metrics";
// ---------------- Configuration ----------------
export const options = {
// Adjust vus/duration for your environment
scenarios: {
steady_mutations: {
executor: "constant-vus",
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
duration: __ENV.DURATION || "5m",
gracefulStop: "30s",
},
ramp_up: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{
duration: "1m",
target: __ENV.RAMP_TARGET
? parseInt(__ENV.RAMP_TARGET, 10)
: 50,
},
{
duration: "1m",
target: __ENV.RAMP_TARGET
? parseInt(__ENV.RAMP_TARGET, 10)
: 50,
},
{ duration: "1m", target: 0 },
],
gracefulStop: "30s",
startTime: "30s",
},
// Adjust vus/duration for your environment
scenarios: {
steady_mutations: {
executor: "constant-vus",
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
duration: __ENV.DURATION || "5m",
gracefulStop: "30s",
},
thresholds: {
http_req_failed: ["rate<0.02"], // < 2% failures
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
"cart_add_item_duration{op:add}": ["p(90)<800"],
"cart_fetch_duration{op:get}": ["p(90)<600"],
ramp_up: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{
duration: "1m",
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
},
{
duration: "1m",
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
},
{ duration: "1m", target: 0 },
],
gracefulStop: "30s",
startTime: "30s",
},
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
},
thresholds: {
http_req_failed: ["rate<0.02"], // < 2% failures
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
"cart_add_item_duration{op:add}": ["p(90)<800"],
"cart_fetch_duration{op:get}": ["p(90)<600"],
},
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
};
// ---------------- Metrics ----------------
@@ -52,197 +48,197 @@ const checkoutCounter = new Counter("cart_checkout_calls");
// ---------------- SKUs ----------------
const SKUS = [
"778290",
"778345",
"778317",
"778277",
"778267",
"778376",
"778244",
"778384",
"778365",
"778377",
"778255",
"778286",
"778246",
"778270",
"778266",
"778285",
"778329",
"778425",
"778407",
"778418",
"778430",
"778469",
"778358",
"778351",
"778319",
"778307",
"778278",
"778251",
"778253",
"778261",
"778263",
"778273",
"778281",
"778294",
"778297",
"778302",
"778290",
"778345",
"778317",
"778277",
"778267",
"778376",
"778244",
"778384",
"778365",
"778377",
"778255",
"778286",
"778246",
"778270",
"778266",
"778285",
"778329",
"778425",
"778407",
"778418",
"778430",
"778469",
"778358",
"778351",
"778319",
"778307",
"778278",
"778251",
"778253",
"778261",
"778263",
"778273",
"778281",
"778294",
"778297",
"778302",
];
// ---------------- Helpers ----------------
function randomSku() {
return SKUS[Math.floor(Math.random() * SKUS.length)];
return SKUS[Math.floor(Math.random() * SKUS.length)];
}
function randomQty() {
return 1 + Math.floor(Math.random() * 3); // 1..3
return 1 + Math.floor(Math.random() * 3); // 1..3
}
function baseUrl() {
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
// Allow user to pass either root host or full /cart path
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
// Allow user to pass either root host or full /cart path
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
}
function extractCookie(res, name) {
const cookies = res.cookies[name];
if (!cookies || cookies.length === 0) return null;
return cookies[0].value;
const cookies = res.cookies[name];
if (!cookies || cookies.length === 0) return null;
return cookies[0].value;
}
function withCookies(headers, cookieJar) {
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
const cookieStr = Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return { ...headers, Cookie: cookieStr };
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
const cookieStr = Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return { ...headers, Cookie: cookieStr };
}
// Maintain cart + owner cookies per VU
let cartState = {
cartid: null,
cartowner: null,
cartid: null,
cartowner: null,
};
// Refresh cookies from response
function updateCookies(res) {
const cid = extractCookie(res, "cartid");
if (cid) cartState.cartid = cid;
const owner = extractCookie(res, "cartowner");
if (owner) cartState.cartowner = owner;
const cid = extractCookie(res, "cartid");
if (cid) cartState.cartid = cid;
const owner = extractCookie(res, "cartowner");
if (owner) cartState.cartowner = owner;
}
// Build headers
function headers() {
const h = { "Content-Type": "application/json" };
const jar = {};
if (cartState.cartid) jar["cartid"] = cartState.cartid;
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
return withCookies(h, jar);
const h = { "Content-Type": "application/json" };
const jar = {};
if (cartState.cartid) jar["cartid"] = cartState.cartid;
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
return withCookies(h, jar);
}
// Ensure cart exists (GET /)
function ensureCart() {
if (cartState.cartid) return;
const res = http.get(baseUrl() + "/", { headers: headers() });
updateCookies(res);
check(res, {
"ensure cart status 200": (r) => r.status === 200,
"ensure cart has id": () => !!cartState.cartid,
});
if (cartState.cartid) return;
const res = http.get(baseUrl(), { headers: headers() });
updateCookies(res);
check(res, {
"ensure cart status 200": (r) => r.status === 200,
"ensure cart has id": () => !!cartState.cartid,
});
}
// Add random item
function addRandomItem() {
const payload = JSON.stringify({
sku: randomSku(),
quantity: randomQty(),
country: "no",
});
const start = Date.now();
const res = http.post(baseUrl(), payload, { headers: headers() });
const dur = Date.now() - start;
addItemTrend.add(dur, { op: "add" });
if (res.status === 200) {
addedItemsCounter.add(1);
}
updateCookies(res);
check(res, {
"add item status ok": (r) => r.status === 200,
});
const payload = JSON.stringify({
sku: randomSku(),
quantity: randomQty(),
country: "no",
});
const start = Date.now();
const res = http.post(baseUrl(), payload, { headers: headers() });
const dur = Date.now() - start;
addItemTrend.add(dur, { op: "add" });
if (res.status === 200) {
addedItemsCounter.add(1);
}
updateCookies(res);
check(res, {
"add item status ok": (r) => r.status === 200,
});
}
// Fetch cart state
function fetchCart() {
const start = Date.now();
const res = http.get(baseUrl() + "/", { headers: headers() });
const dur = Date.now() - start;
fetchTrend.add(dur, { op: "get" });
updateCookies(res);
check(res, { "fetch status ok": (r) => r.status === 200 });
const start = Date.now();
const res = http.get(baseUrl(), { headers: headers() });
const dur = Date.now() - start;
fetchTrend.add(dur, { op: "get" });
updateCookies(res);
check(res, { "fetch status ok": (r) => r.status === 200 });
}
// Occasional checkout trigger
function maybeCheckout() {
if (!cartState.cartid) return;
// // Small probability
// if (Math.random() < 0.02) {
// const start = Date.now();
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
// const dur = Date.now() - start;
// checkoutTrend.add(dur, { op: "checkout" });
// updateCookies(res);
// if (res.status === 200) checkoutCounter.add(1);
// check(res, { "checkout status ok": (r) => r.status === 200 });
// }
if (!cartState.cartid) return;
// // Small probability
// if (Math.random() < 0.02) {
// const start = Date.now();
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
// const dur = Date.now() - start;
// checkoutTrend.add(dur, { op: "checkout" });
// updateCookies(res);
// if (res.status === 200) checkoutCounter.add(1);
// check(res, { "checkout status ok": (r) => r.status === 200 });
// }
}
// ---------------- k6 lifecycle ----------------
export function setup() {
// Provide SKU list length for summary
return { skuCount: SKUS.length };
// Provide SKU list length for summary
return { skuCount: SKUS.length };
}
export default function (data) {
group("cart flow", () => {
// Create or reuse cart
ensureCart();
group("cart flow", () => {
// Create or reuse cart
ensureCart();
// Random number of item mutations per iteration (1..5)
const ops = 1 + Math.floor(Math.random() * 5);
for (let i = 0; i < ops; i++) {
addRandomItem();
}
// Random number of item mutations per iteration (1..5)
const ops = 1 + Math.floor(Math.random() * 5);
for (let i = 0; i < ops; i++) {
addRandomItem();
}
// Fetch state
fetchCart();
// Fetch state
fetchCart();
// Optional checkout attempt
maybeCheckout();
});
// Optional checkout attempt
maybeCheckout();
});
// Small think time
sleep(Math.random() * 0.5);
// Small think time
sleep(Math.random() * 0.5);
}
export function teardown(data) {
// Optionally we could GET confirmation or clear cart cookie
// Not implemented for load purpose.
console.log(`Test complete. SKU count: ${data.skuCount}`);
// Optionally we could GET confirmation or clear cart cookie
// Not implemented for load purpose.
console.log(`Test complete. SKU count: ${data.skuCount}`);
}
// ---------------- Summary ----------------
export function handleSummary(data) {
return {
stdout: JSON.stringify(
{
metrics: {
mutations_avg: data.metrics.cart_add_item_duration?.avg,
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
checkout_count: data.metrics.cart_checkout_calls?.count,
},
checks: data.root_checks,
},
null,
2,
),
};
return {
stdout: JSON.stringify(
{
metrics: {
mutations_avg: data.metrics.cart_add_item_duration?.avg,
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
checkout_count: data.metrics.cart_checkout_calls?.count,
},
checks: data.root_checks,
},
null,
2,
),
};
}

131
pkg/actor/base62-id.go Normal file
View File

@@ -0,0 +1,131 @@
package actor
import (
"crypto/rand"
"encoding/json"
"fmt"
)
type GrainId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id GrainId) String() string {
return encodeBase62(uint64(id))
}
// MarshalJSON encodes the cart id as a JSON string.
func (id GrainId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (id *GrainId) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseGrainId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
}
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
func NewGrainId() (GrainId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewGrainId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewGrainId()
}
return GrainId(u), nil
}
// MustNewGrainId panics if generation fails.
func MustNewGrainId() GrainId {
id, err := NewGrainId()
if err != nil {
panic(err)
}
return id
}
// ParseGrainId parses a base62 string into a GrainId.
// Returns (0,false) for invalid input.
func ParseGrainId(s string) (GrainId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return GrainId(u), true
}
// MustParseGrainId panics on invalid base62 input.
func MustParseGrainId(s string) GrainId {
id, ok := ParseGrainId(s)
if !ok {
panic(fmt.Sprintf("invalid cart id: %q", s))
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}

View File

@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/gogo/protobuf/proto"
"google.golang.org/protobuf/proto"
)
type QueueEvent struct {

View File

@@ -2,9 +2,10 @@ package actor
import (
"context"
"io"
"net/http"
"github.com/gogo/protobuf/proto"
"google.golang.org/protobuf/proto"
)
type MutationResult[V any] struct {
@@ -14,27 +15,29 @@ type MutationResult[V any] struct {
type GrainPool[V any] interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(ctx context.Context, id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Get(ctx context.Context, id uint64) (*V, error)
OwnerHost(id uint64) (Host[V], bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
IsHealthy() bool
Close()
IsKnown(string) bool
RemoveHost(host string)
AddRemoteHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host interface {
type Host[V any] interface {
AnnounceExpiry(ids []uint64)
Negotiate(otherHosts []string) ([]string, error)
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(ctx context.Context, id uint64) (*V, error)
GetActorIds() []uint64
Close() error
Ping() bool

View File

@@ -2,18 +2,21 @@ package actor
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// ControlServer implements the ControlPlane gRPC services.
@@ -97,13 +100,37 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
}, err
}
log.Printf("Ack count: %d", len(req.Ids))
// log.Printf("Ack count: %d", len(req.Ids))
return &messages.OwnerChangeAck{
Accepted: true,
Message: "ownership announced",
}, nil
}
func toAny[V any](grain V) (*anypb.Any, error) {
data, err := json.Marshal(grain)
if err != nil {
return nil, err
}
return &anypb.Any{
Value: data,
}, nil
}
func (s *ControlServer[V]) Get(ctx context.Context, req *messages.GetRequest) (*messages.GetReply, error) {
grain, err := s.pool.Get(ctx, req.Id)
if err != nil {
return nil, err
}
grainAny, err := toAny(grain)
if err != nil {
return nil, err
}
return &messages.GetReply{
Grain: grainAny,
}, nil
}
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
defer span.End()
@@ -126,7 +153,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
}
// ControlPlane: Ping
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
func (s *ControlServer[V]) Ping(ctx context.Context, req *messages.Empty) (*messages.PingReply, error) {
host := s.pool.Hostname()
@@ -139,6 +166,47 @@ func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messag
}, nil
}
func (s *ControlServer[V]) Apply(ctx context.Context, in *messages.ApplyRequest) (*messages.ApplyResult, error) {
msgs := make([]proto.Message, len(in.Messages))
for i, anyMsg := range in.Messages {
msg, err := anyMsg.UnmarshalNew()
if err != nil {
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
}
msgs[i] = msg
}
r, err := s.pool.Apply(ctx, in.Id, msgs...)
if err != nil {
return nil, err
}
grainAny, err := toAny(r)
if err != nil {
return nil, err
}
mutList := make([]*messages.MutationResult, len(in.Messages))
for i, msg := range r.Mutations {
mut, err := anypb.New(msg.Mutation)
if err != nil {
return nil, err
}
var errString *string
if msg.Error != nil {
s := msg.Error.Error()
errString = &s
}
mutList[i] = &messages.MutationResult{
Type: msg.Type,
Message: mut,
Error: errString,
}
}
return &messages.ApplyResult{
State: grainAny,
Mutations: mutList,
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
ctx, span := tracer.Start(ctx, "grpc_negotiate")
@@ -155,7 +223,7 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.Empty) (*messages.ActorIdsReply, error) {
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
defer span.End()
ids := s.pool.GetLocalIds()

View File

@@ -0,0 +1,150 @@
package actor
import (
"context"
"testing"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// MockGrainPool for testing
type mockGrainPool struct {
applied []proto.Message
}
func (m *mockGrainPool) Apply(ctx context.Context, id uint64, mutations ...proto.Message) (*MutationResult[mockGrain], error) {
m.applied = mutations
// Simulate successful application
return &MutationResult[mockGrain]{
Result: mockGrain{},
Mutations: []ApplyResult{
{Type: "AddItem", Mutation: &cart_messages.AddItem{ItemId: 1, Quantity: 2}, Error: nil},
{Type: "RemoveItem", Mutation: &cart_messages.RemoveItem{Id: 1}, Error: nil},
},
}, nil
}
func (m *mockGrainPool) Get(ctx context.Context, id uint64) (*mockGrain, error) {
return &mockGrain{}, nil
}
func (m *mockGrainPool) OwnerHost(id uint64) (Host[mockGrain], bool) {
return nil, false
}
func (m *mockGrainPool) TakeOwnership(id uint64) {}
func (m *mockGrainPool) Hostname() string { return "test-host" }
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) Negotiate(hosts []string) {}
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
func (m *mockGrainPool) RemoveHost(host string) {}
func (m *mockGrainPool) AddRemoteHost(host string) {}
func (m *mockGrainPool) IsHealthy() bool { return true }
func (m *mockGrainPool) IsKnown(host string) bool { return false }
func (m *mockGrainPool) Close() {}
type mockGrain struct{}
func TestApplyRequestWithMutations(t *testing.T) {
// Setup mock pool
pool := &mockGrainPool{}
// Create gRPC server
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
defer server.GracefulStop()
// Create client connection
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
defer conn.Close()
client := control_plane_messages.NewControlPlaneClient(conn)
// Prepare ApplyRequest with multiple Any messages
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
req := &control_plane_messages.ApplyRequest{
Id: 123,
Messages: []*anypb.Any{addItemAny, removeItemAny},
}
// Call Apply
resp, err := client.Apply(context.Background(), req)
if err != nil {
t.Fatalf("Apply failed: %v", err)
}
// Verify response
if resp.State == nil {
t.Errorf("expected State to be non-nil")
}
if len(resp.Mutations) != 2 {
t.Errorf("expected 2 mutation results, got %d", len(resp.Mutations))
}
for i, mut := range resp.Mutations {
if mut.Error != nil {
t.Errorf("expected no error in mutation %d, got %s", i, *mut.Error)
}
}
// Verify mutations were extracted and applied
if len(pool.applied) != 2 {
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
}
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
}
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
}
}
func TestGetRequest(t *testing.T) {
// Setup mock pool
pool := &mockGrainPool{}
// Create gRPC server
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
defer server.GracefulStop()
// Create client connection
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
defer conn.Close()
client := control_plane_messages.NewControlPlaneClient(conn)
// Prepare GetRequest
req := &control_plane_messages.GetRequest{
Id: 123,
}
// Call Get
resp, err := client.Get(context.Background(), req)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
// Verify response
if resp.Grain == nil {
t.Errorf("expected Grain to be non-nil")
}
}

View File

@@ -7,8 +7,8 @@ import (
"reflect"
"sync"
"github.com/gogo/protobuf/proto"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/protobuf/proto"
)
type ApplyResult struct {
@@ -41,14 +41,16 @@ type MutationRegistry interface {
Create(typeName string) (proto.Message, bool)
GetTypeName(msg proto.Message) (string, bool)
RegisterProcessor(processor ...MutationProcessor)
//GetStorageEvent(msg proto.Message) StorageEvent
//FromStorageEvent(event StorageEvent) (proto.Message, error)
RegisterTrigger(trigger ...TriggerHandler)
SetEventChannel(ch chan<- ApplyResult)
}
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
triggers map[reflect.Type][]TriggerHandler
processors []MutationProcessor
eventChannel chan<- ApplyResult
}
var (
@@ -84,6 +86,26 @@ func WithTotals() MutationOption {
}
}
type TriggerHandler interface {
Handle(state any, msg proto.Message) []proto.Message
Name() string
Type() reflect.Type
}
type RegisteredTrigger[V any, I proto.Message] struct {
name string
handler func(state any, msg proto.Message) []proto.Message
msgType reflect.Type
}
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
return &RegisteredTrigger[V, I]{
name: name,
handler: handler,
msgType: reflect.TypeOf((*I)(nil)).Elem(),
}
}
type MutationHandler interface {
Handle(state any, msg proto.Message) error
Name() string
@@ -95,17 +117,26 @@ type MutationHandler interface {
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() T
create func() proto.Message
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
create := func() proto.Message {
var t T
rt := reflect.TypeOf(t)
if rt != nil && rt.Kind() == reflect.Pointer {
return reflect.New(rt.Elem()).Interface().(proto.Message)
}
log.Fatalf("expected to create proto message got %+v", rt)
return nil
}
instance := create()
rt := reflect.TypeOf(instance)
if rt.Kind() == reflect.Ptr {
if rt.Kind() == reflect.Pointer {
rt = rt.Elem()
}
return &RegisteredMutation[V, T]{
@@ -136,6 +167,7 @@ func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
triggers: make(map[reflect.Type][]TriggerHandler),
processors: make([]MutationProcessor, 0),
}
}
@@ -153,6 +185,24 @@ func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
}
}
func (r *ProtoMutationRegistry) RegisterTrigger(triggers ...TriggerHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, trigger := range triggers {
existingTriggers, ok := r.triggers[trigger.Type()]
if !ok {
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
} else {
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
}
}
}
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
r.eventChannel = ch
}
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
@@ -212,13 +262,13 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
}
for _, m := range msg {
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
// Error if any mutation element is nil.
if m == nil {
continue
return results, fmt.Errorf("nil mutation message")
}
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
rv := reflect.ValueOf(m)
if rv.Kind() == reflect.Ptr && rv.IsNil() {
if rv.Kind() == reflect.Pointer && rv.IsNil() {
continue
}
rt := indirectType(reflect.TypeOf(m))
@@ -235,6 +285,25 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
if err != nil {
msgSpan.RecordError(err)
}
if r.eventChannel != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Handle panic from sending to closed channel
log.Printf("event channel closed: %v", r)
}
}()
for _, tr := range r.triggers[rt] {
for _, msg := range tr.Handle(grain, m) {
select {
case r.eventChannel <- msg:
default:
// Channel full or no receiver, skip to avoid blocking
}
}
}
}()
}
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
msgSpan.End()
@@ -251,6 +320,13 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
}
}
}
// Return error for unregistered mutations
for _, res := range results {
if res.Error == ErrMutationNotRegistered {
return results, res.Error
}
}
return results, nil
}

View File

@@ -2,38 +2,37 @@ package actor
import (
"context"
"errors"
"reflect"
"slices"
"testing"
"time"
"git.k6n.net/go-cart-actor/pkg/messages"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
)
type cartState struct {
calls int
lastAdded *messages.AddItem
lastAdded *cart_messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *messages.AddItem) error {
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := msg
state.lastAdded = cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
@@ -47,18 +46,18 @@ func TestRegisteredMutationBasics(t *testing.T) {
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&messages.AddItem{})
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
@@ -67,7 +66,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
@@ -78,7 +77,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
// Apply happy path
state := &cartState{}
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(context.Background(), state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
@@ -90,7 +89,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
}
// Apply nil grain
if _, err := reg.Apply(nil, add); err == nil {
if _, err := reg.Apply(context.Background(), nil, add); err == nil {
t.Fatalf("expected error for nil grain")
}
@@ -100,36 +99,109 @@ func TestRegisteredMutationBasics(t *testing.T) {
}
// Apply unregistered message
if _, err := reg.Apply(context.Background(), state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
if err != ErrMutationNotRegistered {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}
// func TestConcurrentSafeRegistrationLookup(t *testing.T) {
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access.
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
// mut := NewMutation[cartState, *messages.Noop](
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
// func() *messages.Noop { return &messages.Noop{} },
// )
// reg.RegisterMutations(mut)
func TestEventChannel(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
// done := make(chan struct{})
// const workers = 25
// for i := 0; i < workers; i++ {
// go func() {
// for j := 0; j < 100; j++ {
// _, _ = reg.Create("Noop")
// _, _ = reg.GetTypeName(&messages.Noop{})
// _ = reg.Apply(&cartState{}, &messages.Noop{})
// }
// done <- struct{}{}
// }()
// }
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
// for i := 0; i < workers; i++ {
// <-done
// }
// }
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult, 10)
reg.SetEventChannel(eventCh)
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Receive from channel with timeout
select {
case res := <-eventCh:
if res.Type != "AddItem" {
t.Fatalf("expected type AddItem, got %s", res.Type)
}
if res.Error != nil {
t.Fatalf("expected no error, got %v", res.Error)
}
case <-time.After(time.Second):
t.Fatalf("expected to receive event on channel within timeout")
}
}
func TestEventChannelClosed(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult, 10)
reg.SetEventChannel(eventCh)
close(eventCh) // Close the channel to simulate external close
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
// This should not panic due to recover in goroutine
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Test passes if no panic occurs
}
func TestEventChannelUnbufferedNoListener(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult) // unbuffered
reg.SetEventChannel(eventCh)
// No goroutine reading from eventCh
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Since no listener, the send should go to default and not block
// Test passes if Apply completes without hanging
}
// Helpers

View File

@@ -8,7 +8,7 @@ import (
"sync"
"time"
"github.com/gogo/protobuf/proto"
"google.golang.org/protobuf/proto"
)
type SimpleGrainPool[V any] struct {
@@ -18,7 +18,7 @@ type SimpleGrainPool[V any] struct {
mutationRegistry MutationRegistry
spawn func(ctx context.Context, id uint64) (Grain[V], error)
destroy func(grain Grain[V]) error
spawnHost func(host string) (Host, error)
spawnHost func(host string) (Host[V], error)
listeners []LogListener
storage LogStorage[V]
ttl time.Duration
@@ -27,8 +27,8 @@ type SimpleGrainPool[V any] struct {
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]Host
remoteHosts map[string]Host
remoteOwners map[uint64]Host[V]
remoteHosts map[string]Host[V]
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
@@ -38,7 +38,7 @@ type SimpleGrainPool[V any] struct {
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
SpawnHost func(host string) (Host, error)
SpawnHost func(host string) (Host[V], error)
Destroy func(grain Grain[V]) error
TTL time.Duration
PoolSize int
@@ -57,8 +57,8 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
ttl: config.TTL,
poolSize: config.PoolSize,
hostname: config.Hostname,
remoteOwners: make(map[uint64]Host),
remoteHosts: make(map[string]Host),
remoteOwners: make(map[uint64]Host[V]),
remoteHosts: make(map[string]Host[V]),
}
p.purgeTicker = time.NewTicker(time.Minute)
@@ -99,7 +99,7 @@ func (p *SimpleGrainPool[V]) purge() {
}
}
p.localMu.Unlock()
p.forAllHosts(func(remote Host) {
p.forAllHosts(func(remote Host[V]) {
remote.AnnounceExpiry(purgedIds)
})
@@ -136,7 +136,6 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
log.Printf("host %s now owns %d cart ids", host, len(ids))
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
@@ -168,7 +167,7 @@ func (p *SimpleGrainPool[V]) AddRemoteHost(host string) {
p.AddRemote(host)
}
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
}
@@ -200,7 +199,7 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
return remote, nil
}
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
func (p *SimpleGrainPool[V]) initializeRemote(remote Host[V]) {
remotesIds := remote.GetActorIds()
@@ -268,7 +267,7 @@ func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
return ok
}
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
remote.Ping()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
@@ -316,14 +315,14 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]Host, 0, len(p.remoteHosts))
remotes := make([]Host[V], 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
p.forAllHosts(func(remote Host) {
p.forAllHosts(func(remote Host[V]) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
@@ -338,7 +337,7 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
})
}
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
p.remoteMu.RLock()
rh := maps.Clone(p.remoteHosts)
p.remoteMu.RUnlock()
@@ -366,7 +365,7 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
return
}
p.forAllHosts(func(rh Host) {
p.forAllHosts(func(rh Host[V]) {
rh.AnnounceOwnership(p.hostname, ids)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
@@ -385,10 +384,11 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
if err != nil {
return nil, err
}
go p.broadcastOwnership([]uint64{id})
p.localMu.Lock()
p.grains[id] = grain
p.localMu.Unlock()
go p.broadcastOwnership([]uint64{id})
return grain, nil
}
@@ -396,7 +396,7 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
grain, err := p.getOrClaimGrain(ctx, id)
if err != nil {
return nil, err
@@ -421,8 +421,8 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
return nil, err
}
return &MutationResult[*V]{
Result: result,
return &MutationResult[V]{
Result: *result,
Mutations: mutations,
}, nil
}
@@ -437,7 +437,7 @@ func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host[V], bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
@@ -452,7 +452,7 @@ func (p *SimpleGrainPool[V]) Hostname() string {
// Close notifies remotes that this host is shutting down.
func (p *SimpleGrainPool[V]) Close() {
p.forAllHosts(func(rh Host) {
p.forAllHosts(func(rh Host[V]) {
rh.Close()
})

View File

@@ -7,7 +7,7 @@ import (
"io"
"time"
"github.com/gogo/protobuf/proto"
"google.golang.org/protobuf/proto"
)
type StateStorage struct {

View File

@@ -2,11 +2,9 @@ package cart
import (
"encoding/json"
"slices"
"sync"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
@@ -40,26 +38,19 @@ type CartItem struct {
OrgPrice *Price `json:"orgPrice,omitempty"`
Cgm string `json:"cgm,omitempty"`
Tax int
Stock uint16 `json:"stock"`
Quantity int `json:"qty"`
Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
SaleStatus string `json:"saleStatus"`
Marking *Marking `json:"marking,omitempty"`
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
}
type CartDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
Stock uint16 `json:"stock"`
Quantity uint16 `json:"qty"`
Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
SaleStatus string `json:"saleStatus"`
Marking *Marking `json:"marking,omitempty"`
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
}
type CartNotification struct {
@@ -83,55 +74,46 @@ type Notice struct {
Code *string `json:"code,omitempty"`
}
type CartPaymentStatus string
const (
CartPaymentStatusPending CartPaymentStatus = "pending"
CartPaymentStatusFailed CartPaymentStatus = "failed"
CartPaymentStatusSuccess CartPaymentStatus = "success"
CartPaymentStatusCancelled CartPaymentStatus = "partial"
)
type Marking struct {
Type uint32 `json:"type"`
Text string `json:"text"`
}
type GiftcardItem struct {
Id uint32 `json:"id"`
Value Price `json:"value"`
DeliveryDate string `json:"deliveryDate"`
Recipient string `json:"recipient"`
RecipientType string `json:"recipientType"`
Message string `json:"message"`
DesignConfig json.RawMessage `json:"designConfig,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
InventoryReserved bool `json:"inventoryReserved"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
Giftcards []*GiftcardItem `json:"giftcards,omitempty"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
PaymentDeclinedNotices []Notice `json:"paymentDeclinedNotices,omitempty"`
Confirmation *ConfirmationStatus `json:"confirmation,omitempty"`
CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus string `json:"checkoutStatus,omitempty"`
CheckoutCountry string `json:"checkoutCountry,omitempty"`
}
mu sync.RWMutex
lastItemId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Currency string `json:"currency"`
Language string `json:"language"`
Version uint `json:"version"`
InventoryReserved bool `json:"inventoryReserved"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Processing bool `json:"processing"`
//PaymentInProgress uint16 `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
}
type Voucher struct {
@@ -194,15 +176,11 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
return &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Giftcards: []*GiftcardItem{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
@@ -229,7 +207,11 @@ func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
func (c *CartGrain) HandleInventoryChange(change inventory.InventoryChange) {
for _, item := range c.Items {
if item.Sku == change.SKU && change.StockLocationID == *item.StoreId {
l := "se"
if item.StoreId != nil {
l = *item.StoreId
}
if item.Sku == change.SKU && change.StockLocationID == l {
item.Stock = uint16(change.Value)
break
}
@@ -240,33 +222,6 @@ func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := slices.Contains(hasDelivery, item.Id)
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -278,26 +233,6 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
return nil, false
}
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
// updated, err := ApplyRegistered(c, content)
// if err != nil {
// if err == ErrMutationNotRegistered {
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
// }
// return nil, err
// }
// // Sliding TTL: update lastChange only for non-replay successful mutations.
// if updated != nil && !isReplay {
// c.lastChange = time.Now()
// c.lastAccess = time.Now()
// go AppendCartEvent(c.Id, content)
// }
// return updated, nil
// }
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice()
@@ -322,12 +257,7 @@ func (c *CartGrain) UpdateTotals() {
c.TotalPrice.Add(*rowTotal)
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, giftcard := range c.Giftcards {
c.TotalPrice.Add(giftcard.Value)
}
for _, voucher := range c.Vouchers {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
@@ -342,4 +272,5 @@ func (c *CartGrain) UpdateTotals() {
c.TotalPrice.Subtract(*value)
}
}
}

View File

@@ -1,83 +1,86 @@
package cart
import (
"context"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
func NewCartMultationRegistry() actor.MutationRegistry {
type CartMutationContext struct {
reservationService inventory.CartReservationService
}
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
return &CartMutationContext{
reservationService: reservationService,
}
}
func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) {
if quantity <= 0 || c.reservationService == nil {
return nil, nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
ttl := time.Minute * 15
endTime := time.Now().Add(ttl)
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
CartID: inventory.CartID(cartId.String()),
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(sku),
LocationID: l,
},
TTL: ttl,
Quantity: uint32(quantity),
})
if err != nil {
return nil, err
}
return &endTime, nil
}
func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil {
return true
}
return item.Cgm == "55010"
}
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
if c.reservationService == nil {
return nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
}
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(AddItem, func() *messages.AddItem {
return &messages.AddItem{}
}),
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
return &messages.ChangeQuantity{}
}),
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
return &messages.RemoveItem{}
}),
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
return &messages.InitializeCheckout{}
}),
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
return &messages.OrderCreated{}
}),
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
return &messages.RemoveDelivery{}
}),
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
return &messages.SetDelivery{}
}),
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
return &messages.SetPickupPoint{}
}),
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}),
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
return &messages.AddVoucher{}
}),
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{}
}),
actor.NewMutation(InventoryReserved, func() *messages.InventoryReserved {
return &messages.InventoryReserved{}
}),
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
return &messages.PreConditionFailed{}
}),
actor.NewMutation(SetUserId, func() *messages.SetUserId {
return &messages.SetUserId{}
}),
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
return &messages.LineItemMarking{}
}),
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{}
}),
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{}
}),
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
return &messages.PaymentDeclined{}
}),
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}),
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{}
}),
actor.NewMutation(AddGiftcard, func() *messages.AddGiftcard {
return &messages.AddGiftcard{}
}),
actor.NewMutation(RemoveGiftcard, func() *messages.RemoveGiftcard {
return &messages.RemoveGiftcard{}
}),
actor.NewMutation(context.AddItem),
actor.NewMutation(context.ChangeQuantity),
actor.NewMutation(context.RemoveItem),
actor.NewMutation(ClearCart),
actor.NewMutation(AddVoucher),
actor.NewMutation(RemoveVoucher),
actor.NewMutation(UpsertSubscriptionDetails),
actor.NewMutation(SetUserId),
actor.NewMutation(LineItemMarking),
actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SubscriptionAdded),
// actor.NewMutation(SubscriptionRemoved),
)
return reg

View File

@@ -1,48 +0,0 @@
package cart
import (
"testing"
)
// helper to create a cart grain with items and deliveries
func newTestCart() *CartGrain {
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
}
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
c := newTestCart()
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
c.Items = []*CartItem{
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
}
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
c.UpdateTotals()
// Expected totals: sum inc vat of items * qty plus delivery
// item1 total inc = 1250*2 = 2500
// item2 total inc = 2000*1 = 2000
// delivery inc = 4900
expectedInc := int64(2500 + 2000 + 4900)
if c.TotalPrice.IncVat != expectedInc {
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
}
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
if c.TotalDiscount.IncVat != 500 {
t.Fatalf("TotalDiscount expected 500 got %d", c.TotalDiscount.IncVat)
}
}
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
c := newTestCart()
c.UpdateTotals()
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
t.Fatalf("expected zero totals got %+v", c)
}
}

View File

@@ -4,6 +4,8 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/actor"
)
// cart_id.go
@@ -34,7 +36,7 @@ import (
//
// ---------------------------------------------------------------------------
type CartId uint64
type CartId actor.GrainId
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

View File

@@ -1,41 +0,0 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"google.golang.org/protobuf/proto"
)
func AddGiftcard(grain *CartGrain, req *messages.AddGiftcard) error {
if req.Giftcard == nil {
return fmt.Errorf("giftcard cannot be nil")
}
if req.Giftcard.Value <= 0 {
return fmt.Errorf("giftcard value must be positive")
}
grain.lastGiftcardId++
designConfig := json.RawMessage{}
if req.Giftcard.DesignConfig != nil {
// Convert Any to RawMessage
data, err := proto.Marshal(req.Giftcard.DesignConfig)
if err != nil {
return fmt.Errorf("failed to marshal designConfig: %w", err)
}
designConfig = data
}
value := NewPriceFromIncVat(req.Giftcard.Value, 25) // Assuming 25% tax; adjust as needed
item := &GiftcardItem{
Id: grain.lastGiftcardId,
Value: *value,
DeliveryDate: req.Giftcard.DeliveryDate,
Recipient: req.Giftcard.Recipient,
RecipientType: req.Giftcard.RecipientType,
Message: req.Giftcard.Message,
DesignConfig: designConfig,
}
grain.Giftcards = append(grain.Giftcards, item)
grain.UpdateTotals()
return nil
}

View File

@@ -1,9 +1,14 @@
package cart
import (
"context"
"errors"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb"
)
// mutation_add_item.go
@@ -12,15 +17,17 @@ import (
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
// - Validates quantity > 0
// - If an item with same SKU exists -> increases quantity
// - Else creates a new CartItem with computed tax amounts
// - Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
var ErrPaymentInProgress = errors.New("payment in progress")
func AddItem(g *CartGrain, m *messages.AddItem) error {
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
ctx := context.Background()
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
@@ -38,12 +45,23 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
if !sameStore {
continue
}
existing.Quantity += int(m.Quantity)
if c.UseReservations(existing) {
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
log.Printf("failed to release item %d: %v", existing.Id, err)
}
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
if err != nil {
return err
}
existing.ReservationEndTime = endTime
}
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
}
return nil
}
@@ -58,10 +76,15 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
needsReservation := true
if m.ReservationEndTime != nil {
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
}
cartItem := &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: int(m.Quantity),
Quantity: uint16(m.Quantity),
Sku: m.Sku,
Tax: int(taxRate * 100),
Meta: &ItemMeta{
@@ -91,7 +114,21 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
ArticleType: m.ArticleType,
StoreId: m.StoreId,
})
}
if needsReservation && c.UseReservations(cartItem) {
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
if endTime != nil {
m.ReservationEndTime = timestamppb.New(*endTime)
t := m.ReservationEndTime.AsTime()
cartItem.ReservationEndTime = &t
}
}
g.Items = append(g.Items, cartItem)
g.UpdateTotals()
return nil
}

View File

@@ -4,7 +4,7 @@ import (
"slices"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
@@ -15,6 +15,9 @@ func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
StatusCode: 400,
}
}
if g.CheckoutStatus != nil {
return ErrPaymentInProgress
}
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id

View File

@@ -1,9 +1,12 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_change_quantity.go
@@ -26,11 +29,13 @@ import (
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
ctx := context.Background()
foundIndex := -1
for i, it := range g.Items {
if it.Id == uint32(m.Id) {
@@ -44,12 +49,37 @@ func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
if m.Quantity <= 0 {
// Remove the item
itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
}
}
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
g.UpdateTotals()
return nil
}
item := g.Items[foundIndex]
if item == nil {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if c.UseReservations(item) {
if item.ReservationEndTime != nil {
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", item.Sku, item.StoreId)
}
g.Items[foundIndex].Quantity = int(m.Quantity)
}
endTime, err := c.ReserveItem(ctx, g.Id, item.Sku, item.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
item.ReservationEndTime = endTime
}
item.Quantity = uint16(m.Quantity)
g.UpdateTotals()
return nil
}

View File

@@ -0,0 +1,26 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
if g.CheckoutStatus != nil {
return fmt.Errorf("ClearCart: cart is in checkout")
}
// Clear items, vouchers, etc., but keep userId, etc.
g.Items = g.Items[:0]
g.Vouchers = g.Vouchers[:0]
g.Notifications = g.Notifications[:0]
g.OrderReference = ""
g.Processing = false
// g.InventoryReserved = false maybe should release inventory
g.UpdateTotals()
return nil
}

View File

@@ -1,21 +0,0 @@
package cart
import (
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
if grain.Confirmation == nil {
grain.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
grain.Confirmation.ViewCount++
grain.Confirmation.LastViewedAt = time.Now()
}
return nil
}

View File

@@ -1,22 +0,0 @@
package cart
import (
"errors"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"github.com/google/uuid"
)
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
if len(grain.Items) == 0 {
return errors.New("cannot checkout empty cart")
}
if req.Terms != "accepted" {
return errors.New("terms must be accepted")
}
// Validate other fields as needed
grain.CheckoutOrderId = uuid.New().String()
grain.CheckoutStatus = "pending"
grain.CheckoutCountry = req.Country
return nil
}

View File

@@ -1,44 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external Klarna checkout session
// has been successfully created or updated. It persists the Klarna
// order reference / status and marks the cart as having a payment in progress.
//
// Behavior:
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
// - Sets PaymentStatus to the current Klarna status.
// - Sets / updates PaymentInProgress flag.
// - Does NOT alter pricing or line items (so no totals recalculation).
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty (integrity guard).
//
// Concurrency:
// - Relies on upstream mutation serialization for a single grain. If
// parallel checkout attempts are possible, add higher-level guards
// (e.g. reject if PaymentInProgress already true unless reusing
// the same OrderReference).
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("InitializeCheckout: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
}

View File

@@ -1,8 +0,0 @@
package cart
import "git.k6n.net/go-cart-actor/pkg/messages"
func InventoryReserved(g *CartGrain, m *messages.InventoryReserved) error {
g.InventoryReserved = true
return nil
}

View File

@@ -3,7 +3,7 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {

View File

@@ -1,21 +0,0 @@
package cart
import (
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
grain.PaymentStatus = "declined"
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
Timestamp: time.Now(),
Message: req.Message,
Code: req.Code,
})
// Optionally clear checkout order if in progress
if grain.CheckoutOrderId != "" {
grain.CheckoutOrderId = ""
}
return nil
}

View File

@@ -1,7 +0,0 @@
package cart
import messages "git.k6n.net/go-cart-actor/pkg/messages"
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
return nil
}

View File

@@ -1,18 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func RemoveGiftcard(grain *CartGrain, req *messages.RemoveGiftcard) error {
for i, item := range grain.Giftcards {
if item.Id == req.Id {
grain.Giftcards = append(grain.Giftcards[:i], grain.Giftcards[i+1:]...)
grain.UpdateTotals()
return nil
}
}
return fmt.Errorf("giftcard with ID %d not found", req.Id)
}

View File

@@ -1,9 +1,12 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_remove_item.go
@@ -22,10 +25,11 @@ import (
// - If multiple lines somehow shared the same Id (should not happen), only
// the first match would be removed—data integrity relies on unique line Ids.
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := uint32(m.Id)
index := -1
@@ -39,6 +43,14 @@ func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
}
item := g.Items[index]
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
if err != nil {
log.Printf("unable to release item reservation")
}
}
g.Items = append(g.Items[:index], g.Items[index+1:]...)
g.UpdateTotals()
return nil

View File

@@ -3,10 +3,11 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil

View File

@@ -1,96 +0,0 @@
package cart
import (
"fmt"
"slices"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
//
// Registers the SetDelivery mutation.
//
// Semantics (mirrors legacy switch logic):
// - If the payload specifies an explicit list of item IDs (payload.Items):
// - Each referenced cart line must exist.
// - None of the referenced items may already belong to a delivery.
// - Only those items are associated with the new delivery.
// - If payload.Items is empty:
// - All items currently without any delivery are associated with the new delivery.
// - A new delivery line is created with:
// - Auto-incremented delivery ID (cart-local)
// - Provider from payload
// - Fixed price (currently hard-coded: 4900 minor units) adjust as needed
// - Optional PickupPoint copied from payload
// - Cart totals are recalculated (WithTotals)
//
// Error cases:
// - Referenced item does not exist
// - Referenced item already has a delivery
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
//
// Concurrency:
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
// Item scans are read-only and performed outside the lock for simplicity;
// if stricter guarantees are needed, widen the lock section.
//
// Future extension points:
// - Variable delivery pricing (based on weight, distance, provider, etc.)
// - Validation of provider codes
// - Multi-currency delivery pricing
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("SetDelivery: provider is empty")
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]uint32, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := uint32(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
found = true
break
}
}
if !found {
return fmt.Errorf("SetDelivery: item id %d not found", id)
}
if slices.Contains(withDelivery, id) {
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
}
targetItems = append(targetItems, id)
}
}
if len(targetItems) == 0 {
return fmt.Errorf("SetDelivery: no eligible items to attach")
}
// Append new delivery
g.mu.Lock()
g.lastDeliveryId++
newId := g.lastDeliveryId
g.Deliveries = append(g.Deliveries, &CartDelivery{
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: *NewPriceFromIncVat(4900, 25.0),
Items: targetItems,
})
g.mu.Unlock()
return nil
}

View File

@@ -3,7 +3,7 @@ package cart
import (
"errors"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {

View File

@@ -3,10 +3,11 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
for i, item := range grain.Items {
if item.Id == req.ItemId {
grain.Items[i].SubscriptionDetailsId = req.DetailsId

View File

@@ -1,778 +0,0 @@
package cart
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/gogo/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// ----------------------
// Helper constructors
// ----------------------
func newTestGrain() *CartGrain {
return NewCartGrain(123, time.Now())
}
func newRegistry() actor.MutationRegistry {
return NewCartMultationRegistry()
}
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
Price: price,
Quantity: qty,
// Tax left 0 -> handler uses default 25%
StoreId: storePtr,
}
}
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
return &messages.ChangeQuantity{Id: id, Quantity: qty}
}
func msgRemoveItem(id uint32) *messages.RemoveItem {
return &messages.RemoveItem{Id: id}
}
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
uitems := make([]uint32, len(items))
copy(uitems, items)
return &messages.SetDelivery{Provider: provider, Items: uitems}
}
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
return &messages.SetPickupPoint{
DeliveryId: deliveryId,
Id: id,
Name: ptr("Pickup"),
Address: ptr("Street 1"),
City: ptr("Town"),
Zip: ptr("12345"),
Country: ptr("SE"),
}
}
func msgClearCart() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}
func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher {
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
}
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
return &messages.RemoveVoucher{Id: id}
}
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
}
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
return &messages.OrderCreated{OrderId: orderId, Status: status}
}
func msgSetUserId(userId string) *messages.SetUserId {
return &messages.SetUserId{UserId: userId}
}
func msgLineItemMarking(id uint32, typ uint32, marking string) *messages.LineItemMarking {
return &messages.LineItemMarking{Id: id, Type: typ, Marking: marking}
}
func msgRemoveLineItemMarking(id uint32) *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{Id: id}
}
func msgSubscriptionAdded(itemId uint32, detailsId, orderRef string) *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{ItemId: itemId, DetailsId: detailsId, OrderReference: orderRef}
}
func msgPaymentDeclined(message, code string) *messages.PaymentDeclined {
return &messages.PaymentDeclined{Message: message, Code: &code}
}
func msgConfirmationViewed() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}
func msgCreateCheckoutOrder(terms, country string) *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{Terms: terms, Country: country}
}
func msgAddGiftcard(value int64, deliveryDate, recipient, recipientType, message string, designConfig *anypb.Any) *messages.AddGiftcard {
return &messages.AddGiftcard{
Giftcard: &messages.GiftcardItem{
Value: value,
DeliveryDate: deliveryDate,
Recipient: recipient,
RecipientType: recipientType,
Message: message,
DesignConfig: designConfig,
},
}
}
func msgRemoveGiftcard(id uint32) *messages.RemoveGiftcard {
return &messages.RemoveGiftcard{Id: id}
}
func ptr[T any](v T) *T { return &v }
// ----------------------
// Apply helpers
// ----------------------
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
t.Helper()
results, err := reg.Apply(context.Background(), g, msg)
if err != nil {
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
}
if len(results) != 1 {
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
}
return results[0]
}
// Expect success (nil error inside ApplyResult).
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error != nil {
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
}
}
// Expect an error matching substring.
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error == nil {
t.Fatalf("expected error applying %T, got nil", msg)
}
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
}
}
// ----------------------
// Tests
// ----------------------
func TestMutationRegistryCoverage(t *testing.T) {
reg := newRegistry()
expected := []string{
"AddItem",
"ChangeQuantity",
"RemoveItem",
"InitializeCheckout",
"OrderCreated",
"RemoveDelivery",
"SetDelivery",
"SetPickupPoint",
"ClearCartRequest",
"AddVoucher",
"RemoveVoucher",
"UpsertSubscriptionDetails",
"InventoryReserved",
"PreConditionFailed",
"SetUserId",
"LineItemMarking",
"RemoveLineItemMarking",
"SubscriptionAdded",
"PaymentDeclined",
"ConfirmationViewed",
"CreateCheckoutOrder",
"AddGiftcard",
"RemoveGiftcard",
}
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
for _, want := range expected {
if !slices.Contains(names, want) {
t.Fatalf("registry missing mutation %s; got %v", want, names)
}
}
// Create() by name returns correct concrete type.
for _, name := range expected {
msg, ok := reg.Create(name)
if !ok {
t.Fatalf("Create failed for %s", name)
}
rt := reflect.TypeOf(msg)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
if rt.Name() != name {
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
}
}
// Unregistered create
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
}
// GetTypeName sanity
add := &messages.AddItem{}
nm, ok := reg.GetTypeName(add)
if !ok || nm != "AddItem" {
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
}
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{})
if err != nil {
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
}
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
}
}
func TestAddItemAndMerging(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Merge scenario (same SKU + same store pointer)
add1 := msgAddItem("SKU-1", 1000, 2, nil)
applyOK(t, reg, g, add1)
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
// Different store pointer -> new line
store := "S1"
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
if len(g.Items) != 2 {
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
}
// Same store pointer & SKU -> merge with second line
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
}
// Invalid quantity
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
}
func TestChangeQuantityBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
id := g.Items[0].Id
// Increase quantity
applyOK(t, reg, g, msgChangeQty(id, 5))
if g.Items[0].Quantity != 5 {
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
}
// Remove item by setting <=0
applyOK(t, reg, g, msgChangeQty(id, 0))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
// Not found
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
}
func TestRemoveItemBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgRemoveItem(id))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
}
func TestDeliveryMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
i1 := g.Items[0].Id
// Explicit items
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
}
// Attempt to attach an already-delivered item
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
// Attach remaining item via empty list (auto include items without delivery)
applyOK(t, reg, g, msgSetDelivery("DHL"))
if len(g.Deliveries) != 2 {
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
}
// Non-existent item
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
// No eligible items left
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
// Set pickup point on first delivery
did := g.Deliveries[0].Id
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
}
// Bad delivery id
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
// Remove delivery
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
}
// Remove delivery not found
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
}
func TestClearCart(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
applyOK(t, reg, g, msgClearCart())
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
}
}
func TestVoucherMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
if len(g.Vouchers) != 1 {
t.Fatalf("voucher not stored")
}
if g.TotalDiscount.IncVat != 5000 {
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
}
// Duplicate voucher code
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
// Add a large voucher (should not apply because value > total price)
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
if len(g.Vouchers) != 2 {
t.Fatalf("expected second voucher stored")
}
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove existing voucher
firstId := g.Vouchers[0].Id
applyOK(t, reg, g, msgRemoveVoucher(firstId))
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
t.Fatalf("voucher id %d not removed", firstId)
}
// After removing PROMO, BIG remains but is not applied (exceeds price)
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
t.Fatalf("totals incorrect after removal discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove not applied
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
}
func TestCheckoutMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
}
func TestSubscriptionDetailsMutation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Upsert new (Id == nil)
msgNew := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFF1",
SigningType: "TYPE1",
}
applyOK(t, reg, g, msgNew)
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
}
// Capture created id
var createdId string
for k := range g.SubscriptionDetails {
createdId = k
}
// Update existing
msgUpdate := &messages.UpsertSubscriptionDetails{
Id: &createdId,
OfferingCode: "OFF2",
SigningType: "TYPE2",
}
applyOK(t, reg, g, msgUpdate)
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
}
// Update non-existent
badId := "NON_EXISTENT"
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
// Nil mutation should be ignored and produce zero results.
resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil))
if errNil != nil {
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
}
if len(resultsNil) != 0 {
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
}
}
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
func TestRegistryDefensiveErrors(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Nil grain
results, err := reg.Apply(context.Background(), nil, &messages.AddItem{})
if err == nil {
t.Fatalf("expected error for nil grain")
}
if len(results) != 0 {
t.Fatalf("expected no results for nil grain")
}
// Nil message slice
results, _ = reg.Apply(context.Background(), g, nil)
if len(results) != 0 {
t.Fatalf("expected no results when message slice nil")
}
}
type SubscriptionDetailsRequest struct {
Id *string `json:"id,omitempty"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{
Id: sd.Id,
OfferingCode: sd.OfferingCode,
SigningType: sd.SigningType,
Data: &anypb.Any{Value: sd.Data},
}
}
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Valid JSON on create
jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value":"test","a":1}}`
var validCreate SubscriptionDetailsRequest
if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, validCreate.ToMessage())
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
}
var id string
for k := range g.SubscriptionDetails {
id = k
}
if string(g.SubscriptionDetails[id].Meta) != `{"value":"test","a":1}` {
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Update with valid JSON replaces meta
jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id)
var updateValid messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &updateValid)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Invalid JSON on create
jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}`
var invalidCreate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil {
t.Fatal(err)
}
res := applyOne(t, reg, g, &invalidCreate)
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on create, got %v", res.Error)
}
// Invalid JSON on update
jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id)
var badUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil {
t.Fatal(err)
}
res2 := applyOne(t, reg, g, &badUpdate)
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
}
// Empty Data should not overwrite existing meta
jsonStr5 := fmt.Sprintf(`{"id": "%s"}`, id)
var emptyUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &emptyUpdate)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
}
}
func TestSetUserId(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgSetUserId("user123"))
if g.userId != "user123" {
t.Fatalf("expected userId=user123, got %s", g.userId)
}
applyErrorContains(t, reg, g, msgSetUserId(""), "cannot be empty")
}
func TestLineItemMarking(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("MARK", 1000, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Gift message"))
if g.Items[0].Marking == nil || g.Items[0].Marking.Type != 1 || g.Items[0].Marking.Text != "Gift message" {
t.Fatalf("marking not set correctly: %+v", g.Items[0].Marking)
}
applyErrorContains(t, reg, g, msgLineItemMarking(9999, 2, "Test"), "not found")
}
func TestRemoveLineItemMarking(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("REMOVE", 1000, 1, nil))
id := g.Items[0].Id
// First set a marking
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Test marking"))
if g.Items[0].Marking == nil || g.Items[0].Marking.Text != "Test marking" {
t.Fatalf("marking not set")
}
// Now remove it
applyOK(t, reg, g, msgRemoveLineItemMarking(id))
if g.Items[0].Marking != nil {
t.Fatalf("marking not removed")
}
applyErrorContains(t, reg, g, msgRemoveLineItemMarking(9999), "not found")
}
func TestSubscriptionAdded(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("SUB", 1000, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgSubscriptionAdded(id, "det123", "ord456"))
if g.Items[0].SubscriptionDetailsId != "det123" || g.Items[0].OrderReference != "ord456" || !g.Items[0].IsSubscribed {
t.Fatalf("subscription not added: detailsId=%s orderRef=%s isSubscribed=%v",
g.Items[0].SubscriptionDetailsId, g.Items[0].OrderReference, g.Items[0].IsSubscribed)
}
applyErrorContains(t, reg, g, msgSubscriptionAdded(9999, "", ""), "not found")
}
func TestPaymentDeclined(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
g.CheckoutOrderId = "test-order"
applyOK(t, reg, g, msgPaymentDeclined("Payment failed due to insufficient funds", "INSUFFICIENT_FUNDS"))
if g.PaymentStatus != "declined" || g.CheckoutOrderId != "" {
t.Fatalf("payment declined not handled: status=%s checkoutId=%s", g.PaymentStatus, g.CheckoutOrderId)
}
if len(g.PaymentDeclinedNotices) != 1 {
t.Fatalf("expected 1 notice, got %d", len(g.PaymentDeclinedNotices))
}
notice := g.PaymentDeclinedNotices[0]
if notice.Message != "Payment failed due to insufficient funds" {
t.Fatalf("notice message not set correctly: %s", notice.Message)
}
if notice.Code == nil || *notice.Code != "INSUFFICIENT_FUNDS" {
t.Fatalf("notice code not set correctly: %v", notice.Code)
}
if notice.Timestamp.IsZero() {
t.Fatalf("notice timestamp not set")
}
}
func TestConfirmationViewed(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Initial state
if g.Confirmation != nil {
t.Fatalf("confirmation should be nil, got %v", g.Confirmation)
}
// First view
applyOK(t, reg, g, msgConfirmationViewed())
if g.Confirmation.ViewCount != 1 {
t.Fatalf("view count should be 1, got %d", g.Confirmation.ViewCount)
}
if g.Confirmation.LastViewedAt.IsZero() {
t.Fatalf("ConfirmationLastViewedAt not set")
}
firstTime := g.Confirmation.LastViewedAt
// Second view
applyOK(t, reg, g, msgConfirmationViewed())
if g.Confirmation.ViewCount != 2 {
t.Fatalf("view count should be 2, got %d", g.Confirmation.ViewCount)
}
if g.Confirmation.LastViewedAt == firstTime {
t.Fatalf("ConfirmationLastViewedAt should have updated")
}
}
func TestCreateCheckoutOrder(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("CHECKOUT", 1000, 1, nil))
applyOK(t, reg, g, msgCreateCheckoutOrder("accepted", "SE"))
if g.CheckoutOrderId == "" || g.CheckoutStatus != "pending" || g.CheckoutCountry != "SE" {
t.Fatalf("checkout order not created: id=%s status=%s country=%s",
g.CheckoutOrderId, g.CheckoutStatus, g.CheckoutCountry)
}
// Empty cart
g2 := newTestGrain()
applyErrorContains(t, reg, g2, msgCreateCheckoutOrder("accepted", ""), "empty cart")
// Terms not accepted
applyErrorContains(t, reg, g, msgCreateCheckoutOrder("no", ""), "terms must be accepted")
}
func TestAddGiftcard(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
designConfig, _ := anypb.New(&messages.AddItem{}) // example
applyOK(t, reg, g, msgAddGiftcard(5000, "2023-12-25", "John", "email", "Happy Birthday!", designConfig))
if len(g.Giftcards) != 1 {
t.Fatalf("expected 1 giftcard, got %d", len(g.Giftcards))
}
gc := g.Giftcards[0]
if gc.Value.IncVat != 5000 || gc.DeliveryDate != "2023-12-25" || gc.Recipient != "John" || gc.RecipientType != "email" || gc.Message != "Happy Birthday!" {
t.Fatalf("giftcard not set correctly: %+v", gc)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("total price not updated, got %d", g.TotalPrice.IncVat)
}
// Test invalid value
applyErrorContains(t, reg, g, msgAddGiftcard(0, "", "", "", "", nil), "must be positive")
}
func TestRemoveGiftcard(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddGiftcard(1000, "2023-01-01", "Jane", "sms", "Cheers!", nil))
id := g.Giftcards[0].Id
applyOK(t, reg, g, msgRemoveGiftcard(id))
if len(g.Giftcards) != 0 {
t.Fatalf("giftcard not removed")
}
if g.TotalPrice.IncVat != 0 {
t.Fatalf("total price not updated after removal, got %d", g.TotalPrice.IncVat)
}
applyErrorContains(t, reg, g, msgRemoveGiftcard(id), "not found")
}

View File

@@ -4,13 +4,14 @@ import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
metaBytes := m.Data.GetValue()
// Create new subscription details when Id is nil.

View File

@@ -87,6 +87,34 @@ func (p Price) MarshalJSON() ([]byte, error) {
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
func (p *Price) UnmarshalJSON(data []byte) error {
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
p.IncVat = w.IncVat
if len(w.Vat) > 0 {
p.VatRates = make(map[float32]int64, len(w.Vat))
for rateStr, amount := range w.Vat {
rate, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
return err
}
p.VatRates[float32(rate)] = amount
}
} else {
p.VatRates = make(map[float32]int64)
}
return nil
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {

View File

@@ -0,0 +1,195 @@
package checkout
import (
"encoding/json"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// CheckoutId is the same as CartId for simplicity
type CheckoutId = cart.CartId
type PickupPoint struct {
DeliveryId uint32 `json:"deliveryId,omitempty"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
Country *string `json:"country,omitempty"`
}
type CheckoutDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price cart.Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPoint `json:"pickupPoint,omitempty"`
}
type PaymentStatus string
type CheckoutPaymentStatus PaymentStatus
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusSuccess PaymentStatus = "success"
CheckoutPaymentStatusPending CheckoutPaymentStatus = "pending"
CheckoutPaymentStatusFailed CheckoutPaymentStatus = "failed"
CheckoutPaymentStatusSuccess CheckoutPaymentStatus = "success"
CheckoutPaymentStatusCancelled CheckoutPaymentStatus = "partial"
)
type Payment struct {
PaymentId string `json:"paymentId"`
Status PaymentStatus `json:"status"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider,omitempty"`
Method *string `json:"method,omitempty"`
SessionData *json.RawMessage `json:"sessionData,omitempty"`
Events []PaymentEvent `json:"events,omitempty"`
ProcessorReference *string `json:"processorReference,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type PaymentEvent struct {
Name string `json:"name"`
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
func (p *Payment) IsSettled() bool {
if p == nil {
return false
}
switch p.Status {
case PaymentStatusSuccess:
return true
default:
return false
}
}
type ContactDetails struct {
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"`
}
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
}
type CheckoutGrain struct {
mu sync.RWMutex
lastDeliveryId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time
Version uint32 `json:"version"`
Id CheckoutId `json:"id"`
CartId cart.CartId `json:"cartId"`
CartVersion uint64 `json:"cartVersion"`
CartState *cart.CartGrain `json:"cartState"` // snapshot of items
CartTotalPrice *cart.Price `json:"cartTotalPrice"`
OrderId *string `json:"orderId"`
Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"`
PaymentInProgress uint16 `json:"paymentInProgress"`
InventoryReserved bool `json:"inventoryReserved"`
Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
ContactDetails *ContactDetails `json:"contactDetails,omitempty"`
}
func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain {
r := &CheckoutGrain{
lastDeliveryId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
Id: CheckoutId(id),
CartId: cartId,
CartVersion: cartVersion,
Deliveries: []*CheckoutDelivery{},
Payments: []*Payment{},
}
if cartState != nil {
r.CartState = cartState
r.CartTotalPrice = cartState.TotalPrice
}
return r
}
func (c *CheckoutGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CheckoutGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CheckoutGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CheckoutGrain) GetCurrentState() (*CheckoutGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func (c *CheckoutGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CheckoutGrain) FindPayment(paymentId string) (*Payment, bool) {
if paymentId == "" {
return nil, false
}
for _, payment := range c.Payments {
if payment != nil && payment.PaymentId == paymentId {
return payment, true
}
}
return nil, false
}
func (c *CheckoutGrain) SettledPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
settled := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment != nil && payment.IsSettled() {
settled = append(settled, payment)
}
}
if len(settled) == 0 {
return nil
}
return settled
}
func (c *CheckoutGrain) OpenPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
pending := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment == nil {
continue
}
if !payment.IsSettled() {
pending = append(pending, payment)
}
}
if len(pending) == 0 {
return nil
}
return pending
}

View File

@@ -0,0 +1,32 @@
package checkout
import (
"git.k6n.net/go-cart-actor/pkg/actor"
)
type CheckoutMutationContext struct {
// Add any services needed, e.g., for delivery calculations, but since inventory is pre-handled, maybe none
}
func NewCheckoutMutationContext() *CheckoutMutationContext {
return &CheckoutMutationContext{}
}
func NewCheckoutMutationRegistry(ctx *CheckoutMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(HandleInitializeCheckout),
actor.NewMutation(HandlePaymentStarted),
actor.NewMutation(HandlePaymentCompleted),
actor.NewMutation(HandlePaymentDeclined),
actor.NewMutation(HandlePaymentEvent),
actor.NewMutation(HandleConfirmationViewed),
actor.NewMutation(HandleOrderCreated),
actor.NewMutation(HandleInventoryReserved),
actor.NewMutation(HandleSetDelivery),
actor.NewMutation(HandleSetPickupPoint),
actor.NewMutation(HandleRemoveDelivery),
actor.NewMutation(HandleContactDetailsUpdated),
)
return reg
}

View File

@@ -0,0 +1,26 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error {
if m == nil {
return fmt.Errorf("ConfirmationViewed: nil payload")
}
if g.Confirmation != nil {
g.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
g.Confirmation.ViewCount++
g.Confirmation.LastViewedAt = time.Now()
}
return nil
}

View File

@@ -0,0 +1,30 @@
package checkout
import (
"fmt"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// HandleContactDetailsUpdated mutation
func HandleContactDetailsUpdated(g *CheckoutGrain, m *checkout_messages.ContactDetailsUpdated) error {
if m == nil {
return fmt.Errorf("HandleContactDetailsUpdated: nil payload")
}
if g.ContactDetails == nil {
g.ContactDetails = &ContactDetails{}
}
if m.Email != nil {
g.ContactDetails.Email = m.Email
}
if m.Phone != nil {
g.ContactDetails.Phone = m.Phone
}
if m.Name != nil {
g.ContactDetails.Name = m.Name
}
return nil
}

View File

@@ -0,0 +1,94 @@
package checkout
import (
"testing"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func TestHandleContactDetailsUpdated(t *testing.T) {
tests := []struct {
name string
initial *ContactDetails
update *checkout_messages.ContactDetailsUpdated
expected *ContactDetails
}{
{
name: "update all fields",
initial: nil,
update: &checkout_messages.ContactDetailsUpdated{
Email: stringPtr("test@example.com"),
Phone: stringPtr("123456789"),
Name: stringPtr("John Doe"),
},
expected: &ContactDetails{
Email: stringPtr("test@example.com"),
Phone: stringPtr("123456789"),
Name: stringPtr("John Doe"),
},
},
{
name: "update partial fields",
initial: &ContactDetails{
Email: stringPtr("old@example.com"),
Phone: nil,
Name: stringPtr("Old Name"),
},
update: &checkout_messages.ContactDetailsUpdated{
Phone: stringPtr("987654321"),
},
expected: &ContactDetails{
Email: stringPtr("old@example.com"),
Phone: stringPtr("987654321"),
Name: stringPtr("Old Name"),
},
},
{
name: "nil message",
initial: nil,
update: nil,
expected: &ContactDetails{}, // but error expected
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grain := &CheckoutGrain{
ContactDetails: tt.initial,
}
err := HandleContactDetailsUpdated(grain, tt.update)
if tt.name == "nil message" {
if err == nil {
t.Errorf("expected error for nil message, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if grain.ContactDetails == nil && tt.expected != nil {
t.Errorf("expected contact details, got nil")
} else if grain.ContactDetails != nil && tt.expected == nil {
t.Errorf("expected nil contact details, got %v", grain.ContactDetails)
} else if grain.ContactDetails != nil && tt.expected != nil {
if (grain.ContactDetails.Email == nil && tt.expected.Email != nil) || (grain.ContactDetails.Email != nil && tt.expected.Email == nil) || (grain.ContactDetails.Email != nil && *grain.ContactDetails.Email != *tt.expected.Email) {
t.Errorf("email mismatch: got %v, expected %v", grain.ContactDetails.Email, tt.expected.Email)
}
if (grain.ContactDetails.Phone == nil && tt.expected.Phone != nil) || (grain.ContactDetails.Phone != nil && tt.expected.Phone == nil) || (grain.ContactDetails.Phone != nil && *grain.ContactDetails.Phone != *tt.expected.Phone) {
t.Errorf("phone mismatch: got %v, expected %v", grain.ContactDetails.Phone, tt.expected.Phone)
}
if (grain.ContactDetails.Name == nil && tt.expected.Name != nil) || (grain.ContactDetails.Name != nil && tt.expected.Name == nil) || (grain.ContactDetails.Name != nil && *grain.ContactDetails.Name != *tt.expected.Name) {
t.Errorf("name mismatch: got %v, expected %v", grain.ContactDetails.Name, tt.expected.Name)
}
}
})
}
}
func stringPtr(s string) *string {
return &s
}

View File

@@ -0,0 +1,55 @@
package checkout
import (
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPickupPoint(p *messages.PickupPoint, deliveryId uint32) *PickupPoint {
if p == nil {
return nil
}
return &PickupPoint{
Id: p.Id,
Name: p.Name,
Address: p.Address,
City: p.City,
Country: p.Country,
Zip: p.Zip,
}
}
// HandleSetDelivery mutation
// HandleSetDelivery mutation
func HandleSetDelivery(g *CheckoutGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("HandleSetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("HandleSetDelivery: missing provider")
}
// Check if delivery already exists, update or add
for _, d := range g.Deliveries {
if d.Provider == m.Provider {
// Update existing
d.Items = m.Items
d.PickupPoint = asPickupPoint(m.PickupPoint, d.Id)
return nil
}
}
// Add new delivery
g.lastDeliveryId++
delivery := &CheckoutDelivery{
Id: g.lastDeliveryId,
Provider: m.Provider,
Items: m.Items,
PickupPoint: asPickupPoint(m.PickupPoint, g.lastDeliveryId),
Price: *cart.NewPrice(), // Price might need calculation, but for now zero
}
g.Deliveries = append(g.Deliveries, delivery)
return nil
}

View File

@@ -0,0 +1,52 @@
package checkout
import (
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external checkout session
// has been successfully created or updated. It persists the
// order reference / status and marks the checkout as having a payment in progress.
//
// Behavior:
// - Sets OrderId to the order ID.
// - Sets Status to the current status.
// - Sets PaymentInProgress flag.
// - Assumes inventory is already reserved.
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty.
func HandleInitializeCheckout(g *CheckoutGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
// if g.OrderId == "" {
// return fmt.Errorf("InitializeCheckout: missing orderId")
// }
if g.CartState != nil {
if g.CartId != cart.CartId(m.CartId) {
return fmt.Errorf("InitializeCheckout: cart ID mismatch")
}
if g.PaymentInProgress > 0 || g.OrderId != nil {
return fmt.Errorf("InitializeCheckout: payment already in progress")
}
}
err := json.Unmarshal(m.CartState.Value, &g.CartState)
if err != nil {
return err
}
g.CartTotalPrice = g.CartState.TotalPrice
return nil
}

View File

@@ -0,0 +1,16 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleInventoryReserved(g *CheckoutGrain, m *messages.InventoryReserved) error {
if m == nil {
return fmt.Errorf("HandleInventoryReserved: nil payload")
}
g.InventoryReserved = m.Status == "success"
return nil
}

View File

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_order_created.go
@@ -11,21 +11,20 @@ import (
// Registers the OrderCreated mutation.
//
// This mutation represents the completion (or state transition) of an order
// initiated earlier via InitializeCheckout / external Klarna processing.
// It finalizes (or updates) the cart's order metadata.
// initiated earlier via InitializeCheckout / external processing.
// It finalizes (or updates) the checkout's order metadata.
//
// Behavior:
// - Validates payload non-nil and OrderId not empty.
// - Sets (or overwrites) OrderReference with the provided OrderId.
// - Sets PaymentStatus from payload.Status.
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
// - Does NOT adjust monetary totals (no WithTotals()).
// - Sets Status from payload.Status.
// - Sets OrderId if not already set.
// - Does NOT adjust monetary totals.
//
// Notes / Future Extensions:
// - If multiple order completion events can arrive (e.g., retries / webhook
// replays), this handler is idempotent: it simply overwrites fields.
// - If you need to guard against conflicting order IDs, add a check:
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
// if g.OrderId != "" && g.OrderId != m.OrderId { ... }
// - Add audit logging or metrics here if required.
//
// Concurrency:
@@ -33,16 +32,18 @@ import (
// per grain. If out-of-order events are possible, embed versioning or
// timestamps in the mutation and compare before applying changes.
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
func HandleOrderCreated(g *CheckoutGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
return fmt.Errorf("HandleOrderCreated: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("OrderCreated: missing orderId")
}
if g.OrderId == nil {
g.OrderId = &m.OrderId
} else if *g.OrderId != m.OrderId {
return fmt.Errorf("OrderCreated: conflicting order ID")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
}

View File

@@ -0,0 +1,41 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentCompleted registers the completion of a payment for a checkout.
func HandlePaymentCompleted(g *CheckoutGrain, m *messages.PaymentCompleted) error {
if m == nil {
return fmt.Errorf("PaymentCompleted: nil payload")
}
paymentId := m.PaymentId
payment, found := g.FindPayment(paymentId)
if !found {
return fmt.Errorf("PaymentCompleted: payment not found")
}
payment.ProcessorReference = m.ProcessorReference
payment.Status = PaymentStatusSuccess
if m.Amount > 0 {
payment.Amount = m.Amount
}
if m.Currency != "" {
payment.Currency = m.Currency
}
if m.CompletedAt != nil {
*payment.CompletedAt = m.CompletedAt.AsTime()
} else {
*payment.CompletedAt = time.Now()
}
// Update checkout status
g.PaymentInProgress--
return nil
}

View File

@@ -0,0 +1,28 @@
package checkout
import (
"errors"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPointer[T any](value T) *T {
return &value
}
var ErrPaymentNotFound = errors.New("payment not found")
func HandlePaymentDeclined(g *CheckoutGrain, m *messages.PaymentDeclined) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
payment.CompletedAt = asPointer(time.Now())
payment.Status = "failed"
g.PaymentInProgress--
return nil
}

View File

@@ -0,0 +1,20 @@
package checkout
import (
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandlePaymentEvent(g *CheckoutGrain, m *messages.PaymentEvent) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
metaBytes := m.Data.Value
payment.Events = append(payment.Events, PaymentEvent{
Name: m.Name,
Success: m.Success,
Data: metaBytes,
})
return nil
}

View File

@@ -0,0 +1,124 @@
package checkout
import (
"encoding/json"
"testing"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
"google.golang.org/protobuf/types/known/anypb"
)
func TestHandlePaymentEvent(t *testing.T) {
tests := []struct {
name string
initialPayments []*Payment
event *checkout_messages.PaymentEvent
expectedEvents []PaymentEvent
expectError bool
}{
{
name: "add event to existing payment",
initialPayments: []*Payment{
{
PaymentId: "pay123",
Events: []PaymentEvent{
{Name: "init", Success: true, Data: json.RawMessage(`{"key":"value"}`)},
},
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "pay123",
Name: "capture",
Success: true,
Data: &anypb.Any{Value: []byte(`{"amount":100}`)},
},
expectedEvents: []PaymentEvent{
{Name: "init", Success: true, Data: json.RawMessage(`{"key":"value"}`)},
{Name: "capture", Success: true, Data: json.RawMessage(`{"amount":100}`)},
},
expectError: false,
},
{
name: "add event to payment with no initial events",
initialPayments: []*Payment{
{
PaymentId: "pay456",
Events: []PaymentEvent{},
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "pay456",
Name: "refund",
Success: false,
Data: &anypb.Any{Value: []byte(`{"reason":"failed"}`)},
},
expectedEvents: []PaymentEvent{
{Name: "refund", Success: false, Data: json.RawMessage(`{"reason":"failed"}`)},
},
expectError: false,
},
{
name: "payment not found",
initialPayments: []*Payment{
{
PaymentId: "pay123",
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "nonexistent",
Name: "test",
Success: true,
},
expectedEvents: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grain := &CheckoutGrain{
Payments: tt.initialPayments,
}
err := HandlePaymentEvent(grain, tt.event)
if tt.expectError {
if err == nil {
t.Errorf("expected error, got nil")
} else if err != ErrPaymentNotFound {
t.Errorf("expected ErrPaymentNotFound, got %v", err)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
payment, found := grain.FindPayment(tt.event.PaymentId)
if !found {
t.Errorf("payment not found after handling event")
return
}
if len(payment.Events) != len(tt.expectedEvents) {
t.Errorf("expected %d events, got %d", len(tt.expectedEvents), len(payment.Events))
return
}
for i, expected := range tt.expectedEvents {
actual := payment.Events[i]
if actual.Name != expected.Name {
t.Errorf("event %d name mismatch: got %s, expected %s", i, actual.Name, expected.Name)
}
if actual.Success != expected.Success {
t.Errorf("event %d success mismatch: got %t, expected %t", i, actual.Success, expected.Success)
}
if string(actual.Data) != string(expected.Data) {
t.Errorf("event %d data mismatch: got %s, expected %s", i, string(actual.Data), string(expected.Data))
}
}
})
}
}

View File

@@ -0,0 +1,93 @@
package checkout
import (
"encoding/json"
"fmt"
"strings"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentStarted registers the beginning of a payment attempt for a checkout.
// It either upserts the payment entry (based on paymentId) or creates a new one,
// marks the checkout as having a payment in progress.
func HandlePaymentStarted(g *CheckoutGrain, m *messages.PaymentStarted) error {
if m == nil {
return fmt.Errorf("PaymentStarted: nil payload")
}
paymentID := strings.TrimSpace(m.PaymentId)
if paymentID == "" {
return fmt.Errorf("PaymentStarted: missing paymentId")
}
if m.Amount < 0 {
return fmt.Errorf("PaymentStarted: amount cannot be negative")
}
currency := strings.TrimSpace(m.Currency)
provider := strings.TrimSpace(m.Provider)
method := copyOptionalString(m.Method)
startedAt := time.Now().UTC()
if m.StartedAt != nil {
startedAt = m.StartedAt.AsTime()
}
payment, found := g.FindPayment(paymentID)
var sessionData *json.RawMessage
if m.SessionData != nil {
sessionData = (*json.RawMessage)(&m.SessionData.Value)
}
if found {
if payment.Status != "pending" {
return fmt.Errorf("PaymentStarted: payment already started")
}
if payment.PaymentId != paymentID {
payment.PaymentId = paymentID
}
payment.Status = "pending"
payment.Amount = m.Amount
if currency != "" {
payment.Currency = currency
}
if provider != "" {
payment.Provider = provider
}
if method != nil {
payment.Method = method
}
if m.SessionData != nil {
payment.SessionData = sessionData
}
payment.StartedAt = &startedAt
} else {
g.PaymentInProgress++
g.Payments = append(g.Payments, &Payment{
PaymentId: paymentID,
Status: "pending",
Amount: m.Amount,
SessionData: sessionData,
Currency: currency,
Provider: provider,
Method: method,
StartedAt: &startedAt,
})
}
return nil
}
func copyOptionalString(src *string) *string {
if src == nil {
return nil
}
trimmed := strings.TrimSpace(*src)
if trimmed == "" {
return nil
}
dst := trimmed
return &dst
}

View File

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_remove_delivery.go
@@ -13,20 +13,10 @@ import (
// Behavior:
// - Removes the delivery entry whose Id == payload.Id.
// - If not found, returns an error.
// - Cart totals are recalculated (WithTotals) after removal.
// - Items previously associated with that delivery simply become "without delivery";
// subsequent delivery mutations can reassign them.
//
// Differences vs legacy:
// - Legacy logic decremented TotalPrice explicitly before recalculating.
// Here we rely solely on UpdateTotals() to recompute from remaining
// deliveries and items (simpler / single source of truth).
//
// Future considerations:
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
func HandleRemoveDelivery(g *CheckoutGrain, m *messages.RemoveDelivery) error {
if m == nil {
return fmt.Errorf("RemoveDelivery: nil payload")
}
@@ -44,6 +34,5 @@ func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
g.UpdateTotals()
return nil
}

View File

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_set_pickup_point.go
@@ -29,34 +29,23 @@ import (
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
func HandleSetPickupPoint(g *CheckoutGrain, m *messages.SetPickupPoint) error {
if m == nil {
return fmt.Errorf("SetPickupPoint: nil payload")
}
for _, d := range g.Deliveries {
if d.Id == uint32(m.DeliveryId) {
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
Address: m.Address,
City: m.City,
Zip: m.Zip,
Country: m.Country,
d.PickupPoint = &PickupPoint{
Id: m.PickupPoint.Id,
Name: m.PickupPoint.Name,
Address: m.PickupPoint.Address,
City: m.PickupPoint.City,
Zip: m.PickupPoint.Zip,
Country: m.PickupPoint.Country,
}
return nil
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
}
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
// maybe check if payment is done?
g.Deliveries = g.Deliveries[:0]
g.Items = g.Items[:0]
g.UpdateTotals()
return nil
}

View File

@@ -14,36 +14,39 @@ import (
)
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
ctx context.Context
client *kubernetes.Clientset
listOptions metav1.ListOptions
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
if hasReadyCondition(&pod) {
hosts = append(hosts, pod.Status.PodIP)
}
}
return hosts, nil
}
func hasReadyCondition(pod *v1.Pod) bool {
return slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
})
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{}
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions)
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
@@ -54,9 +57,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
isReady := slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
})
isReady := hasReadyCondition(pod)
m.Lock()
oldState := ipsThatAreReady[pod.Status.PodIP]
ipsThatAreReady[pod.Status.PodIP] = isReady
@@ -76,9 +77,10 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
ctx: context.Background(),
client: client,
listOptions: listOptions,
}
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
@@ -17,7 +18,9 @@ func TestDiscovery(t *testing.T) {
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
d := NewK8sDiscovery(client, metav1.ListOptions{
LabelSelector: "app",
})
res, err := d.DiscoverInNamespace("")
if err != nil {
t.Errorf("Error discovering: %v", err)
@@ -36,7 +39,9 @@ func TestWatch(t *testing.T) {
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
d := NewK8sDiscovery(client, metav1.ListOptions{
LabelSelector: "app",
})
ch, err := d.Watch()
if err != nil {
t.Errorf("Error watching: %v", err)

View File

@@ -9,3 +9,9 @@ type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type DiscoveryTarget interface {
IsKnown(string) bool
RemoveHost(host string)
AddRemoteHost(host string)
}

View File

@@ -1,582 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v6.33.1
// source: control_plane.proto
package messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Empty request placeholder (common pattern).
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
type PingReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *PingReply) GetUnixTime() int64 {
if x != nil {
return x.UnixTime
}
return 0
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
type NegotiateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
if x != nil {
return x.KnownHosts
}
return nil
}
// NegotiateReply returns the callee's healthy hosts (including itself).
type NegotiateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
if x != nil {
return x.Hosts
}
return nil
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActorIdsReply) ProtoMessage() {}
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
type OwnerChangeAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *OwnerChangeAck) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
type ClosingNotice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClosingNotice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnershipAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpiryAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63,
0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
6, // [6:12] is the sub-list for method output_type
0, // [0:6] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ func (NoopTracer) Trace(string, map[string]any) {}
// for the purpose of promotion condition evaluation.
type PromotionItem struct {
SKU string
Quantity int
Quantity uint16
Category string
PriceIncVat int64
}
@@ -36,7 +36,7 @@ type PromotionItem struct {
// customer/order metadata.
type PromotionEvalContext struct {
CartTotalIncVat int64
TotalItemQuantity int
TotalItemQuantity uint32
Items []PromotionItem
CustomerSegment string
CustomerLifetimeValue float64
@@ -89,7 +89,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
Category: strings.ToLower(category),
PriceIncVat: it.Price.IncVat,
})
ctx.TotalItemQuantity += it.Quantity
ctx.TotalItemQuantity += uint32(it.Quantity)
}
for _, o := range opts {
o(ctx)

View File

@@ -58,7 +58,7 @@ func (t *testTracer) Count(name string) int {
func makeCart(totalOverride int64, items []struct {
sku string
category string
qty int
qty uint16
priceInc int64
}) *cart.CartGrain {
g := cart.NewCartGrain(1, time.Now())
@@ -92,7 +92,7 @@ func TestEvaluateRuleBasicAND(t *testing.T) {
g := makeCart(12000, []struct {
sku string
category string
qty int
qty uint16
priceInc int64
}{
{"SKU-1", "summer", 2, 3000},
@@ -226,7 +226,7 @@ func TestEvaluateProductCategoryIN(t *testing.T) {
g := makeCart(-1, []struct {
sku string
category string
qty int
qty uint16
priceInc int64
}{
{"A", "shoes", 1, 5000},
@@ -268,7 +268,7 @@ func TestEvaluateGroupOR(t *testing.T) {
g := makeCart(3000, []struct {
sku string
category string
qty int
qty uint16
priceInc int64
}{
{"ONE", "x", 1, 3000},

View File

@@ -1,7 +1,9 @@
package proxy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -9,17 +11,20 @@ import (
"net/http"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// RemoteHost mirrors the lightweight controller used for remote node
// interaction.
type RemoteHost struct {
type RemoteHost[V any] struct {
host string
httpBase string
conn *grpc.ClientConn
@@ -37,7 +42,34 @@ var (
logger = otelslog.NewLogger(name)
)
func NewRemoteHost(host string) (*RemoteHost, error) {
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
type MockResponseWriter struct {
StatusCode int
HeaderMap http.Header
Body *bytes.Buffer
}
func NewMockResponseWriter() *MockResponseWriter {
return &MockResponseWriter{
StatusCode: 200,
HeaderMap: make(http.Header),
Body: &bytes.Buffer{},
}
}
func (m *MockResponseWriter) Header() http.Header {
return m.HeaderMap
}
func (m *MockResponseWriter) Write(data []byte) (int, error) {
return m.Body.Write(data)
}
func (m *MockResponseWriter) WriteHeader(statusCode int) {
m.StatusCode = statusCode
}
func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
target := fmt.Sprintf("%s:1337", host)
@@ -58,7 +90,7 @@ func NewRemoteHost(host string) (*RemoteHost, error) {
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
return &RemoteHost{
return &RemoteHost[V]{
host: host,
httpBase: fmt.Sprintf("http://%s:8080", host),
conn: conn,
@@ -69,18 +101,18 @@ func NewRemoteHost(host string) (*RemoteHost, error) {
}, nil
}
func (h *RemoteHost) Name() string {
func (h *RemoteHost[V]) Name() string {
return h.host
}
func (h *RemoteHost) Close() error {
func (h *RemoteHost[V]) Close() error {
if h.conn != nil {
h.conn.Close()
}
return nil
}
func (h *RemoteHost) Ping() bool {
func (h *RemoteHost[V]) Ping() bool {
var err error = errors.ErrUnsupported
for err != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -88,7 +120,6 @@ func (h *RemoteHost) Ping() bool {
cancel()
if err != nil {
h.missedPings++
log.Printf("Ping %s failed (%d) %v", h.host, h.missedPings, err)
}
if !h.IsHealthy() {
return false
@@ -100,7 +131,71 @@ func (h *RemoteHost) Ping() bool {
return true
}
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
func (h *RemoteHost[V]) Get(ctx context.Context, id uint64) (*V, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
reply, error := h.controlClient.Get(ctx, &messages.GetRequest{Id: id})
if error != nil {
return nil, error
}
var grain V
err := json.Unmarshal(reply.Grain.Value, &grain)
if err != nil {
return nil, fmt.Errorf("failed to unpack state: %w", err)
}
return &grain, nil
}
func (h *RemoteHost[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[V], error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
toSend := make([]*anypb.Any, len(mutation))
for i, msg := range mutation {
anyMsg, err := anypb.New(msg)
if err != nil {
return nil, fmt.Errorf("failed to pack message: %w", err)
}
toSend[i] = anyMsg
}
resp, err := h.controlClient.Apply(ctx, &messages.ApplyRequest{
Id: id,
Messages: toSend,
})
if err != nil {
h.missedPings++
log.Printf("Apply %s failed: %v", h.host, err)
return nil, err
}
h.missedPings = 0
var grain V
err = json.Unmarshal(resp.State.Value, &grain)
if err != nil {
return nil, fmt.Errorf("failed to unpack state: %w", err)
}
var mutationList []actor.ApplyResult
for _, msg := range resp.Mutations {
mutation, err := anypb.UnmarshalNew(msg.Message, proto.UnmarshalOptions{})
if err != nil {
return nil, fmt.Errorf("failed to unpack mutation: %w", err)
}
if msg.Error != nil {
err = errors.New(*msg.Error)
}
mutationList = append(mutationList, actor.ApplyResult{
Mutation: mutation,
Error: err,
})
}
res := &actor.MutationResult[V]{
Result: grain,
Mutations: mutationList,
}
return res, nil
}
func (h *RemoteHost[V]) Negotiate(knownHosts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -116,7 +211,7 @@ func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
return resp.Hosts, nil
}
func (h *RemoteHost) GetActorIds() []uint64 {
func (h *RemoteHost[V]) GetActorIds() []uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := h.controlClient.GetLocalActorIds(ctx, &messages.Empty{})
@@ -128,7 +223,7 @@ func (h *RemoteHost) GetActorIds() []uint64 {
return reply.GetIds()
}
func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: ownerHost,
Ids: uids,
@@ -141,7 +236,7 @@ func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
h.missedPings = 0
}
func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64) {
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
Host: h.host,
Ids: uids,
@@ -154,7 +249,7 @@ func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
h.missedPings = 0
}
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error) {
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
ctx, span := tracer.Start(r.Context(), "remote_proxy")
@@ -168,7 +263,11 @@ func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (b
)
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
req, err := http.NewRequestWithContext(ctx, r.Method, target, r.Body)
var bdy io.Reader = r.Body
if customBody != nil {
bdy = customBody
}
req, err := http.NewRequestWithContext(ctx, r.Method, target, bdy)
if err != nil {
span.RecordError(err)
http.Error(w, "proxy build error", http.StatusBadGateway)
@@ -207,6 +306,6 @@ func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (b
}
func (r *RemoteHost) IsHealthy() bool {
func (r *RemoteHost[V]) IsHealthy() bool {
return r.missedPings < 3
}

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"os"
"git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
type Rule struct {

96
proto/cart.proto Normal file
View File

@@ -0,0 +1,96 @@
syntax = "proto3";
package cart_messages;
option go_package = "git.k6n.net/go-cart-actor/proto/cart;cart_messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message ClearCartRequest {}
message AddItem {
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
string sku = 4;
string name = 5;
string image = 6;
int32 stock = 7;
int32 tax = 8;
string brand = 13;
string category = 14;
string category2 = 15;
string category3 = 16;
string category4 = 17;
string category5 = 18;
string disclaimer = 10;
string articleType = 11;
string sellerId = 19;
string sellerName = 20;
string country = 21;
string saleStatus = 24;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
string cgm = 25;
optional google.protobuf.Timestamp reservationEndTime = 26;
}
message RemoveItem { uint32 Id = 1; }
message ChangeQuantity {
uint32 Id = 1;
int32 quantity = 2;
}
message SetUserId {
string userId = 1;
}
message LineItemMarking {
uint32 id = 1;
uint32 type = 2;
string marking = 3;
}
message RemoveLineItemMarking {
uint32 id = 1;
}
message SubscriptionAdded {
uint32 itemId = 1;
string detailsId = 3;
string orderReference = 4;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated string voucherRules = 3;
string description = 4;
}
message RemoveVoucher { uint32 id = 1; }
message UpsertSubscriptionDetails {
optional string id = 1;
string offeringCode = 2;
string signingType = 3;
google.protobuf.Any data = 4;
}
message Mutation {
oneof type {
ClearCartRequest clear_cart = 1;
AddItem add_item = 2;
RemoveItem remove_item = 3;
ChangeQuantity change_quantity = 4;
SetUserId set_user_id = 5;
LineItemMarking line_item_marking = 6;
RemoveLineItemMarking remove_line_item_marking = 7;
SubscriptionAdded subscription_added = 8;
AddVoucher add_voucher = 20;
RemoveVoucher remove_voucher = 21;
UpsertSubscriptionDetails upsert_subscription_details = 22;
}
}

1266
proto/cart/cart.pb.go Normal file

File diff suppressed because it is too large Load Diff

113
proto/checkout.proto Normal file
View File

@@ -0,0 +1,113 @@
syntax = "proto3";
package checkout_messages;
option go_package = "git.k6n.net/go-cart-actor/proto/checkout;checkout_messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message SetDelivery {
string provider = 1;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
optional string address = 6;
optional string city = 7;
}
message SetPickupPoint {
uint32 deliveryId = 1;
PickupPoint pickupPoint = 2;
}
message PickupPoint {
string id = 2;
optional string name = 3;
optional string address = 4;
optional string city = 5;
optional string zip = 6;
optional string country = 7;
}
message RemoveDelivery { uint32 id = 1; }
message PaymentStarted {
string paymentId = 1;
int64 amount = 3;
string currency = 4;
string provider = 5;
optional string method = 6;
optional google.protobuf.Timestamp startedAt = 7;
optional google.protobuf.Any sessionData = 8;
repeated uint32 items = 2;
}
message PaymentCompleted {
string paymentId = 1;
string status = 2;
int64 amount = 3;
string currency = 4;
optional string processorReference = 5;
optional google.protobuf.Timestamp completedAt = 6;
}
message PaymentDeclined {
string paymentId = 1;
string message = 2;
optional string code = 3;
}
message PaymentEvent {
string paymentId = 1;
string name = 2;
bool success = 3;
google.protobuf.Any data = 4;
}
message ConfirmationViewed {
google.protobuf.Timestamp viewedAt = 1;
}
message OrderCreated {
string orderId = 1;
string status = 2;
google.protobuf.Timestamp createdAt = 3;
}
message InitializeCheckout {
string orderId = 1;
uint64 cartId = 2;
uint32 version = 3;
google.protobuf.Any cartState = 4;
}
message ContactDetailsUpdated {
optional string email = 1;
optional string phone = 2;
optional string name = 3;
}
message InventoryReserved {
string id = 1;
string status = 2;
optional string message = 3;
}
message Mutation {
oneof type {
SetDelivery set_delivery = 1;
SetPickupPoint set_pickup_point = 2;
RemoveDelivery remove_delivery = 3;
PaymentDeclined payment_declined = 4;
ConfirmationViewed confirmation_viewed = 5;
ContactDetailsUpdated contact_details_updated = 6;
OrderCreated order_created = 7;
InitializeCheckout initialize_checkout = 9;
InventoryReserved inventory_reserved = 10;
PaymentStarted payment_started = 11;
PaymentCompleted payment_completed = 12;
PaymentEvent payment_event = 13;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,902 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v6.33.1
// source: control_plane.proto
package control_plane_messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
anypb "google.golang.org/protobuf/types/known/anypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Empty request placeholder (common pattern).
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
type PingReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *PingReply) GetUnixTime() int64 {
if x != nil {
return x.UnixTime
}
return 0
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
type NegotiateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
if x != nil {
return x.KnownHosts
}
return nil
}
// NegotiateReply returns the callee's healthy hosts (including itself).
type NegotiateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
if x != nil {
return x.Hosts
}
return nil
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActorIdsReply) ProtoMessage() {}
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
type OwnerChangeAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *OwnerChangeAck) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
type ClosingNotice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClosingNotice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnershipAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpiryAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
type ApplyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Messages []*anypb.Any `protobuf:"bytes,2,rep,name=messages,proto3" json:"messages,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyRequest) Reset() {
*x = ApplyRequest{}
mi := &file_control_plane_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyRequest) ProtoMessage() {}
func (x *ApplyRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead.
func (*ApplyRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{9}
}
func (x *ApplyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *ApplyRequest) GetMessages() []*anypb.Any {
if x != nil {
return x.Messages
}
return nil
}
type GetRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetRequest) Reset() {
*x = GetRequest{}
mi := &file_control_plane_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRequest) ProtoMessage() {}
func (x *GetRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead.
func (*GetRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{10}
}
func (x *GetRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type GetReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Grain *anypb.Any `protobuf:"bytes,1,opt,name=grain,proto3" json:"grain,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetReply) Reset() {
*x = GetReply{}
mi := &file_control_plane_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetReply) ProtoMessage() {}
func (x *GetReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetReply.ProtoReflect.Descriptor instead.
func (*GetReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{11}
}
func (x *GetReply) GetGrain() *anypb.Any {
if x != nil {
return x.Grain
}
return nil
}
type MutationResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
Message *anypb.Any `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
Error *string `protobuf:"bytes,3,opt,name=error,proto3,oneof" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MutationResult) Reset() {
*x = MutationResult{}
mi := &file_control_plane_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MutationResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MutationResult) ProtoMessage() {}
func (x *MutationResult) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MutationResult.ProtoReflect.Descriptor instead.
func (*MutationResult) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{12}
}
func (x *MutationResult) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *MutationResult) GetMessage() *anypb.Any {
if x != nil {
return x.Message
}
return nil
}
func (x *MutationResult) GetError() string {
if x != nil && x.Error != nil {
return *x.Error
}
return ""
}
type ApplyResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
State *anypb.Any `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"`
Mutations []*MutationResult `protobuf:"bytes,2,rep,name=mutations,proto3" json:"mutations,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyResult) Reset() {
*x = ApplyResult{}
mi := &file_control_plane_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyResult) ProtoMessage() {}
func (x *ApplyResult) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyResult.ProtoReflect.Descriptor instead.
func (*ApplyResult) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{13}
}
func (x *ApplyResult) GetState() *anypb.Any {
if x != nil {
return x.State
}
return nil
}
func (x *ApplyResult) GetMutations() []*MutationResult {
if x != nil {
return x.Mutations
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x16, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61,
0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12,
0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f,
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e, 0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22,
0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73,
0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48,
0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d,
0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a,
0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63,
0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a,
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69,
0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63,
0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03,
0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72,
0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73,
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a,
0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12,
0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x22,
0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x67,
0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x79, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a,
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x19, 0x0a,
0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05,
0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x72, 0x72,
0x6f, 0x72, 0x22, 0x7f, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c,
0x74, 0x12, 0x2a, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x44, 0x0a,
0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x32, 0xd6, 0x05, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x50,
0x6c, 0x61, 0x6e, 0x65, 0x12, 0x48, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x1d, 0x2e, 0x63,
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x21, 0x2e, 0x63, 0x6f,
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x5d,
0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x12, 0x28, 0x2e, 0x63, 0x6f,
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e,
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x58, 0x0a,
0x10, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49,
0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x66, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
0x6e, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x29, 0x2e, 0x63,
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41,
0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12,
0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72,
0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23,
0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x73,
0x75, 0x6c, 0x74, 0x12, 0x60, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45,
0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45,
0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e,
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x58, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67,
0x12, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e,
0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12,
0x4b, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e,
0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x42, 0x40, 0x5a, 0x3e,
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x67, 0x6f, 0x2d, 0x63,
0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: control_plane_messages.Empty
(*PingReply)(nil), // 1: control_plane_messages.PingReply
(*NegotiateRequest)(nil), // 2: control_plane_messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: control_plane_messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: control_plane_messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: control_plane_messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: control_plane_messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: control_plane_messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: control_plane_messages.ExpiryAnnounce
(*ApplyRequest)(nil), // 9: control_plane_messages.ApplyRequest
(*GetRequest)(nil), // 10: control_plane_messages.GetRequest
(*GetReply)(nil), // 11: control_plane_messages.GetReply
(*MutationResult)(nil), // 12: control_plane_messages.MutationResult
(*ApplyResult)(nil), // 13: control_plane_messages.ApplyResult
(*anypb.Any)(nil), // 14: google.protobuf.Any
}
var file_control_plane_proto_depIdxs = []int32{
14, // 0: control_plane_messages.ApplyRequest.messages:type_name -> google.protobuf.Any
14, // 1: control_plane_messages.GetReply.grain:type_name -> google.protobuf.Any
14, // 2: control_plane_messages.MutationResult.message:type_name -> google.protobuf.Any
14, // 3: control_plane_messages.ApplyResult.state:type_name -> google.protobuf.Any
12, // 4: control_plane_messages.ApplyResult.mutations:type_name -> control_plane_messages.MutationResult
0, // 5: control_plane_messages.ControlPlane.Ping:input_type -> control_plane_messages.Empty
2, // 6: control_plane_messages.ControlPlane.Negotiate:input_type -> control_plane_messages.NegotiateRequest
0, // 7: control_plane_messages.ControlPlane.GetLocalActorIds:input_type -> control_plane_messages.Empty
7, // 8: control_plane_messages.ControlPlane.AnnounceOwnership:input_type -> control_plane_messages.OwnershipAnnounce
9, // 9: control_plane_messages.ControlPlane.Apply:input_type -> control_plane_messages.ApplyRequest
8, // 10: control_plane_messages.ControlPlane.AnnounceExpiry:input_type -> control_plane_messages.ExpiryAnnounce
6, // 11: control_plane_messages.ControlPlane.Closing:input_type -> control_plane_messages.ClosingNotice
10, // 12: control_plane_messages.ControlPlane.Get:input_type -> control_plane_messages.GetRequest
1, // 13: control_plane_messages.ControlPlane.Ping:output_type -> control_plane_messages.PingReply
3, // 14: control_plane_messages.ControlPlane.Negotiate:output_type -> control_plane_messages.NegotiateReply
4, // 15: control_plane_messages.ControlPlane.GetLocalActorIds:output_type -> control_plane_messages.ActorIdsReply
5, // 16: control_plane_messages.ControlPlane.AnnounceOwnership:output_type -> control_plane_messages.OwnerChangeAck
13, // 17: control_plane_messages.ControlPlane.Apply:output_type -> control_plane_messages.ApplyResult
5, // 18: control_plane_messages.ControlPlane.AnnounceExpiry:output_type -> control_plane_messages.OwnerChangeAck
5, // 19: control_plane_messages.ControlPlane.Closing:output_type -> control_plane_messages.OwnerChangeAck
11, // 20: control_plane_messages.ControlPlane.Get:output_type -> control_plane_messages.GetReply
13, // [13:21] is the sub-list for method output_type
5, // [5:13] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
file_control_plane_proto_msgTypes[12].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 14,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

View File

@@ -4,7 +4,7 @@
// - protoc v6.33.1
// source: control_plane.proto
package messages
package control_plane_messages
import (
context "context"
@@ -19,12 +19,14 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
ControlPlane_Ping_FullMethodName = "/control_plane_messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/control_plane_messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/control_plane_messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceOwnership"
ControlPlane_Apply_FullMethodName = "/control_plane_messages.ControlPlane/Apply"
ControlPlane_AnnounceExpiry_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/control_plane_messages.ControlPlane/Closing"
ControlPlane_Get_FullMethodName = "/control_plane_messages.ControlPlane/Get"
)
// ControlPlaneClient is the client API for ControlPlane service.
@@ -41,10 +43,12 @@ type ControlPlaneClient interface {
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error)
}
type controlPlaneClient struct {
@@ -95,6 +99,16 @@ func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *Ownershi
return out, nil
}
func (c *controlPlaneClient) Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyResult)
err := c.cc.Invoke(ctx, ControlPlane_Apply_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
@@ -115,6 +129,16 @@ func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opt
return out, nil
}
func (c *controlPlaneClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetReply)
err := c.cc.Invoke(ctx, ControlPlane_Get_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ControlPlaneServer is the server API for ControlPlane service.
// All implementations must embed UnimplementedControlPlaneServer
// for forward compatibility.
@@ -129,10 +153,12 @@ type ControlPlaneServer interface {
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
Apply(context.Context, *ApplyRequest) (*ApplyResult, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
Get(context.Context, *GetRequest) (*GetReply, error)
mustEmbedUnimplementedControlPlaneServer()
}
@@ -155,12 +181,18 @@ func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty)
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
}
func (UnimplementedControlPlaneServer) Apply(context.Context, *ApplyRequest) (*ApplyResult, error) {
return nil, status.Errorf(codes.Unimplemented, "method Apply not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
}
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
}
func (UnimplementedControlPlaneServer) Get(context.Context, *GetRequest) (*GetReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Get not implemented")
}
func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {}
func (UnimplementedControlPlaneServer) testEmbeddedByValue() {}
@@ -254,6 +286,24 @@ func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_Apply_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Apply(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Apply_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Apply(ctx, req.(*ApplyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpiryAnnounce)
if err := dec(in); err != nil {
@@ -290,11 +340,29 @@ func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Get(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Get_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Get(ctx, req.(*GetRequest))
}
return interceptor(ctx, in, info, handler)
}
// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ControlPlane_ServiceDesc = grpc.ServiceDesc{
ServiceName: "messages.ControlPlane",
ServiceName: "control_plane_messages.ControlPlane",
HandlerType: (*ControlPlaneServer)(nil),
Methods: []grpc.MethodDesc{
{
@@ -313,6 +381,10 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
MethodName: "AnnounceOwnership",
Handler: _ControlPlane_AnnounceOwnership_Handler,
},
{
MethodName: "Apply",
Handler: _ControlPlane_Apply_Handler,
},
{
MethodName: "AnnounceExpiry",
Handler: _ControlPlane_AnnounceExpiry_Handler,
@@ -321,6 +393,10 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
MethodName: "Closing",
Handler: _ControlPlane_Closing_Handler,
},
{
MethodName: "Get",
Handler: _ControlPlane_Get_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "control_plane.proto",

View File

@@ -1,8 +1,10 @@
syntax = "proto3";
package messages;
package control_plane_messages;
option go_package = "git.k6n.net/go-cart-actor/proto;messages";
option go_package = "git.k6n.net/go-cart-actor/proto/control;control_plane_messages";
import "google/protobuf/any.proto";
// -----------------------------------------------------------------------------
// Control Plane gRPC API
@@ -65,6 +67,31 @@ message ExpiryAnnounce {
repeated uint64 ids = 2;
}
message ApplyRequest {
uint64 id = 1;
repeated google.protobuf.Any messages = 2;
}
message GetRequest {
uint64 id = 1;
}
message GetReply {
google.protobuf.Any grain = 1;
}
message MutationResult {
string type = 1;
google.protobuf.Any message = 2;
optional string error = 3;
}
message ApplyResult {
google.protobuf.Any state = 1;
repeated MutationResult mutations = 2;
}
// ControlPlane defines cluster coordination and ownership operations.
service ControlPlane {
// Ping for liveness; lightweight health signal.
@@ -80,12 +107,14 @@ service ControlPlane {
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck);
rpc Apply(ApplyRequest) returns (ApplyResult);
// Expiry announcement: drop remote ownership hints when local TTL expires.
rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck);
// Closing announces graceful shutdown so peers can proactively adjust.
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
rpc Get(GetRequest) returns (GetReply);
}
// -----------------------------------------------------------------------------

View File

@@ -1,173 +0,0 @@
syntax = "proto3";
package messages;
option go_package = "git.k6n.net/go-cart-actor/proto;messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message ClearCartRequest {}
message AddItem {
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
string sku = 4;
string name = 5;
string image = 6;
int32 stock = 7;
int32 tax = 8;
string brand = 13;
string category = 14;
string category2 = 15;
string category3 = 16;
string category4 = 17;
string category5 = 18;
string disclaimer = 10;
string articleType = 11;
string sellerId = 19;
string sellerName = 20;
string country = 21;
string saleStatus = 24;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
string cgm = 25;
optional google.protobuf.Timestamp reservationEndTime = 26
}
message RemoveItem { uint32 Id = 1; }
message ChangeQuantity {
uint32 Id = 1;
int32 quantity = 2;
}
message SetDelivery {
string provider = 1;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
optional string address = 6;
optional string city = 7;
}
message SetPickupPoint {
uint32 deliveryId = 1;
string id = 2;
optional string name = 3;
optional string address = 4;
optional string city = 5;
optional string zip = 6;
optional string country = 7;
}
message PickupPoint {
string id = 1;
optional string name = 2;
optional string address = 3;
optional string city = 4;
optional string zip = 5;
optional string country = 6;
}
message RemoveDelivery { uint32 id = 1; }
message SetUserId {
string userId = 1;
}
message LineItemMarking {
uint32 id = 1;
uint32 type = 2;
string marking = 3;
}
message RemoveLineItemMarking {
uint32 id = 1;
}
message SubscriptionAdded {
uint32 itemId = 1;
string detailsId = 3;
string orderReference = 4;
}
message PaymentDeclined {
string message = 1;
optional string code = 2;
}
message ConfirmationViewed {
}
message CreateCheckoutOrder {
string terms = 1;
string checkout = 2;
string confirmation = 3;
string push = 4;
string validation = 5;
string country = 6;
}
message OrderCreated {
string orderId = 1;
string status = 2;
}
message Noop {
// Intentionally empty - used for ownership acquisition or health pings
}
message InitializeCheckout {
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}
message InventoryReserved {
string id = 1;
string status = 2;
optional string message = 3;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated string voucherRules = 3;
string description = 4;
}
message RemoveVoucher { uint32 id = 1; }
message UpsertSubscriptionDetails {
optional string id = 1;
string offeringCode = 2;
string signingType = 3;
google.protobuf.Any data = 4;
}
message PreConditionFailed {
string operation = 1;
string error = 2;
google.protobuf.Any input = 3;
}
message GiftcardItem {
int64 value = 1;
string deliveryDate = 2;
string recipient = 3;
string recipientType = 4;
string message = 5;
optional google.protobuf.Any designConfig = 6;
}
message AddGiftcard {
GiftcardItem giftcard = 1;
}
message RemoveGiftcard {
uint32 id = 1;
}