83 Commits

Author SHA1 Message Date
8bf29020dd Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-10 19:47:23 +01:00
44d7c1faad add listener and remove memory inventory service 2025-11-10 19:47:15 +01:00
matst80
caab742461 add missing code
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-10 19:46:18 +01:00
matst80
b272282b1f better metrics
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-11-07 14:44:15 +01:00
matst80
43fcf69139 more traces
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-11-07 14:20:54 +01:00
matst80
7d9fd0ebb4 fix proxy
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-11-07 14:04:50 +01:00
matst80
e662c7dafa log
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-11-07 13:36:57 +01:00
matst80
d969da428f Update otel.go
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-11-07 12:37:31 +01:00
matst80
d0325e302e change to otel metrics 2025-11-07 12:36:53 +01:00
matst80
1aa12ff8d9 Update pool-server.go
All checks were successful
Build and Publish / Metadata (push) Successful in 17s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
2025-11-07 09:27:08 +01:00
matst80
68eca49cd0 Update pool-server.go
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-07 09:24:36 +01:00
matst80
bb80c9ab13 change
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-07 09:22:41 +01:00
matst80
dce00fb5e3 change to debug service
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m13s
2025-11-06 14:37:57 +01:00
matst80
81e2fb5faa do all requests with context
All checks were successful
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-11-05 19:04:56 +01:00
matst80
01d8d86c7c manual handler
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 22s
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m32s
2025-11-05 18:46:17 +01:00
matst80
0c4d9e2245 Update checkout_server.go
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-11-05 18:35:02 +01:00
matst80
8aab59a5f4 Update pool-server.go
Some checks failed
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:32:59 +01:00
matst80
c1599af40b Update pool-server.go 2025-11-05 18:32:55 +01:00
matst80
acf2a3a8c1 Update pool-server.go
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:28:19 +01:00
matst80
0a86bd380a Update main.go
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-11-05 18:27:43 +01:00
matst80
42913a079f update handler
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:26:26 +01:00
matst80
c71b668a87 slask
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-11-05 18:03:20 +01:00
matst80
89ee3e725f test
Some checks failed
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:00:25 +01:00
matst80
aef90e2bbb update
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:56:04 +01:00
matst80
834bf9f7bc prom meter
All checks were successful
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-11-05 17:22:34 +01:00
matst80
de77a3b707 change to prometheus
Some checks failed
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:21:27 +01:00
matst80
162a2638fa more changes
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:18:09 +01:00
matst80
5533d241e9 again
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-11-05 16:32:10 +01:00
matst80
b36125d664 change type
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
2025-11-05 16:25:51 +01:00
matst80
cd0ee22ddc own
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-11-05 15:24:27 +01:00
matst80
00fcacf1be test again
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m23s
2025-11-05 15:02:24 +01:00
matst80
2378635790 remove otel for now
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-11-05 14:15:14 +01:00
matst80
da28e993cd add oltp everything
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-11-05 13:36:29 +01:00
matst80
00c2ff70da update
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-11-05 13:25:08 +01:00
matst80
eb4061f1b8 update
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m23s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-11-05 13:14:23 +01:00
matst80
e155736313 set otel variables
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10m11s
2025-11-05 13:00:57 +01:00
matst80
5e7591335c update
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m12s
2025-11-05 12:22:26 +01:00
matst80
c68f726a96 otel
Some checks failed
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 39s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m45s
2025-11-05 11:57:52 +01:00
matst80
82d564b136 test otel
All checks were successful
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-11-05 10:23:00 +01:00
matst80
eb1f7750df Update main.go 2025-11-04 18:20:29 +01:00
matst80
ce0bac477a Update main.go
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
2025-11-04 18:13:50 +01:00
matst80
7c0e3e84a2 implement redis inventory with threadsafe reservation
Some checks failed
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-04 18:13:06 +01:00
matst80
c67ebd7a5f initial inventory service 2025-11-04 12:48:41 +01:00
matst80
42e38504a3 test without rollout
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m1s
2025-11-03 11:04:58 +01:00
b7ae36e53c allow error responses when proxying
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-10-23 18:24:45 +02:00
matst80
d9fb49ec0b update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m19s
2025-10-23 12:42:55 +02:00
matst80
2202c149b8 cleanup
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-22 12:39:48 +02:00
matst80
e91433eda7 process
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-20 21:43:41 +02:00
matst80
7eb000fd17 only after mutations
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-10-20 21:09:45 +02:00
matst80
9ecd91c163 testing promotion actions
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-10-20 20:58:34 +02:00
matst80
246a5ebd85 format
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-10-20 18:38:57 +02:00
matst80
e1de5a00a0 eval
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-10-20 17:52:44 +02:00
matst80
99c9f611e7 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Failing after 10m37s
2025-10-20 09:36:53 +02:00
matst80
df0cd58dcd update totals on remove 2025-10-20 09:36:30 +02:00
86f97f2888 Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
2025-10-19 16:46:15 +02:00
af3eb0d7bf fix close if no queue is used 2025-10-19 16:46:14 +02:00
matst80
a0c82dc351 missing file
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-10-18 15:51:04 +02:00
matst80
0c127e9d38 add description all the way
Some checks failed
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-18 15:50:38 +02:00
matst80
662b381a34 probably fix discounts
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-18 15:42:30 +02:00
matst80
e127251a60 add state file parser
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-18 15:25:44 +02:00
matst80
b7f0990269 simpler rules
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-18 15:21:11 +02:00
matst80
d58409e3fc add total test
Some checks are pending
Build and Publish / BuildAndDeployAmd64 (push) Blocked by required conditions
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-18 15:14:22 +02:00
matst80
2ce45656d9 test
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m54s
2025-10-18 14:42:52 +02:00
matst80
dc352e3b74 voucher change
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-10-18 14:33:21 +02:00
matst80
915d845014 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 8m13s
2025-10-18 13:37:33 +02:00
matst80
4a54661f24 add voucher store
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-10-17 12:48:43 +02:00
matst80
918aa7d265 promotion types
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m7s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-17 09:23:05 +02:00
matst80
a1833d6685 missed save
All checks were successful
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-16 22:54:08 +02:00
matst80
614be25ae8 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m5s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-16 22:49:29 +02:00
matst80
71fc23bf50 update 2025-10-16 22:49:12 +02:00
db918730d5 update to fix ingresses
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
2025-10-16 22:12:52 +02:00
8ecad3060f update mapping
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m55s
2025-10-16 21:54:20 +02:00
matst80
c060680768 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-10-16 18:01:32 +02:00
matst80
15089862d5 cleanup
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-10-16 15:38:04 +02:00
matst80
cdb0241c8a more fancy
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-10-16 13:42:21 +02:00
matst80
07a7ec5781 send mutation
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 13m3s
2025-10-16 12:58:33 +02:00
matst80
fa89670553 update checkout handler
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-10-16 12:45:45 +02:00
matst80
9ab0c08e79 handle dynamic json data and examples for subscription details
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
2025-10-16 12:09:47 +02:00
8682daf481 feature/backoffice (#6)
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m11s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/6
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-16 09:45:58 +02:00
matst80
8c2bcf5e75 send all on results on amqp
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m2s
2025-10-15 07:57:45 +02:00
matst80
47adb12112 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m1s
2025-10-14 23:24:29 +02:00
matst80
4835041f14 voucher saftey 2025-10-14 23:17:32 +02:00
104f9fbb4c missing updates (#5)
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/5
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 23:12:01 +02:00
67 changed files with 5676 additions and 982 deletions

View File

@@ -47,10 +47,13 @@ jobs:
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
- name: Apply deployment manifests
run: kubectl apply -f deployment/deployment.yaml -n cart
- name: Rollout amd64 backoffice deployment
run: |
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
- name: Rollout amd64 deployment (pin to version)
run: |
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
kubectl rollout status deployment/cart-actor-x86 -n cart
# kubectl rollout status deployment/cart-actor-x86 -n cart
BuildAndDeployArm64:
needs: Metadata
@@ -74,4 +77,4 @@ jobs:
- name: Rollout arm64 deployment (pin to version)
run: |
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
kubectl rollout status deployment/cart-actor-arm64 -n cart
# kubectl rollout status deployment/cart-actor-arm64 -n cart

View File

@@ -59,6 +59,13 @@ RUN --mount=type=cache,target=/go/build-cache \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor ./cmd/cart
RUN --mount=type=cache,target=/go/build-cache \
go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-backoffice ./cmd/backoffice
############################
# Runtime Stage
############################
@@ -67,6 +74,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-cart-backoffice /go-cart-backoffice
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337

View File

@@ -1,36 +1,5 @@
# Go Cart Actor
## Migration Notes (Ring-based Ownership Transition)
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
Summary of changes:
- ConfirmOwner RPC removed from the ControlPlane service.
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
- OwnerChangeAck retained solely as the response type for the Closing RPC.
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
- New Prometheus ring metrics:
- cart_ring_epoch
- cart_ring_hosts
- cart_ring_vnodes
- cart_ring_host_share{host}
- cart_ring_lookup_local_total
- cart_ring_lookup_remote_total
Action required for consumers:
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
- Rapid changes in ring epoch (cart_ring_epoch)
- Host churn (cart_ring_hosts)
- Imbalance in vnode distribution (cart_ring_host_share)
No data migration is necessary; cart IDs and grain state are unaffected.
---
A distributed cart management system using the actor model pattern.
## Prerequisites

BIN
cart

Binary file not shown.

View File

@@ -0,0 +1,280 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
type FileServer struct {
// Define fields here
dataDir string
storage actor.LogStorage[cart.CartGrain]
}
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
return &FileServer{
dataDir: dataDir,
storage: storage,
}
}
func isValidId(id string) (uint64, bool) {
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
return nr, true
}
if nr, ok := cart.ParseCartId(id); ok {
return uint64(nr), true
}
return 0, false
}
func isValidFileId(name string) (uint64, bool) {
parts := strings.Split(name, ".")
if len(parts) > 1 && parts[1] == "events" {
idStr := parts[0]
return isValidId(idStr)
}
return 0, false
}
// func AccessTime(info os.FileInfo) (time.Time, bool) {
// switch stat := info.Sys().(type) {
// case *syscall.Stat_t:
// // Linux: Atim; macOS/BSD: Atimespec
// // Use reflection or build tags if naming differs.
// // Linux:
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
// }
// // macOS/BSD example (uncomment if needed):
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
// }
// return time.Time{}, false
// }
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
//sys := info.Sys()
//fmt.Printf("sys type %T", sys)
out.Size = info.Size()
out.Modified = info.ModTime()
//out.Accessed, _ = AccessTime(info)
return out
}
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
func listCartFiles(dir string) ([]*CartFileInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []*CartFileInfo{}, nil
}
return nil, err
}
out := make([]*CartFileInfo, 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
}
info.Sys()
out = append(out, appendFileInfo(info, &CartFileInfo{
ID: fmt.Sprintf("%d", id),
CartId: cart.CartId(id),
}))
}
return out, nil
}
func readRawLogLines(path string) ([]json.RawMessage, error) {
fh, err := os.Open(path)
if err != nil {
return nil, err
}
defer fh.Close()
lines := make([]json.RawMessage, 0, 64)
s := bufio.NewScanner(fh)
// increase buffer to handle larger JSON lines
buf := make([]byte, 0, 1024*1024)
s.Buffer(buf, 1024*1024)
for s.Scan() {
line := s.Bytes()
if line == nil {
continue
}
lines = append(lines, line)
}
if err := s.Err(); err != nil {
return nil, err
}
return lines, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
list, err := listCartFiles(fs.dataDir)
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),
"carts": list,
})
}
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
fileName := filepath.Join(fs.dataDir, "promotions.json")
if r.Method == http.MethodGet {
file, err := os.Open(fileName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer file.Close()
io.Copy(w, file)
return
}
if r.Method == http.MethodPost {
file, err := os.Create(fileName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer file.Close()
io.Copy(file, r.Body)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
fileName := filepath.Join(fs.dataDir, "vouchers.json")
if r.Method == http.MethodGet {
file, err := os.Open(fileName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer file.Close()
io.Copy(w, file)
return
}
if r.Method == http.MethodPost {
file, err := os.Create(fileName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer file.Close()
io.Copy(file, r.Body)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "missing id")
return
}
_, ok := isValidId(idStr)
if !ok {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid id %s", idStr)
return
}
w.WriteHeader(http.StatusNotImplemented)
}
type JsonError struct {
Error string `json:"error"`
}
func (fs *FileServer) CartHandler(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
}
// reconstruct state from event log if present
grain := cart.NewCartGrain(id, time.Now())
err := fs.storage.LoadEvents(id, grain)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
path := filepath.Join(fs.dataDir, 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: "cart 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,
"cartId": cart.CartId(id).String(),
"state": grain,
"mutations": lines,
"meta": map[string]any{
"size": info.Size(),
"modified": info.ModTime(),
"path": path,
},
})
}

View File

@@ -0,0 +1,89 @@
package main
import (
"math/rand"
"os"
"path/filepath"
"testing"
"time"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
// repository (from a small curated list to keep the test hermetic) and verifies
// that appendFileInfo populates Size, Modified and System without mutating the
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
// of using "a random project file"; the test behavior is deterministic enough for
// CI because all chosen files are expected to exist.
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
candidates := []string{
filepath.FromSlash("../../pkg/cart/cart_id.go"),
filepath.FromSlash("../../pkg/actor/grain.go"),
filepath.FromSlash("../../cmd/cart/main.go"),
}
// Pick one at random.
rand.Seed(time.Now().UnixNano())
path := candidates[rand.Intn(len(candidates))]
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat failed for %s: %v", path, err)
}
// Pre-populate a CartFileInfo with identity fields.
origID := "test-id"
origCartId := cart.CartId(12345)
cf := &CartFileInfo{ID: origID, CartId: origCartId}
// Call function under test.
got := appendFileInfo(info, cf)
if got != cf {
t.Fatalf("appendFileInfo should return the same pointer instance")
}
if cf.ID != origID {
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
}
if cf.CartId != origCartId {
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
}
if cf.Size != info.Size() {
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
}
mod := info.ModTime()
// Allow small clock skew / coarse timestamp truncation.
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
}
}
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
// are updated for a freshly written file with known content length.
func TestAppendFileInfoTempFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "temp.events.log")
content := []byte("hello world\nanother line\n")
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatalf("write temp file failed: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat temp file failed: %v", err)
}
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
appendFileInfo(info, cf)
if cf.Size != int64(len(content)) {
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
}
if cf.Modified.IsZero() {
t.Fatalf("Modified should be set")
}
}

248
cmd/backoffice/hub.go Normal file
View File

@@ -0,0 +1,248 @@
package main
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"io"
"net"
"net/http"
"strings"
"time"
)
// Hub manages websocket clients and broadcasts messages to them.
type Hub struct {
register chan *Client
unregister chan *Client
broadcast chan []byte
clients map[*Client]bool
}
// Client represents a single websocket client connection.
type Client struct {
hub *Hub
conn net.Conn
send chan []byte
}
// NewHub constructs a new Hub instance.
func NewHub() *Hub {
return &Hub{
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte, 1024),
clients: make(map[*Client]bool),
}
}
// Run starts the hub event loop.
func (h *Hub) Run() {
for {
select {
case c := <-h.register:
h.clients[c] = true
case c := <-h.unregister:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
_ = c.conn.Close()
}
case msg := <-h.broadcast:
for c := range h.clients {
select {
case c.send <- msg:
default:
// Client is slow or dead; drop it.
delete(h.clients, c)
close(c.send)
_ = c.conn.Close()
}
}
}
}
}
// computeAccept computes the Sec-WebSocket-Accept header value.
func computeAccept(key string) string {
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
h := sha1.New()
h.Write([]byte(key + magic))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
http.Error(w, "upgrade required", http.StatusBadRequest)
return
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
return
}
accept := computeAccept(key)
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "websocket not supported", http.StatusInternalServerError)
return
}
conn, buf, err := hj.Hijack()
if err != nil {
return
}
// Write the upgrade response
response := "HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + accept + "\r\n" +
"\r\n"
if _, err := buf.WriteString(response); err != nil {
_ = conn.Close()
return
}
if err := buf.Flush(); err != nil {
_ = conn.Close()
return
}
client := &Client{
hub: h,
conn: conn,
send: make(chan []byte, 256),
}
h.register <- client
go client.writePump()
go client.readPump()
}
// writeWSFrame writes a single WebSocket frame to the writer.
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
// FIN set, opcode as provided
header := []byte{0x80 | (opcode & 0x0F)}
l := len(payload)
switch {
case l < 126:
header = append(header, byte(l))
case l <= 65535:
ext := make([]byte, 2)
binary.BigEndian.PutUint16(ext, uint16(l))
header = append(header, 126)
header = append(header, ext...)
default:
ext := make([]byte, 8)
binary.BigEndian.PutUint64(ext, uint64(l))
header = append(header, 127)
header = append(header, ext...)
}
if _, err := w.Write(header); err != nil {
return err
}
if l > 0 {
if _, err := w.Write(payload); err != nil {
return err
}
}
return nil
}
// readPump handles control frames from the client and discards other incoming frames.
// This server is broadcast-only to clients.
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
}()
reader := bufio.NewReader(c.conn)
for {
// Read first two bytes
b1, err := reader.ReadByte()
if err != nil {
return
}
b2, err := reader.ReadByte()
if err != nil {
return
}
opcode := b1 & 0x0F
masked := (b2 & 0x80) != 0
length := int64(b2 & 0x7F)
if length == 126 {
ext := make([]byte, 2)
if _, err := io.ReadFull(reader, ext); err != nil {
return
}
length = int64(binary.BigEndian.Uint16(ext))
} else if length == 127 {
ext := make([]byte, 8)
if _, err := io.ReadFull(reader, ext); err != nil {
return
}
length = int64(binary.BigEndian.Uint64(ext))
}
var maskKey [4]byte
if masked {
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
return
}
}
// Handle Ping -> Pong
if opcode == 0x9 && length <= 125 {
payload := make([]byte, length)
if _, err := io.ReadFull(reader, payload); err != nil {
return
}
// Unmask if masked
if masked {
for i := int64(0); i < length; i++ {
payload[i] ^= maskKey[i%4]
}
}
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
continue
}
// Close frame
if opcode == 0x8 {
// Drain payload if any, then exit
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
return
}
return
}
// For other frames, just discard payload
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
return
}
}
}
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
if !ok {
// try to send close frame
_ = writeWSFrame(c.conn, 0x8, nil)
return
}
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
return
}
case <-ticker.C:
// Send a ping to keep connections alive behind proxies
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
}
}
}

View File

@@ -1,5 +1,152 @@
package main
func main() {
// Your code here
import (
"context"
"errors"
"log"
"net/http"
"os"
"time"
actor "git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
)
type CartFileInfo struct {
ID string `json:"id"`
CartId cart.CartId `json:"cartId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return err
}
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
if err != nil {
_ = ch.Close()
return err
}
go func() {
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case m, ok := <-msgs:
if !ok {
log.Fatalf("connection closed")
continue
}
// Log and broadcast to all websocket clients
log.Printf("mutation event: %s", string(m.Body))
if hub != nil {
select {
case hub.broadcast <- m.Body:
default:
// if hub queue is full, drop to avoid blocking
}
}
if err := m.Ack(false); err != nil {
log.Printf("error acknowledging message: %v", err)
}
}
}
}()
return nil
}
func main() {
dataDir := envOrDefault("DATA_DIR", "data")
addr := envOrDefault("ADDR", ":8080")
amqpURL := os.Getenv("AMQP_URL")
_ = os.MkdirAll(dataDir, 0755)
reg := cart.NewCartMultationRegistry()
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
fs := NewFileServer(dataDir, diskStorage)
hub := NewHub()
go hub.Run()
mux := http.NewServeMux()
mux.HandleFunc("GET /carts", fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
mux.HandleFunc("/promotions", fs.PromotionsHandler)
mux.HandleFunc("/vouchers", fs.VoucherHandler)
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
mux.HandleFunc("/ws", hub.ServeWS)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
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"))
})
// Global CORS middleware allowing all origins and handling preflight
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Expose-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
mux.ServeHTTP(w, r)
})
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if amqpURL != "" {
conn, err := amqp.Dial(amqpURL)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
if err := startMutationConsumer(ctx, conn, hub); err != nil {
log.Printf("AMQP listener disabled: %v", err)
} else {
log.Printf("AMQP listener connected")
}
}
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server error: %v", err)
}
// server stopped
}

View File

@@ -9,42 +9,48 @@ import (
)
type AmqpOrderHandler struct {
Url string
Connection *amqp.Connection
Channel *amqp.Channel
conn *amqp.Connection
}
func (h *AmqpOrderHandler) Connect() error {
conn, err := amqp.Dial(h.Url)
if err != nil {
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
return &AmqpOrderHandler{
conn: conn,
}
h.Connection = conn
}
ch, err := conn.Channel()
func (h *AmqpOrderHandler) DefineTopics() error {
ch, err := h.conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
h.Channel = ch
defer ch.Close()
return nil
}
err = ch.ExchangeDeclare(
"orders", // name
"direct", // type
true, // durable
false, // auto-deleted
false, // internal
false, // no-wait
nil, // arguments
)
if err != nil {
return fmt.Errorf("failed to declare an exchange: %w", err)
}
func (h *AmqpOrderHandler) Close() error {
if h.Channel != nil {
h.Channel.Close()
}
if h.Connection != nil {
return h.Connection.Close()
}
return nil
}
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
ch, err := h.conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
defer ch.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := h.Channel.PublishWithContext(ctx,
return ch.PublishWithContext(ctx,
"orders", // exchange
"new", // routing key
false, // mandatory
@@ -53,9 +59,5 @@ func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
ContentType: "application/json",
Body: body,
})
if err != nil {
return fmt.Errorf("failed to publish a message: %w", err)
}
return nil
}

View File

@@ -3,6 +3,8 @@ package main
import (
"encoding/json"
"fmt"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
// CheckoutMeta carries the external / URL metadata required to build a
@@ -33,7 +35,7 @@ type CheckoutMeta struct {
//
// 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 *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}

120
cmd/cart/checkout_server.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
amqp "github.com/rabbitmq/amqp091-go"
)
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux) {
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.DefineTopics()
mux.HandleFunc("POST /push", func(w http.ResponseWriter, r *http.Request) {
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(orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = confirmOrder(order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(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(orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
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("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)
}
log.Printf("Klarna order validation: %s", order.ID)
w.WriteHeader(http.StatusOK)
})
}

View File

@@ -0,0 +1,60 @@
package main
import (
"log"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
"git.tornberg.me/go-cart-actor/pkg/discovery"
"k8s.io/apimachinery/pkg/watch"
"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)
}
return discovery.NewK8sDiscovery(client)
}
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {
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.Type {
case watch.Deleted:
if pool.IsKnown(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

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -64,13 +65,13 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
return nil, fmt.Errorf("%s", res.Status)
}
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -82,13 +83,14 @@ func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
return k.getOrderResponse(res)
}
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
if err != nil {
return nil, err
}
req.WithContext(ctx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -100,12 +102,12 @@ func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutO
return k.getOrderResponse(res)
}
func (k *KlarnaClient) AbortOrder(orderId string) error {
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
if err != nil {
return err
}
req.WithContext(ctx)
req.SetBasicAuth(k.UserName, k.Password)
_, err = http.DefaultClient.Do(req)
@@ -113,11 +115,12 @@ func (k *KlarnaClient) AbortOrder(orderId string) error {
}
// ordermanagement/v1/orders/{order_id}/acknowledge
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
if err != nil {
return err
}
req.WithContext(ctx)
id := uuid.New()
req.SetBasicAuth(k.UserName, k.Password)

View File

@@ -1,28 +1,28 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/discovery"
"git.tornberg.me/go-cart-actor/pkg/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/promotions"
"git.tornberg.me/go-cart-actor/pkg/proxy"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
@@ -30,14 +30,6 @@ var (
Name: "cart_grain_spawned_total",
Help: "The total number of spawned grains",
})
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_lookups_total",
Help: "The total number of lookups",
})
)
func init() {
@@ -45,7 +37,9 @@ func init() {
}
type App struct {
pool *actor.SimpleGrainPool[CartGrain]
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
klarnaClient *KlarnaClient
}
var podIp = os.Getenv("POD_IP")
@@ -76,84 +70,50 @@ func getCountryFromHost(host string) string {
return ""
}
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)
}
return discovery.NewK8sDiscovery(client)
}
type MutationContext struct {
VoucherService voucher.Service
}
type CartChangeEvent struct {
CartId cart.CartId `json:"cartId"`
Mutations []actor.ApplyResult `json:"mutations"`
}
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
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{}
promotionData, err := promotions.LoadStateFile("data/promotions.json")
if err != nil {
log.Printf("Error loading promotions: %v\n", err)
}
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
promotionService := promotions.NewPromotionService(nil)
reg := cart.NewCartMultationRegistry()
reg.RegisterProcessor(
actor.NewMutationProcessor(func(g *cart.CartGrain) error {
g.UpdateTotals()
ctx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
_, actions := promotionService.EvaluateAll(promotionData.State.Promotions, ctx)
for _, action := range actions {
log.Printf("apply: %+v", action)
g.UpdateTotals()
}
return nil
}),
)
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[CartGrain]{
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
Spawn: func(id uint64) (actor.Grain[cart.CartGrain], error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
}
ret := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now()
ret.lastAccess = time.Now()
err := diskStorage.LoadEvents(id, ret)
@@ -171,66 +131,55 @@ func main() {
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
app := &App{
pool: pool,
pool: pool,
server: syncedServer,
klarnaClient: klarnaClient,
}
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Printf("no connection to amqp defined")
} else {
app.HandleCheckoutRequests(amqpUrl, mux)
}
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
go diskStorage.SaveLoop(10 * time.Second)
// go diskStorage.SaveLoop(10 * time.Second)
UseDiscovery(pool)
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.Type {
case watch.Deleted:
if pool.IsKnown(evt.Host) {
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemote(evt.Host)
}
}
}
}(GetDiscovery())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
orderHandler := &AmqpOrderHandler{
Url: amqpUrl,
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
syncedServer.Serve(mux)
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
})
// mux.HandleFunc("GET /save", app.HandleSave)
//mux.HandleFunc("/", app.RewritePath)
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
mux.Handle("/metrics", promhttp.Handler())
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
debugMux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
grainCount, capacity := app.pool.LocalUsage()
@@ -256,152 +205,6 @@ func main() {
w.Write([]byte("ok"))
})
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
orderId := r.URL.Query().Get("order_id")
order := &CheckoutOrder{}
if orderId == "" {
cookie, err := r.Cookie("cartid")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
if cookie.Value == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("no cart id to checkout is empty"))
return
}
parsed, ok := ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid cart id format"))
return
}
cartId := parsed
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
return nil
})(cartId, w, r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
} else {
order, err = klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
})
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
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("/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)
}
log.Printf("Klarna order validation: %s", order.ID)
//err = confirmOrder(order, orderHandler)
//if err != nil {
// log.Printf("Error validating order: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
//}
//
//err = triggerOrderCompleted(err, syncedServer, order)
//if err != nil {
// log.Printf("Error processing cart message: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
//}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
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 := klarnaClient.GetOrder(orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = confirmOrder(order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = klarnaClient.AcknowledgeOrder(orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
@@ -409,22 +212,41 @@ func main() {
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
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, "/"),
}
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
done <- true
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Server started at port 8080")
go http.ListenAndServe(":8080", mux)
<-done
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
// Error when starting HTTP server.
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
// Wait for first CTRL+C.
// Stop receiving signal notifications as soon as possible.
stop()
}
}
@@ -433,7 +255,7 @@ func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error
OrderId: order.ID,
Status: order.Status,
}
cid, ok := ParseCartId(order.MerchantReference1)
cid, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
@@ -447,11 +269,7 @@ func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
if err != nil {
return err
}
err = orderHandler.Connect()
if err != nil {
return err
}
defer orderHandler.Close()
err = orderHandler.OrderCompleted(orderToSend)
if err != nil {
return err

117
cmd/cart/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
}

View File

@@ -2,6 +2,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -11,18 +12,39 @@ import (
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/gogo/protobuf/proto"
"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"
"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: "cart_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_lookups_total",
Help: "The total number of lookups",
})
)
type PoolServer struct {
actor.GrainPool[*CartGrain]
actor.GrainPool[*cart.CartGrain]
pod_name string
klarnaClient *KlarnaClient
}
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
return &PoolServer{
GrainPool: pool,
pod_name: pod_name,
@@ -30,11 +52,11 @@ func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClie
}
}
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
func (s *PoolServer) ApplyLocal(id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
return s.Apply(uint64(id), mutation...)
}
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
grain, err := s.Get(uint64(id))
if err != nil {
return err
@@ -43,9 +65,9 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id C
return s.WriteResult(w, grain)
}
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku")
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
@@ -53,6 +75,7 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
@@ -73,7 +96,7 @@ func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
return err
}
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
@@ -93,7 +116,7 @@ type SetDeliveryRequest struct {
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
@@ -111,7 +134,7 @@ func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request,
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
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)
@@ -138,7 +161,7 @@ func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Reques
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
@@ -152,7 +175,7 @@ func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Reques
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
@@ -176,14 +199,14 @@ type SetCartItems struct {
Items []Item `json:"items"`
}
func getMultipleAddMessages(items []Item, country string) []proto.Message {
func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
msgs := make([]proto.Message, 0, len(items))
for _, itm := range items {
wg.Go(
func() {
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
@@ -197,7 +220,7 @@ func getMultipleAddMessages(items []Item, country string) []proto.Message {
return msgs
}
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
@@ -206,7 +229,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
msgs = append(msgs, &messages.ClearCartRequest{})
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
reply, err := s.ApplyLocal(id, msgs...)
if err != nil {
@@ -215,14 +238,14 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
return s.WriteResult(w, reply)
}
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
reply, err := s.ApplyLocal(id, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
if err != nil {
return err
}
@@ -236,13 +259,13 @@ type AddRequest struct {
StoreId *string `json:"storeId"`
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
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)
if err != nil {
return err
}
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
if err != nil {
return err
}
@@ -287,7 +310,7 @@ func getLocale(country string) string {
return "sv-se"
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
func (s *PoolServer) CreateOrUpdateCheckout(ctx context.Context, host string, id cart.CartId) (*CheckoutOrder, error) {
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
@@ -313,13 +336,13 @@ func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOr
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
return s.klarnaClient.UpdateOrder(ctx, grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
return s.klarnaClient.CreateOrder(ctx, bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
@@ -341,12 +364,12 @@ func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId)
// }
//
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
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 CartId
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = MustNewCartId()
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
@@ -358,9 +381,9 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.R
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := ParseCartId(cookie.Value)
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = MustNewCartId()
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
@@ -388,7 +411,7 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.R
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{
Name: "cartid",
@@ -403,17 +426,17 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
return nil
}
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
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 CartId
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := MustNewCartId()
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := ParseCartId(raw); !ok {
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
@@ -431,30 +454,66 @@ func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId CartId, w http.ResponseWriter, r *http.Request) 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 {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("cartid", cartId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
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)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("cartid", cartId.String()))
defer span.End()
return fn(w, r, cartId)
}
}
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
// rollCnt 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)
}
}
type AddVoucherRequest struct {
VoucherCode string `json:"code"`
}
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
data := &AddVoucherRequest{}
json.NewDecoder(r.Body).Decode(data)
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
s.ApplyLocal(cartId, &messages.PreConditionFailed{
Operation: "AddVoucher",
Error: err.Error(),
})
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
@@ -469,7 +528,44 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
return nil
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
data := &messages.UpsertSubscriptionDetails{}
err := json.NewDecoder(r.Body).Decode(data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
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.Context(), r.Host, cartId)
if err != nil {
return err
}
s.ApplyCheckoutStarted(order, cartId)
return fn(order, w)
}
order, err := s.klarnaClient.GetOrder(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")
id, err := strconv.ParseInt(idStr, 10, 64)
@@ -488,45 +584,57 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
return nil
}
func (s *PoolServer) Serve() *http.ServeMux {
func (s *PoolServer) Serve(mux *http.ServeMux) {
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
})
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
// w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// w.WriteHeader(http.StatusOK)
// })
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
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.SetAttributes(attr)
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
handlerFunc(w, r)
}))
}
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
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)))
//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)))
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux
}

View File

@@ -1,12 +1,14 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.tornberg.me/go-cart-actor/pkg/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/matst80/slask-finder/pkg/index"
)
@@ -25,9 +27,14 @@ func getBaseUrl(country string) string {
return "http://localhost:8082"
}
func FetchItem(sku string, country string) (*index.DataItem, error) {
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
baseUrl := getBaseUrl(country)
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
req = req.WithContext(ctx)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
@@ -37,36 +44,46 @@ func FetchItem(sku string, country string) (*index.DataItem, error) {
return &item, err
}
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
item, err := FetchItem(ctx, sku, country)
if err != nil {
return nil, err
}
return ToItemAddMessage(item, storeId, qty, country), nil
return ToItemAddMessage(item, storeId, qty, country)
}
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if err != nil {
return nil
return nil, err
}
stock := StockStatus(0)
stock := cart.StockStatus(0)
centralStockValue, ok := item.GetStringFieldValue(3)
if storeId == nil {
if ok {
if !item.Buyable {
return nil, fmt.Errorf("item not available")
}
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
stock = StockStatus(centralStock)
stock = cart.StockStatus(centralStock)
}
if stock == cart.StockStatus(0) && item.SaleStatus == "TBD" {
return nil, fmt.Errorf("no items available")
}
}
} else {
if !item.BuyableInStore {
return nil, fmt.Errorf("item not available in store")
}
storeStock, ok := item.Stock.GetStock()[*storeId]
if ok {
stock = StockStatus(storeStock)
stock = cart.StockStatus(storeStock)
}
}
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
@@ -108,7 +125,8 @@ func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country st
Country: country,
Outlet: outlet,
StoreId: storeId,
}
SaleStatus: item.SaleStatus,
}, nil
}
func getTax(articleType string) int32 {
@@ -119,3 +137,10 @@ func getTax(articleType string) int32 {
return 2500
}
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}

76
cmd/inventory/main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"context"
"log"
"time"
"git.tornberg.me/go-cart-actor/pkg/inventory"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
func main() {
var ctx = context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "10.10.3.18:6379",
Password: "slaskredis", // no password set
DB: 0, // use default DB
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
s, err := inventory.NewRedisInventoryService(rdb, ctx)
if err != nil {
log.Fatalf("Unable to connect to inventory redis: %v", err)
return
}
// Start inventory change listener
listener := inventory.NewInventoryChangeListener(rdb, ctx, func(changes []inventory.InventoryChange) {
for _, change := range changes {
log.Printf("Inventory change detected: SKU %s at location %s now has %d", change.SKU, change.StockLocationID, change.Value)
}
})
log.Println("Starting inventory change listener...")
go listener.Start()
listener.WaitReady()
log.Println("Listener is ready, proceeding with inventory operations...")
rdb.Pipelined(ctx, func(p redis.Pipeliner) error {
s.UpdateInventory(p, "1", "1", 10)
s.UpdateInventory(p, "2", "2", 20)
s.UpdateInventory(p, "3", "3", 30)
s.UpdateInventory(p, "4", "4", 40)
return nil
})
err = s.ReserveInventory(
inventory.ReserveRequest{
SKU: "1",
LocationID: "1",
Quantity: 3,
},
inventory.ReserveRequest{
SKU: "2",
LocationID: "2",
Quantity: 15,
},
inventory.ReserveRequest{
SKU: "3",
LocationID: "3",
Quantity: 25,
},
)
if err != nil {
log.Printf("Unable to reserve inventory: %v", err)
}
v, err := s.GetInventory("1", "1")
if err != nil {
log.Printf("Unable to get inventory: %v", err)
return
}
log.Printf("Inventory after reservation: %v", v)
// Wait a bit for listener to process messages
time.Sleep(2 * time.Second)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,94 @@ type: Opaque
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-backoffice
arch: amd64
name: cart-backoffice-x86
spec:
replicas: 1
selector:
matchLabels:
app: cart-backoffice
arch: amd64
template:
metadata:
labels:
app: cart-backoffice
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/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
command: ["/go-cart-backoffice"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 30
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- 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: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
# - name: BASE_URL
# value: "https://s10n-no.tornberg.me"
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
@@ -55,6 +143,8 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
@@ -62,14 +152,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
periodSeconds: 50
volumeMounts:
- mountPath: "/data"
name: data
@@ -87,6 +177,10 @@ spec:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
@@ -157,6 +251,8 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
@@ -164,14 +260,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
periodSeconds: 30
volumeMounts:
- mountPath: "/data"
name: data
@@ -184,6 +280,10 @@ spec:
env:
- name: TZ
value: "Europe/Stockholm"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
@@ -212,7 +312,7 @@ apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
@@ -222,6 +322,17 @@ spec:
- name: web
port: 8080
---
kind: Service
apiVersion: v1
metadata:
name: cart-backoffice
spec:
selector:
app: cart-backoffice
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -250,3 +361,27 @@ spec:
name: cart-actor
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-backend-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- slask-cart.tornberg.me
secretName: cart-backoffice-actor-tls-secret
rules:
- host: slask-cart.tornberg.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cart-backoffice
port:
number: 8080

42
go.mod
View File

@@ -1,13 +1,26 @@
module git.tornberg.me/go-cart-actor
go 1.25.1
go 1.25.3
require (
github.com/gogo/protobuf v1.3.2
github.com/google/uuid v1.6.0
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68
github.com/prometheus/client_golang v1.23.2
github.com/rabbitmq/amqp091-go v1.10.0
github.com/redis/go-redis/v9 v9.16.0
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
k8s.io/api v0.34.1
@@ -16,16 +29,20 @@ require (
)
require (
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.13.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/bits-and-blooms/bitset v1.24.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/swag v0.25.1 // indirect
@@ -43,6 +60,7 @@ require (
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -55,18 +73,21 @@ require (
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/procfs v0.18.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
@@ -74,8 +95,9 @@ require (
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

103
go.sum
View File

@@ -1,10 +1,15 @@
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/RoaringBitmap/roaring/v2 v2.13.0 h1:38BxJ6lGPcBLykIRCyYtViB/By3+a/iS9znKsiBbhNc=
github.com/RoaringBitmap/roaring/v2 v2.13.0/go.mod h1:Mpi+oQ+3oCU7g1aF75Ib/XYCTqjTGpHI0f8djSZVY3I=
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.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.1 h1:hqnfFbjjk3pxGa5E9Ho3hjoU7odtUuNmJ9Ao+Bo8s1c=
github.com/bits-and-blooms/bitset v1.24.1/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -14,11 +19,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
@@ -26,6 +35,7 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -95,6 +105,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -116,8 +128,8 @@ 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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68 h1:nDSim5IRFrLG3okBty3nes50ZuxKMEY5rwj2FuUnTgc=
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68/go.mod h1:ZlbURsmhMX98D8z/du5Cez8fZU/sfkA98ruczIsB9PY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -156,35 +168,37 @@ github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -195,18 +209,38 @@ 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=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -218,8 +252,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -271,16 +305,18 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -312,7 +348,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=

View File

@@ -26,7 +26,7 @@ type DiskStorage[V any] struct {
type LogStorage[V any] interface {
LoadEvents(id uint64, grain Grain[V]) error
AppendEvent(id uint64, msg ...proto.Message) error
AppendMutations(id uint64, msg ...proto.Message) error
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
@@ -104,11 +104,13 @@ func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
}
func (s *DiskStorage[V]) Close() {
s.save()
if s.queue != nil {
s.save()
}
close(s.done)
}
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error {
if s.queue != nil {
queue := make([]QueueEvent, 0)
data, found := s.queue.Load(id)

View File

@@ -22,6 +22,7 @@ type GrainPool[V any] interface {
Negotiate(otherHosts []string)
GetLocalIds() []uint64
RemoveHost(host string)
AddRemoteHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()

View File

@@ -8,6 +8,10 @@ import (
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"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"
)
@@ -16,13 +20,77 @@ import (
// It delegates to a grain pool and cluster operations to a synced pool.
type ControlServer[V any] struct {
messages.UnimplementedControlPlaneServer
pool GrainPool[V]
}
const name = "grpc_server"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
pingCalls metric.Int64Counter
negotiateCalls metric.Int64Counter
getLocalActorIdsCalls metric.Int64Counter
announceOwnershipCalls metric.Int64Counter
announceExpiryCalls metric.Int64Counter
closingCalls metric.Int64Counter
)
func init() {
var err error
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
metric.WithDescription("Number of ping calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
metric.WithDescription("Number of negotiate calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
metric.WithDescription("Number of get local actor ids calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
metric.WithDescription("Number of announce ownership calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
metric.WithDescription("Number of announce expiry calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
metric.WithDescription("Number of closing calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
defer span.End()
span.SetAttributes(
attribute.String("component", "controlplane"),
attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)),
)
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
if err != nil {
span.RecordError(err)
return &messages.OwnerChangeAck{
Accepted: false,
Message: "owner change failed",
@@ -37,7 +105,20 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
}
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()
span.SetAttributes(
attribute.String("component", "controlplane"),
attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)),
)
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
if err != nil {
span.RecordError(err)
}
return &messages.OwnerChangeAck{
Accepted: err == nil,
Message: "expiry acknowledged",
@@ -46,15 +127,28 @@ 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) {
host := s.pool.Hostname()
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
// log.Printf("got ping")
return &messages.PingReply{
Host: s.pool.Hostname(),
Host: host,
UnixTime: time.Now().Unix(),
}, 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")
defer span.End()
span.SetAttributes(
attribute.String("component", "controlplane"),
attribute.Int("known_hosts_count", len(req.KnownHosts)),
)
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
negotiateCalls.Add(ctx, 1)
s.pool.Negotiate(req.KnownHosts)
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
@@ -62,13 +156,33 @@ 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) {
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
defer span.End()
ids := s.pool.GetLocalIds()
span.SetAttributes(
attribute.String("component", "controlplane"),
attribute.Int("id_count", len(ids)),
)
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
getLocalActorIdsCalls.Add(ctx, 1)
return &messages.ActorIdsReply{Ids: ids}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
if req.GetHost() != "" {
s.pool.RemoveHost(req.GetHost())
ctx, span := tracer.Start(ctx, "grpc_closing")
defer span.End()
host := req.GetHost()
span.SetAttributes(
attribute.String("component", "controlplane"),
attribute.String("host", host),
)
logger.InfoContext(ctx, "closing notice", "host", host)
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
if host != "" {
s.pool.RemoveHost(host)
}
return &messages.OwnerChangeAck{
Accepted: true,

View File

@@ -0,0 +1,47 @@
package actor
import (
"log"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
)
type LogListener interface {
AppendMutations(id uint64, msg ...ApplyResult)
}
type AmqpListener struct {
conn *amqp.Connection
transformer func(id uint64, msg []ApplyResult) (any, error)
}
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
return &AmqpListener{
conn: conn,
transformer: transformer,
}
}
func (l *AmqpListener) DefineTopics() {
ch, err := l.conn.Channel()
if err != nil {
log.Fatalf("Failed to open a channel: %v", err)
}
defer ch.Close()
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
log.Fatalf("Failed to declare topic mutation: %v", err)
}
}
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
data, err := l.transformer(id, msg)
if err != nil {
log.Printf("Failed to transform mutation event: %v", err)
return
}
err = messaging.SendChange(l.conn, "cart", "mutation", data)
if err != nil {
log.Printf("Failed to send mutation event: %v", err)
}
}

View File

@@ -15,11 +15,32 @@ type ApplyResult struct {
Error error `json:"error,omitempty"`
}
type MutationProcessor interface {
Process(grain any) error
}
type BasicMutationProcessor[V any] struct {
processor func(any) error
}
func NewMutationProcessor[V any](process func(V) error) MutationProcessor {
return &BasicMutationProcessor[V]{
processor: func(v any) error {
return process(v.(V))
},
}
}
func (p *BasicMutationProcessor[V]) Process(grain any) error {
return p.processor(grain)
}
type MutationRegistry interface {
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
RegisterMutations(handlers ...MutationHandler)
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)
}
@@ -27,6 +48,7 @@ type MutationRegistry interface {
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
processors []MutationProcessor
}
var (
@@ -114,9 +136,14 @@ func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
processors: make([]MutationProcessor, 0),
}
}
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
r.processors = append(r.processors, processors...)
}
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
@@ -171,11 +198,21 @@ func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyR
if grain == nil {
return results, fmt.Errorf("nil grain")
}
// Nil slice of mutations still treated as an error (call contract violation).
if msg == nil {
return results, fmt.Errorf("nil mutation message")
}
for _, m := range msg {
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
if m == nil {
continue
}
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
rv := reflect.ValueOf(m)
if rv.Kind() == reflect.Ptr && rv.IsNil() {
continue
}
rt := indirectType(reflect.TypeOf(m))
r.mutationRegistryMu.RLock()
entry, ok := r.mutationRegistry[rt]
@@ -188,10 +225,14 @@ func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyR
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
// if entry.updateTotals {
// grain.UpdateTotals()
// }
if len(results) > 0 {
for _, processor := range r.processors {
err := processor.Process(grain)
if err != nil {
return results, err
}
}
}
return results, nil
}

View File

@@ -21,8 +21,8 @@ func TestRegisteredMutationBasics(t *testing.T) {
func(state *cartState, msg *messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := *msg
state.lastAdded = &cp
cp := msg
state.lastAdded = cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },

View File

@@ -17,6 +17,7 @@ type SimpleGrainPool[V any] struct {
mutationRegistry MutationRegistry
spawn func(id uint64) (Grain[V], error)
spawnHost func(host string) (Host, error)
listeners []LogListener
storage LogStorage[V]
ttl time.Duration
poolSize int
@@ -66,6 +67,19 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
return p, nil
}
func (p *SimpleGrainPool[V]) AddListener(listener LogListener) {
p.listeners = append(p.listeners, listener)
}
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
for i, l := range p.listeners {
if l == listener {
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
break
}
}
}
func (p *SimpleGrainPool[V]) purge() {
purgeLimit := time.Now().Add(-p.ttl)
purgedIds := make([]uint64, 0, len(p.grains))
@@ -143,6 +157,10 @@ func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
p.broadcastOwnership([]uint64{id})
}
func (p *SimpleGrainPool[V]) AddRemoteHost(host string) {
p.AddRemote(host)
}
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
@@ -383,11 +401,14 @@ func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*Mutat
}
if p.storage != nil {
go func() {
if err := p.storage.AppendEvent(id, mutation...); err != nil {
if err := p.storage.AppendMutations(id, mutation...); err != nil {
log.Printf("failed to store mutation for grain %d: %v", id, err)
}
}()
}
for _, listener := range p.listeners {
go listener.AppendMutations(id, mutations...)
}
result, err := grain.GetCurrentState()
if err != nil {
return nil, err

View File

@@ -1,8 +1,7 @@
package main
package cart
import (
"encoding/json"
"fmt"
"slices"
"sync"
"time"
@@ -33,7 +32,7 @@ type ItemMeta struct {
type CartItem struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId uint32 `json:"parentId,omitempty"`
ParentId *uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
@@ -45,6 +44,7 @@ type CartItem struct {
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
SaleStatus string `json:"saleStatus"`
}
type CartDelivery struct {
@@ -62,32 +62,42 @@ type CartNotification struct {
Content string `json:"content"`
}
type SubscriptionDetails struct {
Id string `json:"id"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Meta json.RawMessage `json:"data,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
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"`
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
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"`
}
type Voucher struct {
Code string `json:"code"`
Rules []*messages.VoucherRule `json:"rules"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
@@ -119,8 +129,8 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
}
// All voucher rules must pass (logical AND)
for _, rule := range v.Rules {
expr := rule.GetCondition()
for _, expr := range v.Rules {
if expr == "" {
// Empty condition treated as pass (acts like a comment / placeholder)
continue
@@ -138,6 +148,23 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
return cart.Items, true
}
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
return &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
SubscriptionDetails: make(map[string]*SubscriptionDetails),
}
}
func (c *CartGrain) GetId() uint64 {
return uint64(c.Id)
}
@@ -155,22 +182,6 @@ func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
return c, nil
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
// func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
// cartItem, err := getItemData(sku, qty, country)
// if err != nil {
// return nil, err
// }
// cartItem.StoreId = storeId
// return c.Apply(cartItem, false)
// }
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
@@ -240,27 +251,36 @@ func (c *CartGrain) UpdateTotals() {
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
if item.OrgPrice != nil {
diff := NewPrice()
diff.Add(*item.OrgPrice)
diff.Subtract(item.Price)
diff.Multiply(int64(item.Quantity))
rowTotal.Subtract(*diff)
item.Discount = diff
if diff.IncVat > 0 {
c.TotalDiscount.Add(*diff)
}
}
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, voucher := range c.Vouchers {
if _, ok := voucher.AppliesTo(c); ok {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
if ok {
value := NewPriceFromIncVat(voucher.Value, 25)
if c.TotalPrice.IncVat <= value.IncVat {
// don't apply discounts to more than the total price
continue
}
voucher.Applied = true
c.TotalDiscount.Add(*value)
c.TotalPrice.Subtract(*value)
}

View File

@@ -0,0 +1,54 @@
package cart
import (
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
func NewCartMultationRegistry() 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(PreConditionFailed, func() *messages.PreConditionFailed {
return &messages.PreConditionFailed{}
}),
)
return reg
}

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"testing"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"crypto/rand"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -28,9 +28,22 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
// Merge with any existing item having same SKU and matching StoreId (including both nil).
for _, existing := range g.Items {
if existing.Sku != m.Sku {
continue
}
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
if !sameStore {
continue
}
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(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
}
@@ -63,6 +76,8 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
SellerId: m.SellerId,
SellerName: m.SellerName,
},
SaleStatus: m.SaleStatus,
ParentId: m.ParentId,
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"slices"
@@ -54,10 +54,12 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
g.lastVoucherId++
g.Vouchers = append(g.Vouchers, &Voucher{
Id: g.lastVoucherId,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
Id: g.lastVoucherId,
Applied: false,
Description: m.Description,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
})
g.UpdateTotals()
return nil

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -45,6 +45,7 @@ func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
if m.Quantity <= 0 {
// Remove the item
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
g.UpdateTotals()
return nil
}

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

View File

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

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"

520
pkg/cart/mutation_test.go Normal file
View File

@@ -0,0 +1,520 @@
package cart
import (
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/gogo/protobuf/proto"
anypb "google.golang.org/protobuf/types/known/anypb"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/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 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(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",
}
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(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(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(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(g, nil)
if len(results) != 0 {
t.Fatalf("expected no results when message slice nil")
}
}
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Valid JSON on create
validCreate := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFFJSON",
SigningType: "TYPEJSON",
Data: &anypb.Any{Value: []byte(`{"ok":true}`)},
}
applyOK(t, reg, g, validCreate)
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) != `{"ok":true}` {
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Update with valid JSON replaces meta
updateValid := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte(`{"changed":123}`)},
}
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
invalidCreate := &messages.UpsertSubscriptionDetails{
OfferingCode: "BAD",
Data: &anypb.Any{Value: []byte(`{"broken":}`)},
}
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
badUpdate := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte(`{oops`)},
}
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.Value should not overwrite existing meta
emptyUpdate := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte{}},
}
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))
}
}

View File

@@ -0,0 +1,57 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
// Create new subscription details when Id is nil.
if m.Id == nil {
// Validate JSON if provided.
var meta json.RawMessage
if m.Data != nil && len(m.Data.Value) > 0 {
if !json.Valid(m.Data.Value) {
return fmt.Errorf("subscription details invalid json")
}
meta = json.RawMessage(m.Data.Value)
}
id := MustNewCartId().String()
g.SubscriptionDetails[id] = &SubscriptionDetails{
Id: id,
OfferingCode: m.OfferingCode,
SigningType: m.SigningType,
Meta: meta,
}
return nil
}
// Update existing entry.
existing, ok := g.SubscriptionDetails[*m.Id]
if !ok {
return fmt.Errorf("subscription details not found")
}
if m.OfferingCode != "" {
existing.OfferingCode = m.OfferingCode
}
if m.SigningType != "" {
existing.SigningType = m.SigningType
}
if m.Data != nil {
// Only validate & assign if there is content; empty -> leave as-is.
if len(m.Data.Value) > 0 {
if !json.Valid(m.Data.Value) {
return fmt.Errorf("subscription details invalid json")
}
existing.Meta = m.Data.Value
}
}
return nil
}

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package main
package cart
import (
"encoding/json"
@@ -33,14 +33,14 @@ func TestPriceMarshalJSON(t *testing.T) {
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1000, 0.25)
if p.IncVat != 1000 {
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
p := NewPriceFromIncVat(1250, 25)
if p.IncVat != 1250 {
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
}
if p.ValueExVat() != 750 {
if p.ValueExVat() != 1000 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}

85
pkg/inventory/listener.go Normal file
View File

@@ -0,0 +1,85 @@
package inventory
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/redis/go-redis/v9"
)
type InventoryChange struct {
SKU string `json:"sku"`
StockLocationID string `json:"stock_location_id"`
Value int64 `json:"value"`
}
type InventoryChangeHandler func(changes []InventoryChange)
type InventoryChangeListener struct {
client *redis.Client
ctx context.Context
handler InventoryChangeHandler
ready chan struct{}
}
func NewInventoryChangeListener(client *redis.Client, ctx context.Context, handler InventoryChangeHandler) *InventoryChangeListener {
return &InventoryChangeListener{
client: client,
ctx: ctx,
handler: handler,
ready: make(chan struct{}),
}
}
func (l *InventoryChangeListener) WaitReady() {
<-l.ready
}
func (l *InventoryChangeListener) Start() error {
pubsub := l.client.Subscribe(l.ctx, "inventory_changed")
defer pubsub.Close()
// Signal that we're ready to receive messages
close(l.ready)
ch := pubsub.Channel()
for msg := range ch {
var payload map[string]int64
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
log.Printf("Failed to unmarshal inventory change message: %v", err)
continue
}
changes := make([]InventoryChange, 0, len(payload))
for key, newValue := range payload {
// Parse key: "inventory:sku:location"
parts := strings.Split(key, ":")
if len(parts) != 3 || parts[0] != "inventory" {
log.Printf("Invalid inventory key format: %s", key)
continue
}
sku := parts[1]
locationID := parts[2]
changes = append(changes, InventoryChange{
SKU: sku,
StockLocationID: locationID,
Value: newValue,
})
}
if l.handler != nil {
l.handler(changes)
} else {
// Default logging
for _, change := range changes {
fmt.Printf("Inventory changed: SKU %s at location %s -> %d\n", change.SKU, change.StockLocationID, change.Value)
}
}
}
return nil
}

View File

@@ -0,0 +1,251 @@
package inventory
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
)
type RedisInventoryService struct {
client *redis.Client
ctx context.Context
luaScripts map[string]*redis.Script
}
func NewRedisInventoryService(client *redis.Client, ctx context.Context) (*RedisInventoryService, error) {
rdb := client
// Ping Redis to check connection
_, err := rdb.Ping(ctx).Result()
if err != nil {
return nil, err
}
return &RedisInventoryService{
client: rdb,
ctx: ctx,
luaScripts: make(map[string]*redis.Script),
}, nil
}
func (s *RedisInventoryService) LoadLuaScript(key string) error {
// Get the script from Redis
script, err := s.client.Get(s.ctx, key).Result()
if err != nil {
return err
}
// Load the script into the luaScripts cache
s.luaScripts[key] = redis.NewScript(script)
return nil
}
func (s *RedisInventoryService) AddWarehouse(warehouse *Warehouse) error {
// Convert warehouse to Redis-friendly format
data := map[string]interface{}{
"id": string(warehouse.ID),
"name": warehouse.Name,
"inventory": warehouse.Inventory,
}
// Store in Redis with a key pattern like "warehouse:<ID>"
key := "warehouse:" + string(warehouse.ID)
_, err := s.client.HMSet(s.ctx, key, data).Result()
return err
}
func (s *RedisInventoryService) GetInventory(sku SKU, locationID LocationID) (int64, error) {
cmd := s.client.Get(s.ctx, getInventoryKey(sku, locationID))
if err := cmd.Err(); err != nil {
return 0, err
}
i, err := cmd.Int64()
if err != nil {
return 0, err
}
return i, nil
}
func getInventoryKey(sku SKU, locationID LocationID) string {
return fmt.Sprintf("inventory:%s:%s", sku, locationID)
}
func (s *RedisInventoryService) UpdateInventory(rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error {
key := getInventoryKey(sku, locationID)
cmd := rdb.Set(s.ctx, key, quantity, 0)
return cmd.Err()
}
var (
ErrInsufficientInventory = errors.New("insufficient inventory")
ErrInvalidQuantity = errors.New("invalid quantity")
ErrMissingReservation = errors.New("missing reservation")
)
func makeKeysAndArgs(req ...ReserveRequest) ([]string, []string) {
keys := make([]string, len(req))
args := make([]string, len(req))
for i, r := range req {
if r.Quantity <= 0 {
return nil, nil
}
keys[i] = getInventoryKey(r.SKU, r.LocationID)
args[i] = strconv.Itoa(int(r.Quantity))
}
return keys, args
}
func (s *RedisInventoryService) ReservationCheck(req ...ReserveRequest) error {
if len(req) == 0 {
return ErrMissingReservation
}
keys, args := makeKeysAndArgs(req...)
if keys == nil || args == nil {
return ErrInvalidQuantity
}
cmd := reservationCheck.Run(s.ctx, s.client, keys, args)
if err := cmd.Err(); err != nil {
return err
}
if val, err := cmd.Int(); err != nil {
return err
} else if val != 1 {
return ErrInsufficientInventory
}
return nil
}
func (s *RedisInventoryService) ReserveInventory(req ...ReserveRequest) error {
if len(req) == 0 {
return ErrMissingReservation
}
keys, args := makeKeysAndArgs(req...)
if keys == nil || args == nil {
return ErrInvalidQuantity
}
cmd := reserveScript.Run(s.ctx, s.client, keys, args)
if err := cmd.Err(); err != nil {
return err
}
if val, err := cmd.Int(); err != nil {
return err
} else if val != 1 {
return ErrInsufficientInventory
}
return nil
}
var reservationCheck = redis.NewScript(`
-- Get the number of keys passed
local num_keys = #KEYS
-- Ensure the number of keys matches the number of quantities
if num_keys ~= #ARGV then
return {err = "Script requires the same number of keys and quantities."}
end
local new_values = {}
local payload = {}
-- ---
-- 1. CHECK PHASE
-- ---
-- Loop through all keys to check their values first
for i = 1, num_keys do
local key = KEYS[i]
local quantity_to_check = tonumber(ARGV[i])
-- Fail if the quantity is not a valid number
if not quantity_to_check then
return {err = "Invalid quantity provided for key: " .. key}
end
-- Get the current value stored at the key
local current_val = tonumber(redis.call('GET', key))
-- Check the condition
-- Fail if:
-- 1. The key doesn't exist (current_val is nil)
-- 2. The value is not > the required quantity
if not current_val or current_val <= quantity_to_check then
-- Return 0 to indicate the operation failed and no changes were made
return 0
end
end
return 1
`)
var reserveScript = redis.NewScript(`
-- Get the number of keys passed
local num_keys = #KEYS
-- Ensure the number of keys matches the number of quantities
if num_keys ~= #ARGV then
return {err = "Script requires the same number of keys and quantities."}
end
local new_values = {}
local payload = {}
-- ---
-- 1. CHECK PHASE
-- ---
-- Loop through all keys to check their values first
for i = 1, num_keys do
local key = KEYS[i]
local quantity_to_check = tonumber(ARGV[i])
-- Fail if the quantity is not a valid number
if not quantity_to_check then
return {err = "Invalid quantity provided for key: " .. key}
end
-- Get the current value stored at the key
local current_val = tonumber(redis.call('GET', key))
-- Check the condition
-- Fail if:
-- 1. The key doesn't exist (current_val is nil)
-- 2. The value is not > the required quantity
if not current_val or current_val <= quantity_to_check then
-- Return 0 to indicate the operation failed and no changes were made
return 0
end
-- If the check passes, store the new value
local new_val = current_val - quantity_to_check
table.insert(new_values, new_val)
-- Add this key and its *new* value to our payload map
payload[key] = new_val
end
-- ---
-- 2. UPDATE PHASE
-- ---
-- If the script reaches this point, all checks passed.
-- Now, loop again and apply all the updates.
for i = 1, num_keys do
local key = KEYS[i]
local new_val = new_values[i]
-- Set the key to its new calculated value
redis.call('SET', key, new_val)
end
local message_payload = cjson.encode(payload)
-- Publish the JSON-encoded message to the specified channel
redis.call('PUBLISH', "inventory_changed", message_payload)
-- Return 1 to indicate the operation was successful
return 1
`)

30
pkg/inventory/types.go Normal file
View File

@@ -0,0 +1,30 @@
package inventory
import "time"
type SKU string
type LocationID string
type InventoryItem struct {
SKU SKU
Quantity int
LastUpdate time.Time
}
type Warehouse struct {
ID LocationID
Name string
Inventory []InventoryItem
}
type InventoryService interface {
GetInventory(sku SKU, locationID LocationID) (int64, error)
ReserveInventory(req ...ReserveRequest) error
}
type ReserveRequest struct {
SKU SKU
LocationID LocationID
Quantity uint32
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v5.29.3
// protoc-gen-go v1.36.10
// protoc v6.32.1
// source: control_plane.proto
package messages
@@ -453,65 +453,38 @@ func (x *ExpiryAnnounce) GetIds() []uint64 {
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, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 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,
})
const file_control_plane_proto_rawDesc = "" +
"\n" +
"\x13control_plane.proto\x12\bmessages\"\a\n" +
"\x05Empty\"<\n" +
"\tPingReply\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
"\x10NegotiateRequest\x12\x1f\n" +
"\vknown_hosts\x18\x01 \x03(\tR\n" +
"knownHosts\"&\n" +
"\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
"\rActorIdsReply\x12\x10\n" +
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\"9\n" +
"\x11OwnershipAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids\"6\n" +
"\x0eExpiryAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids2\x8d\x03\n" +
"\fControlPlane\x12,\n" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x12<\n" +
"\x10GetLocalActorIds\x12\x0f.messages.Empty\x1a\x17.messages.ActorIdsReply\x12J\n" +
"\x11AnnounceOwnership\x12\x1b.messages.OwnershipAnnounce\x1a\x18.messages.OwnerChangeAck\x12D\n" +
"\x0eAnnounceExpiry\x12\x18.messages.ExpiryAnnounce\x1a\x18.messages.OwnerChangeAck\x12<\n" +
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var (
file_control_plane_proto_rawDescOnce sync.Once

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v6.32.1
// source: control_plane.proto
package messages

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v5.29.3
// protoc-gen-go v1.36.10
// protoc v6.32.1
// source: messages.proto
package messages
@@ -9,6 +9,7 @@ package 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"
@@ -79,6 +80,7 @@ type AddItem struct {
SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"`
SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"`
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
SaleStatus string `protobuf:"bytes,24,opt,name=saleStatus,proto3" json:"saleStatus,omitempty"`
Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"`
StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"`
@@ -256,6 +258,13 @@ func (x *AddItem) GetCountry() string {
return ""
}
func (x *AddItem) GetSaleStatus() string {
if x != nil {
return x.SaleStatus
}
return ""
}
func (x *AddItem) GetOutlet() string {
if x != nil && x.Outlet != nil {
return *x.Outlet
@@ -917,86 +926,19 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
return false
}
type VoucherRule struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
Condition string `protobuf:"bytes,4,opt,name=condition,proto3" json:"condition,omitempty"`
Action string `protobuf:"bytes,5,opt,name=action,proto3" json:"action,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VoucherRule) Reset() {
*x = VoucherRule{}
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VoucherRule) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VoucherRule) ProtoMessage() {}
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
mi := &file_messages_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 VoucherRule.ProtoReflect.Descriptor instead.
func (*VoucherRule) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *VoucherRule) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *VoucherRule) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *VoucherRule) GetCondition() string {
if x != nil {
return x.Condition
}
return ""
}
func (x *VoucherRule) GetAction() string {
if x != nil {
return x.Action
}
return ""
}
type AddVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"`
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
VoucherRules []*VoucherRule `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
VoucherRules []string `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddVoucher) Reset() {
*x = AddVoucher{}
mi := &file_messages_proto_msgTypes[13]
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1008,7 +950,7 @@ func (x *AddVoucher) String() string {
func (*AddVoucher) ProtoMessage() {}
func (x *AddVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[13]
mi := &file_messages_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1021,7 +963,7 @@ func (x *AddVoucher) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddVoucher.ProtoReflect.Descriptor instead.
func (*AddVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{13}
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *AddVoucher) GetCode() string {
@@ -1038,13 +980,20 @@ func (x *AddVoucher) GetValue() int64 {
return 0
}
func (x *AddVoucher) GetVoucherRules() []*VoucherRule {
func (x *AddVoucher) GetVoucherRules() []string {
if x != nil {
return x.VoucherRules
}
return nil
}
func (x *AddVoucher) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
type RemoveVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -1054,7 +1003,7 @@ type RemoveVoucher struct {
func (x *RemoveVoucher) Reset() {
*x = RemoveVoucher{}
mi := &file_messages_proto_msgTypes[14]
mi := &file_messages_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1066,7 +1015,7 @@ func (x *RemoveVoucher) String() string {
func (*RemoveVoucher) ProtoMessage() {}
func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[14]
mi := &file_messages_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1079,7 +1028,7 @@ func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveVoucher.ProtoReflect.Descriptor instead.
func (*RemoveVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{14}
return file_messages_proto_rawDescGZIP(), []int{13}
}
func (x *RemoveVoucher) GetId() uint32 {
@@ -1089,153 +1038,262 @@ func (x *RemoveVoucher) GetId() uint32 {
return 0
}
type UpsertSubscriptionDetails struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id *string `protobuf:"bytes,1,opt,name=id,proto3,oneof" json:"id,omitempty"`
OfferingCode string `protobuf:"bytes,2,opt,name=offeringCode,proto3" json:"offeringCode,omitempty"`
SigningType string `protobuf:"bytes,3,opt,name=signingType,proto3" json:"signingType,omitempty"`
Data *anypb.Any `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpsertSubscriptionDetails) Reset() {
*x = UpsertSubscriptionDetails{}
mi := &file_messages_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpsertSubscriptionDetails) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpsertSubscriptionDetails) ProtoMessage() {}
func (x *UpsertSubscriptionDetails) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[14]
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 UpsertSubscriptionDetails.ProtoReflect.Descriptor instead.
func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{14}
}
func (x *UpsertSubscriptionDetails) GetId() string {
if x != nil && x.Id != nil {
return *x.Id
}
return ""
}
func (x *UpsertSubscriptionDetails) GetOfferingCode() string {
if x != nil {
return x.OfferingCode
}
return ""
}
func (x *UpsertSubscriptionDetails) GetSigningType() string {
if x != nil {
return x.SigningType
}
return ""
}
func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
if x != nil {
return x.Data
}
return nil
}
type PreConditionFailed struct {
state protoimpl.MessageState `protogen:"open.v1"`
Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
Input *anypb.Any `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreConditionFailed) Reset() {
*x = PreConditionFailed{}
mi := &file_messages_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreConditionFailed) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreConditionFailed) ProtoMessage() {}
func (x *PreConditionFailed) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[15]
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 PreConditionFailed.ProtoReflect.Descriptor instead.
func (*PreConditionFailed) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{15}
}
func (x *PreConditionFailed) GetOperation() string {
if x != nil {
return x.Operation
}
return ""
}
func (x *PreConditionFailed) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *PreConditionFailed) GetInput() *anypb.Any {
if x != nil {
return x.Input
}
return nil
}
var File_messages_proto protoreflect.FileDescriptor
var file_messages_proto_rawDesc = string([]byte{
0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c,
0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97,
0x05, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74,
0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65,
0x6d, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74,
0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05,
0x52, 0x03, 0x74, 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67,
0x6f, 0x72, 0x79, 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65,
0x67, 0x6f, 0x72, 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
0x79, 0x33, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
0x72, 0x79, 0x33, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34,
0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
0x34, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12,
0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12,
0x20, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70,
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a,
0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a,
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18,
0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64,
0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18,
0x17, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
0x64, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42,
0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f,
0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x22, 0x86, 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52,
0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70,
0x50, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69,
0x6e, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e,
0x74, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70,
0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12,
0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52,
0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x69, 0x63,
0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01,
0x0a, 0x0e, 0x53, 0x65, 0x74, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74,
0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01,
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03,
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05,
0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a,
0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x0b, 0x50, 0x69,
0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88,
0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01,
0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48,
0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69,
0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01,
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01,
0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01,
0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06,
0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x72, 0x79, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
0x52, 0x02, 0x69, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05,
0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x65, 0x72,
0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x22,
0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x75, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x70, 0x75, 0x73, 0x68, 0x12, 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69,
0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
0x22, 0x40, 0x0a, 0x0c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x22, 0x06, 0x0a, 0x04, 0x4e, 0x6f, 0x6f, 0x70, 0x22, 0x74, 0x0a, 0x12, 0x49, 0x6e,
0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50,
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x70,
0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73,
0x22, 0x79, 0x0a, 0x0b, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74,
0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x0a, 0x41,
0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x12, 0x39, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75,
0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65,
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x1f,
0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x42,
0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e,
0x6d, 0x65, 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,
})
const file_messages_proto_rawDesc = "" +
"\n" +
"\x0emessages.proto\x12\bmessages\x1a\x19google/protobuf/any.proto\"\x12\n" +
"\x10ClearCartRequest\"\xb7\x05\n" +
"\aAddItem\x12\x17\n" +
"\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
"\n" +
"disclaimer\x18\n" +
" \x01(\tR\n" +
"disclaimer\x12 \n" +
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
"\n" +
"sellerName\x18\x14 \x01(\tR\n" +
"sellerName\x12\x18\n" +
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" +
"\n" +
"saleStatus\x18\x18 \x01(\tR\n" +
"saleStatus\x12\x1b\n" +
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" +
"\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01B\t\n" +
"\a_outletB\n" +
"\n" +
"\b_storeIdB\v\n" +
"\t_parentId\"\x1c\n" +
"\n" +
"RemoveItem\x12\x0e\n" +
"\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" +
"\x0eChangeQuantity\x12\x0e\n" +
"\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
"\vSetDelivery\x12\x1a\n" +
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
"\x05items\x18\x02 \x03(\rR\x05items\x12<\n" +
"\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\n" +
"\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" +
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
"\aaddress\x18\x06 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\a \x01(\tH\x02R\x04city\x88\x01\x01B\x0e\n" +
"\f_pickupPointB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_city\"\xf9\x01\n" +
"\x0eSetPickupPoint\x12\x1e\n" +
"\n" +
"deliveryId\x18\x01 \x01(\rR\n" +
"deliveryId\x12\x0e\n" +
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x04 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x05 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x06 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\a \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\"\xd6\x01\n" +
"\vPickupPoint\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x03 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x04 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x05 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\x06 \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\" \n" +
"\x0eRemoveDelivery\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\"\xb9\x01\n" +
"\x13CreateCheckoutOrder\x12\x14\n" +
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
"\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x12\n" +
"\x04push\x18\x04 \x01(\tR\x04push\x12\x1e\n" +
"\n" +
"validation\x18\x05 \x01(\tR\n" +
"validation\x12\x18\n" +
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
"\fOrderCreated\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
"\x04Noop\"t\n" +
"\x12InitializeCheckout\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgress\"|\n" +
"\n" +
"AddVoucher\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" +
"\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" +
"\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" +
"\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" +
"\rRemoveVoucher\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" +
"\x19UpsertSubscriptionDetails\x12\x13\n" +
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" +
"\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" +
"\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" +
"\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" +
"\x03_id\"t\n" +
"\x12PreConditionFailed\x12\x1c\n" +
"\toperation\x18\x01 \x01(\tR\toperation\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\x12*\n" +
"\x05input\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\x05inputB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var (
file_messages_proto_rawDescOnce sync.Once
@@ -1249,32 +1307,35 @@ func file_messages_proto_rawDescGZIP() []byte {
return file_messages_proto_rawDescData
}
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_messages_proto_goTypes = []any{
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
(*AddItem)(nil), // 1: messages.AddItem
(*RemoveItem)(nil), // 2: messages.RemoveItem
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
(*SetDelivery)(nil), // 4: messages.SetDelivery
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
(*PickupPoint)(nil), // 6: messages.PickupPoint
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 9: messages.OrderCreated
(*Noop)(nil), // 10: messages.Noop
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
(*VoucherRule)(nil), // 12: messages.VoucherRule
(*AddVoucher)(nil), // 13: messages.AddVoucher
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
(*AddItem)(nil), // 1: messages.AddItem
(*RemoveItem)(nil), // 2: messages.RemoveItem
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
(*SetDelivery)(nil), // 4: messages.SetDelivery
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
(*PickupPoint)(nil), // 6: messages.PickupPoint
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 9: messages.OrderCreated
(*Noop)(nil), // 10: messages.Noop
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
(*AddVoucher)(nil), // 12: messages.AddVoucher
(*RemoveVoucher)(nil), // 13: messages.RemoveVoucher
(*UpsertSubscriptionDetails)(nil), // 14: messages.UpsertSubscriptionDetails
(*PreConditionFailed)(nil), // 15: messages.PreConditionFailed
(*anypb.Any)(nil), // 16: google.protobuf.Any
}
var file_messages_proto_depIdxs = []int32{
6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
12, // 1: messages.AddVoucher.voucherRules:type_name -> messages.VoucherRule
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
16, // 1: messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
16, // 2: messages.PreConditionFailed.input:type_name -> google.protobuf.Any
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_messages_proto_init() }
@@ -1286,13 +1347,14 @@ func file_messages_proto_init() {
file_messages_proto_msgTypes[4].OneofWrappers = []any{}
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
file_messages_proto_msgTypes[14].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
NumEnums: 0,
NumMessages: 15,
NumMessages: 16,
NumExtensions: 0,
NumServices: 0,
},

724
pkg/promotions/eval.go Normal file
View File

@@ -0,0 +1,724 @@
package promotions
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
var errInvalidTimeFormat = errors.New("invalid time format")
// Tracer allows callers to receive structured debug information during evaluation.
type Tracer interface {
Trace(event string, data map[string]any)
}
// NoopTracer is used when no tracer provided.
type NoopTracer struct{}
func (NoopTracer) Trace(string, map[string]any) {}
// PromotionItem is a lightweight abstraction derived from cart.CartItem
// for the purpose of promotion condition evaluation.
type PromotionItem struct {
SKU string
Quantity int
Category string
PriceIncVat int64
}
// PromotionEvalContext carries all dynamic data required to evaluate promotion
// conditions. It can be constructed from a cart.CartGrain plus optional
// customer/order metadata.
type PromotionEvalContext struct {
CartTotalIncVat int64
TotalItemQuantity int
Items []PromotionItem
CustomerSegment string
CustomerLifetimeValue float64
OrderCount int
Now time.Time
}
// ContextOption allows customization of fields when building a PromotionEvalContext.
type ContextOption func(*PromotionEvalContext)
// WithCustomerSegment sets the customer segment.
func WithCustomerSegment(seg string) ContextOption {
return func(c *PromotionEvalContext) { c.CustomerSegment = seg }
}
// WithCustomerLifetimeValue sets lifetime value metric.
func WithCustomerLifetimeValue(v float64) ContextOption {
return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v }
}
// WithOrderCount sets historical order count.
func WithOrderCount(n int) ContextOption {
return func(c *PromotionEvalContext) { c.OrderCount = n }
}
// WithNow overrides the timestamp used for date/time related conditions.
func WithNow(t time.Time) ContextOption {
return func(c *PromotionEvalContext) { c.Now = t }
}
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
ctx := &PromotionEvalContext{
Items: make([]PromotionItem, 0, len(g.Items)),
CartTotalIncVat: 0,
TotalItemQuantity: 0,
Now: time.Now(),
}
if g.TotalPrice != nil {
ctx.CartTotalIncVat = g.TotalPrice.IncVat
}
for _, it := range g.Items {
category := ""
if it.Meta != nil {
category = it.Meta.Category
}
ctx.Items = append(ctx.Items, PromotionItem{
SKU: it.Sku,
Quantity: it.Quantity,
Category: strings.ToLower(category),
PriceIncVat: it.Price.IncVat,
})
ctx.TotalItemQuantity += it.Quantity
}
for _, o := range opts {
o(ctx)
}
return ctx
}
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
type PromotionService struct {
tracer Tracer
}
// NewPromotionService constructs a PromotionService with an optional tracer.
func NewPromotionService(t Tracer) *PromotionService {
if t == nil {
t = NoopTracer{}
}
return &PromotionService{tracer: t}
}
// EvaluationResult holds the outcome of evaluating a single rule.
type EvaluationResult struct {
Rule PromotionRule
Applicable bool
FailedReason string
MatchedActions []Action
}
// EvaluateRule determines if a single PromotionRule applies to the provided context.
// Returns an EvaluationResult with applicability and actions (if applicable).
func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult {
s.tracer.Trace("rule_start", map[string]any{
"rule_id": rule.ID,
"priority": rule.Priority,
"status": rule.Status,
"startDate": rule.StartDate,
"endDate": rule.EndDate,
})
// Status gate
now := ctx.Now
switch rule.Status {
case StatusInactive, StatusExpired:
s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"}
case StatusScheduled:
// Allow scheduled only if current time >= startDate (and within endDate if present)
}
// Date window checks (if parseable)
if rule.StartDate != "" {
if tStart, err := parseDate(rule.StartDate); err == nil {
if now.Before(tStart) {
s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"}
}
}
}
if rule.EndDate != nil && *rule.EndDate != "" {
if tEnd, err := parseDate(*rule.EndDate); err == nil {
if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day
s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"}
}
}
}
// Usage limit
if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit {
s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"}
}
if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) {
s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"}
}
s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID})
return EvaluationResult{
Rule: rule,
Applicable: true,
MatchedActions: rule.Actions,
}
}
// EvaluateAll returns all applicable promotion actions given a list of rules and context.
// Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence).
func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) {
results := make([]EvaluationResult, 0, len(rules))
for _, r := range rules {
res := s.EvaluateRule(r, ctx)
results = append(results, res)
}
actions := make([]Action, 0)
for _, res := range orderedByPriority(results) {
if res.Applicable {
s.tracer.Trace("actions_add", map[string]any{
"rule_id": res.Rule.ID,
"count": len(res.MatchedActions),
"priority": res.Rule.Priority,
})
actions = append(actions, res.MatchedActions...)
}
}
s.tracer.Trace("evaluation_complete", map[string]any{
"rules_total": len(rules),
"actions": len(actions),
})
return results, actions
}
// orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable).
func orderedByPriority(in []EvaluationResult) []EvaluationResult {
out := make([]EvaluationResult, len(in))
copy(out, in)
for i := 1; i < len(out); i++ {
j := i
for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority {
out[j-1], out[j] = out[j], out[j-1]
j--
}
}
return out
}
// ----------------------------
// Condition evaluation (with tracing)
// ----------------------------
func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool {
for idx, c := range conds {
if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) {
return false
}
}
return true
}
func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
if grp, ok := c.(ConditionGroup); ok {
return evaluateGroupTrace(grp, ctx, t, ruleID, path)
}
bc, ok := c.(BaseCondition)
if !ok {
t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path})
return false
}
res := evaluateBaseCondition(bc, ctx)
t.Trace("cond_base", map[string]any{
"rule_id": ruleID,
"path": path,
"type": bc.Type,
"op": bc.Operator,
"value": bc.Value.String(),
"result": res,
})
return res
}
func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
op := normalizeLogicOperator(string(g.Operator))
if len(g.Conditions) == 0 {
t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path})
return true
}
if op == string(LogicAND) {
for i, child := range g.Conditions {
if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) {
t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path})
return false
}
}
t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path})
return true
}
for i, child := range g.Conditions {
if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) {
t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path})
return true
}
}
t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path})
return false
}
// Fallback non-traced evaluation (used internally by traced path)
func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool {
for _, c := range conds {
if !evaluateCondition(c, ctx) {
return false
}
}
return true
}
func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool {
if grp, ok := c.(ConditionGroup); ok {
return evaluateGroup(grp, ctx)
}
bc, ok := c.(BaseCondition)
if !ok {
return false
}
return evaluateBaseCondition(bc, ctx)
}
func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
op := normalizeLogicOperator(string(g.Operator))
if len(g.Conditions) == 0 {
return true
}
if op == string(LogicAND) {
for _, child := range g.Conditions {
if !evaluateCondition(child, ctx) {
return false
}
}
return true
}
for _, child := range g.Conditions {
if evaluateCondition(child, ctx) {
return true
}
}
return false
}
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
switch b.Type {
case CondCartTotal:
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
case CondItemQuantity:
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
case CondCustomerSegment:
return evalStringCompare(ctx.CustomerSegment, b)
case CondProductCategory:
return evalAnyItemMatch(func(it PromotionItem) bool {
return evalValueAgainstTarget(strings.ToLower(it.Category), b)
}, b, ctx)
case CondProductID:
return evalAnyItemMatch(func(it PromotionItem) bool {
return evalValueAgainstTarget(strings.ToLower(it.SKU), b)
}, b, ctx)
case CondCustomerLifetime:
return evalNumberCompare(ctx.CustomerLifetimeValue, b)
case CondOrderCount:
return evalNumberCompare(float64(ctx.OrderCount), b)
case CondDateRange:
return evalDateRange(ctx.Now, b)
case CondDayOfWeek:
return evalDayOfWeek(ctx.Now, b)
case CondTimeOfDay:
return evalTimeOfDay(ctx.Now, b)
default:
return false
}
}
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
if slices.ContainsFunc(ctx.Items, pred) {
return true
}
switch normalizeOperator(string(b.Operator)) {
case string(OpNotIn), string(OpNotContains):
return true
}
return false
}
// ----------------------------
// Primitive evaluators
// ----------------------------
func evalNumberCompare(target float64, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
cond, ok := b.Value.AsFloat64()
if !ok {
if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) {
found := sliceFloatContains(list, target)
if op == string(OpIn) {
return found
}
return !found
}
return false
}
switch op {
case string(OpEquals):
return target == cond
case string(OpNotEquals):
return target != cond
case string(OpGreaterThan):
return target > cond
case string(OpLessThan):
return target < cond
case string(OpGreaterOrEqual):
return target >= cond
case string(OpLessOrEqual):
return target <= cond
default:
return false
}
}
func evalStringCompare(target string, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
targetLower := strings.ToLower(target)
if s, ok := b.Value.AsString(); ok {
condLower := strings.ToLower(s)
switch op {
case string(OpEquals):
return targetLower == condLower
case string(OpNotEquals):
return targetLower != condLower
case string(OpContains):
return strings.Contains(targetLower, condLower)
case string(OpNotContains):
return !strings.Contains(targetLower, condLower)
}
}
if arr, ok := b.Value.AsStringSlice(); ok {
switch op {
case string(OpIn):
for _, v := range arr {
if targetLower == strings.ToLower(v) {
return true
}
}
return false
case string(OpNotIn):
for _, v := range arr {
if targetLower == strings.ToLower(v) {
return false
}
}
return true
case string(OpContains):
for _, v := range arr {
if strings.Contains(targetLower, strings.ToLower(v)) {
return true
}
}
return false
case string(OpNotContains):
for _, v := range arr {
if strings.Contains(targetLower, strings.ToLower(v)) {
return false
}
}
return true
}
}
return false
}
func evalValueAgainstTarget(target string, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
tLower := strings.ToLower(target)
if s, ok := b.Value.AsString(); ok {
vLower := strings.ToLower(s)
switch op {
case string(OpEquals):
return tLower == vLower
case string(OpNotEquals):
return tLower != vLower
case string(OpContains):
return strings.Contains(tLower, vLower)
case string(OpNotContains):
return !strings.Contains(tLower, vLower)
case string(OpIn):
return tLower == vLower
case string(OpNotIn):
return tLower != vLower
}
}
if list, ok := b.Value.AsStringSlice(); ok {
found := false
for _, v := range list {
if tLower == strings.ToLower(v) {
found = true
break
}
}
switch op {
case string(OpIn):
return found
case string(OpNotIn):
return !found
case string(OpContains):
for _, v := range list {
if strings.Contains(tLower, strings.ToLower(v)) {
return true
}
}
return false
case string(OpNotContains):
for _, v := range list {
if strings.Contains(tLower, strings.ToLower(v)) {
return false
}
}
return true
}
}
return false
}
func evalDateRange(now time.Time, b BaseCondition) bool {
var start, end time.Time
if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 {
t0, e0 := parseDate(ss[0])
t1, e1 := parseDate(ss[1])
if e0 != nil || e1 != nil {
return false
}
start, end = t0, t1
} else if s, ok := b.Value.AsString(); ok {
parts := strings.Split(s, "..")
if len(parts) != 2 {
return false
}
t0, e0 := parseDate(parts[0])
t1, e1 := parseDate(parts[1])
if e0 != nil || e1 != nil {
return false
}
start, end = t0, t1
} else {
return false
}
endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive))
}
func evalDayOfWeek(now time.Time, b BaseCondition) bool {
dow := int(now.Weekday())
allowed := make(map[int]struct{})
if arr, ok := b.Value.AsStringSlice(); ok {
for _, v := range arr {
if idx, ok := parseDayOfWeek(v); ok {
allowed[idx] = struct{}{}
}
}
} else if s, ok := b.Value.AsString(); ok {
for _, part := range strings.Split(s, "|") {
if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok {
allowed[idx] = struct{}{}
}
}
} else {
return false
}
if len(allowed) == 0 {
return false
}
op := normalizeOperator(string(b.Operator))
_, present := allowed[dow]
if op == string(OpIn) || op == string(OpEquals) || op == "" {
return present
}
if op == string(OpNotIn) || op == string(OpNotEquals) {
return !present
}
return present
}
func evalTimeOfDay(now time.Time, b BaseCondition) bool {
var startMin, endMin int
if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 {
s0, e0 := parseClock(arr[0])
s1, e1 := parseClock(arr[1])
if e0 != nil || e1 != nil {
return false
}
startMin, endMin = s0, s1
} else if s, ok := b.Value.AsString(); ok {
parts := strings.Split(s, "-")
if len(parts) != 2 {
return false
}
s0, e0 := parseClock(parts[0])
s1, e1 := parseClock(parts[1])
if e0 != nil || e1 != nil {
return false
}
startMin, endMin = s0, s1
} else {
return false
}
todayMin := now.Hour()*60 + now.Minute()
if startMin > endMin {
return todayMin >= startMin || todayMin <= endMin
}
return todayMin >= startMin && todayMin <= endMin
}
// ----------------------------
// Parsing helpers
// ----------------------------
func parseDate(s string) (time.Time, error) {
layouts := []string{
"2006-01-02",
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
}
for _, l := range layouts {
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
return t, nil
}
}
return time.Time{}, errInvalidTimeFormat
}
func parseDayOfWeek(s string) (int, bool) {
sl := strings.ToLower(strings.TrimSpace(s))
switch sl {
case "sun", "sunday", "0":
return 0, true
case "mon", "monday", "1":
return 1, true
case "tue", "tues", "tuesday", "2":
return 2, true
case "wed", "weds", "wednesday", "3":
return 3, true
case "thu", "thur", "thurs", "thursday", "4":
return 4, true
case "fri", "friday", "5":
return 5, true
case "sat", "saturday", "6":
return 6, true
default:
return 0, false
}
}
func parseClock(s string) (int, error) {
s = strings.TrimSpace(s)
parts := strings.Split(s, ":")
if len(parts) != 2 {
return 0, errInvalidTimeFormat
}
h, err := parsePositiveInt(parts[0])
if err != nil {
return 0, err
}
m, err := parsePositiveInt(parts[1])
if err != nil {
return 0, err
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, errInvalidTimeFormat
}
return h*60 + m, nil
}
func parsePositiveInt(s string) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errInvalidTimeFormat
}
n = n*10 + int(r-'0')
}
return n, nil
}
func normalizeOperator(op string) string {
o := strings.ToLower(strings.TrimSpace(op))
switch o {
case "=", "equals", "eq":
return string(OpEquals)
case "!=", "not_equals", "neq":
return string(OpNotEquals)
case ">", "greater_than", "gt":
return string(OpGreaterThan)
case "<", "less_than", "lt":
return string(OpLessThan)
case ">=", "greater_or_equal", "ge", "gte":
return string(OpGreaterOrEqual)
case "<=", "less_or_equal", "le", "lte":
return string(OpLessOrEqual)
case "contains":
return string(OpContains)
case "not_contains":
return string(OpNotContains)
case "in":
return string(OpIn)
case "not_in":
return string(OpNotIn)
default:
return o
}
}
func normalizeLogicOperator(op string) string {
o := strings.ToLower(strings.TrimSpace(op))
switch o {
case "&&", "and":
return string(LogicAND)
case "||", "or":
return string(LogicOR)
default:
return o
}
}
func sliceFloatContains(list []float64, v float64) bool {
return slices.Contains(list, v)
}
// ----------------------------
// Potential extension hooks
// ----------------------------
//
// Future ideas:
// - Conflict resolution strategies (e.g., best discount wins, stackable tags)
// - Action transformation (e.g., applying tiered logic or bundles carefully)
// - Recording evaluation traces for debugging / analytics.
// - Tracing instrumentation for condition evaluation.
//
// These can be integrated by adding strategy interfaces or injecting evaluators
// into PromotionService.
//
// ----------------------------
// End of file
// ----------------------------

448
pkg/promotions/eval_test.go Normal file
View File

@@ -0,0 +1,448 @@
package promotions
import (
"encoding/json"
"slices"
"testing"
"time"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
// --- Helpers ---------------------------------------------------------------
func cvNum(n float64) ConditionValue {
b, _ := json.Marshal(n)
return ConditionValue{Raw: b}
}
func cvString(s string) ConditionValue {
b, _ := json.Marshal(s)
return ConditionValue{Raw: b}
}
func cvStrings(ss []string) ConditionValue {
b, _ := json.Marshal(ss)
return ConditionValue{Raw: b}
}
// testTracer captures trace events for assertions.
type testTracer struct {
events []traceEvent
}
type traceEvent struct {
event string
data map[string]any
}
func (t *testTracer) Trace(event string, data map[string]any) {
t.events = append(t.events, traceEvent{event: event, data: data})
}
func (t *testTracer) HasEvent(name string) bool {
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
}
func (t *testTracer) Count(name string) int {
c := 0
for _, e := range t.events {
if e.event == name {
c++
}
}
return c
}
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
func makeCart(totalOverride int64, items []struct {
sku string
category string
qty int
priceInc int64
}) *cart.CartGrain {
g := cart.NewCartGrain(1, time.Now())
for _, it := range items {
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
g.Items = append(g.Items, &cart.CartItem{
Id: uint32(len(g.Items) + 1),
Sku: it.sku,
Price: *p,
TotalPrice: cart.Price{
IncVat: p.IncVat * int64(it.qty),
VatRates: p.VatRates,
},
Quantity: it.qty,
Meta: &cart.ItemMeta{
Category: it.category,
},
})
}
// Recalculate totals
g.UpdateTotals()
if totalOverride >= 0 {
g.TotalPrice.IncVat = totalOverride
}
return g
}
// --- Tests -----------------------------------------------------------------
func TestEvaluateRuleBasicAND(t *testing.T) {
g := makeCart(12000, []struct {
sku string
category string
qty int
priceInc int64
}{
{"SKU-1", "summer", 2, 3000},
{"SKU-2", "winter", 1, 6000},
})
ctx := NewContextFromCart(g,
WithCustomerSegment("vip"),
WithOrderCount(10),
WithCustomerLifetimeValue(1234.56),
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
)
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
conds := Conditions{
ConditionGroup{
ID: "grp",
Type: "group",
Operator: LogicAND,
Conditions: Conditions{
BaseCondition{
ID: "c_cart_total",
Type: CondCartTotal,
Operator: OpGreaterOrEqual,
Value: cvNum(10000),
},
BaseCondition{
ID: "c_item_qty",
Type: CondItemQuantity,
Operator: OpGreaterOrEqual,
Value: cvNum(3),
},
BaseCondition{
ID: "c_segment",
Type: CondCustomerSegment,
Operator: OpEquals,
Value: cvString("vip"),
},
},
},
}
rule := PromotionRule{
ID: "rule-AND",
Name: "VIP Summer",
Description: "Test rule",
Status: StatusActive,
Priority: 1,
StartDate: "2024-06-01",
EndDate: ptr("2024-06-30"),
Conditions: conds,
Actions: []Action{
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
},
UsageLimit: ptrInt(100),
UsageCount: 5,
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
CreatedBy: "tester",
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
}
if len(res.MatchedActions) != 1 {
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
}
if !tracer.HasEvent("rule_applicable") {
t.Errorf("expected tracing event rule_applicable")
}
}
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
rule := PromotionRule{
ID: "limit",
Name: "Limit",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
UsageLimit: ptrInt(5),
UsageCount: 5,
}
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if res.Applicable {
t.Fatalf("expected rule NOT applicable due to usage limit")
}
if res.FailedReason != "usage_limit_exhausted" {
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
}
if !tracer.HasEvent("rule_skip_usage_limit") {
t.Errorf("tracer missing rule_skip_usage_limit event")
}
}
func TestEvaluateRuleDateWindow(t *testing.T) {
// Start in future
rule := PromotionRule{
ID: "date",
Name: "DateWindow",
Status: StatusActive,
Priority: 1,
StartDate: "2025-01-01",
EndDate: ptr("2025-01-31"),
Conditions: Conditions{},
}
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if res.Applicable {
t.Fatalf("expected rule NOT applicable (before start)")
}
if res.FailedReason != "before_start" {
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
}
if !tracer.HasEvent("rule_skip_before_start") {
t.Errorf("missing rule_skip_before_start trace event")
}
}
func TestEvaluateProductCategoryIN(t *testing.T) {
g := makeCart(-1, []struct {
sku string
category string
qty int
priceInc int64
}{
{"A", "shoes", 1, 5000},
{"B", "bags", 1, 7000},
})
ctx := NewContextFromCart(g)
conds := Conditions{
BaseCondition{
ID: "c_category",
Type: CondProductCategory,
Operator: OpIn,
Value: cvStrings([]string{"shoes", "hats"}),
},
}
rule := PromotionRule{
ID: "cat-in",
Status: StatusActive,
Priority: 10,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: conds,
Actions: []Action{
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected category IN rule to apply")
}
if !tracer.HasEvent("rule_applicable") {
t.Errorf("tracing missing rule_applicable")
}
}
func TestEvaluateGroupOR(t *testing.T) {
g := makeCart(3000, []struct {
sku string
category string
qty int
priceInc int64
}{
{"ONE", "x", 1, 3000},
})
ctx := NewContextFromCart(g)
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
group := ConditionGroup{
ID: "grp-or",
Type: "group",
Operator: LogicOR,
Conditions: Conditions{
BaseCondition{
ID: "c_total",
Type: CondCartTotal,
Operator: OpGreaterOrEqual,
Value: cvNum(10000),
},
BaseCondition{
ID: "c_qty",
Type: CondItemQuantity,
Operator: OpGreaterOrEqual,
Value: cvNum(1),
},
},
}
rule := PromotionRule{
ID: "or-rule",
Status: StatusActive,
Priority: 5,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{group},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected OR rule to apply (second condition true)")
}
// Ensure group pass event
if !tracer.HasEvent("cond_group_or_pass") {
t.Errorf("expected cond_group_or_pass trace event")
}
}
func TestEvaluateAllPriorityOrdering(t *testing.T) {
ctx := &PromotionEvalContext{
CartTotalIncVat: 20000,
TotalItemQuantity: 2,
Items: []PromotionItem{
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
},
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
}
// Rule A: priority 5
ruleA := PromotionRule{
ID: "A",
Status: StatusActive,
Priority: 5,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
Actions: []Action{
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
},
}
// Rule B: priority 1
ruleB := PromotionRule{
ID: "B",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
Actions: []Action{
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if len(actions) != 2 {
t.Fatalf("expected 2 actions, got %d", len(actions))
}
// Actions should follow priority order: ruleB (1) then ruleA (5)
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
t.Fatalf("actions order invalid: %+v", actions)
}
if tracer.Count("actions_add") != 2 {
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
}
if !tracer.HasEvent("evaluation_complete") {
t.Errorf("missing evaluation_complete trace")
}
}
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
// Wednesday 14:30 UTC
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
ctx := &PromotionEvalContext{
Now: now,
}
condDay := BaseCondition{
ID: "dow",
Type: CondDayOfWeek,
Operator: OpIn,
Value: cvStrings([]string{"wed", "fri"}),
}
condTime := BaseCondition{
ID: "tod",
Type: CondTimeOfDay,
Operator: OpEquals, // operator is ignored for time-of-day internally
Value: cvString("13:00-15:00"),
}
rule := PromotionRule{
ID: "day-time",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{condDay, condTime},
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
}
}
func TestDateRangeCondition(t *testing.T) {
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
ctx := &PromotionEvalContext{Now: now}
condRange := BaseCondition{
ID: "date_range",
Type: CondDateRange,
Operator: OpEquals, // not used
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
}
rule := PromotionRule{
ID: "range",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{condRange},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected date range rule to apply for 2024-03-15")
}
}
// --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s }
func ptrInt(i int) *int { return &i }

443
pkg/promotions/type_test.go Normal file
View File

@@ -0,0 +1,443 @@
package promotions
import (
"encoding/json"
"testing"
)
// sampleJSON mirrors the user's full example data (all three rules)
var sampleJSON = []byte(`[
{
"id": "1",
"name": "Summer Sale 2024",
"description": "20% off on all summer items",
"status": "active",
"priority": 1,
"startDate": "2024-06-01",
"endDate": "2024-08-31",
"conditions": [
{
"id": "group1",
"type": "group",
"operator": "AND",
"conditions": [
{
"id": "c1",
"type": "product_category",
"operator": "in",
"value": ["summer", "beachwear"],
"label": "Product category is Summer or Beachwear"
},
{
"id": "c2",
"type": "cart_total",
"operator": "greater_or_equal",
"value": 50,
"label": "Cart total is at least $50"
}
]
}
],
"actions": [
{
"id": "a1",
"type": "percentage_discount",
"value": 20,
"label": "20% discount"
}
],
"usageLimit": 1000,
"usageCount": 342,
"createdAt": "2024-05-15T10:00:00Z",
"updatedAt": "2024-05-20T14:30:00Z",
"createdBy": "admin@example.com",
"tags": ["seasonal", "summer"]
},
{
"id": "2",
"name": "VIP Customer Exclusive",
"description": "Free shipping for VIP customers",
"status": "active",
"priority": 2,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "c3",
"type": "customer_segment",
"operator": "equals",
"value": "vip",
"label": "Customer segment is VIP"
}
],
"actions": [
{
"id": "a2",
"type": "free_shipping",
"value": 0,
"label": "Free shipping"
}
],
"usageCount": 1523,
"createdAt": "2023-12-15T09:00:00Z",
"updatedAt": "2024-01-05T11:20:00Z",
"createdBy": "marketing@example.com",
"tags": ["vip", "loyalty"]
},
{
"id": "3",
"name": "Buy 2 Get 1 Free",
"description": "Buy 2 items, get the cheapest one free",
"status": "scheduled",
"priority": 3,
"startDate": "2024-12-01",
"endDate": "2024-12-25",
"conditions": [
{
"id": "c4",
"type": "item_quantity",
"operator": "greater_or_equal",
"value": 3,
"label": "Cart has at least 3 items"
}
],
"actions": [
{
"id": "a3",
"type": "buy_x_get_y",
"value": 0,
"config": { "buy": 2, "get": 1, "discount": 100 },
"label": "Buy 2 Get 1 Free"
}
],
"usageCount": 0,
"createdAt": "2024-11-01T08:00:00Z",
"updatedAt": "2024-11-01T08:00:00Z",
"createdBy": "admin@example.com",
"tags": ["holiday", "christmas"]
}
]`)
func TestDecodePromotionRulesBasic(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
// Rule 1 checks
r1 := rules[0]
if r1.ID != "1" {
t.Errorf("rule[0].ID = %s, want 1", r1.ID)
}
if r1.Status != StatusActive {
t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive)
}
if r1.EndDate == nil || *r1.EndDate != "2024-08-31" {
t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate)
}
if r1.UsageLimit == nil || *r1.UsageLimit != 1000 {
t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit)
}
// Rule 2 checks
r2 := rules[1]
if r2.ID != "2" {
t.Errorf("rule[1].ID = %s, want 2", r2.ID)
}
if r2.EndDate != nil {
t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate)
}
if r2.UsageLimit != nil {
t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit)
}
}
func TestConditionDecoding(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
r1 := rules[0]
if len(r1.Conditions) != 1 {
t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions))
}
grp, ok := r1.Conditions[0].(ConditionGroup)
if !ok {
t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0])
}
if grp.Operator != LogicAND {
t.Errorf("group operator = %s, want AND", grp.Operator)
}
if len(grp.Conditions) != 2 {
t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions))
}
// First child: product_category condition with slice value
c0, ok := grp.Conditions[0].(BaseCondition)
if !ok {
t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0])
}
if c0.Type != CondProductCategory {
t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory)
}
if c0.Operator != OpIn {
t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn)
}
if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" {
t.Errorf("expected string slice value [summer,...], got %v", arr)
}
// Second child: cart_total numeric
c1, ok := grp.Conditions[1].(BaseCondition)
if !ok {
t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1])
}
if c1.Type != CondCartTotal {
t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal)
}
if val, ok := c1.Value.AsFloat64(); !ok || val != 50 {
t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok)
}
}
func TestConditionValueHelpers(t *testing.T) {
tests := []struct {
name string
jsonVal string
wantStr string
wantNum *float64
wantSS []string
wantFS []float64
}{
{
name: "string value",
jsonVal: `"vip"`,
wantStr: "vip",
},
{
name: "number value",
jsonVal: `42`,
wantNum: floatPtr(42),
},
{
name: "string slice",
jsonVal: `["a","b"]`,
wantSS: []string{"a", "b"},
},
{
name: "number slice int",
jsonVal: `[1,2,3]`,
wantFS: []float64{1, 2, 3},
},
{
name: "number slice float",
jsonVal: `[1.5,2.25]`,
wantFS: []float64{1.5, 2.25},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var cv ConditionValue
if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil {
t.Fatalf("unmarshal value failed: %v", err)
}
if tc.wantStr != "" {
if got, ok := cv.AsString(); !ok || got != tc.wantStr {
t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr)
}
}
if tc.wantNum != nil {
if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum {
t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum)
}
}
if tc.wantSS != nil {
if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] {
t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS)
}
}
if tc.wantFS != nil {
if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) {
t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS)
} else {
for i := range got {
if got[i] != tc.wantFS[i] {
t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i])
}
}
}
}
})
}
}
func TestWalkConditionsTraversal(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
visited := 0
WalkConditions(rules[0].Conditions, func(c Condition) bool {
visited++
return true
})
// group + 2 children
if visited != 3 {
t.Errorf("expected 3 visited conditions, got %d", visited)
}
}
func TestActionBundleConfigParsing(t *testing.T) {
jsonData := []byte(`[
{
"id": "bundle-1",
"name": "Bundle Deal",
"description": "Fixed price bundle",
"status": "active",
"priority": 1,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [],
"actions": [
{
"id": "act-bundle",
"type": "bundle_discount",
"value": 0,
"bundleConfig": {
"containers": [
{
"id": "cont1",
"name": "Shoes",
"quantity": 2,
"selectionType": "any",
"qualifyingRules": {
"type": "category",
"value": "shoes"
}
},
{
"id": "cont2",
"name": "Socks",
"quantity": 3,
"selectionType": "specific",
"qualifyingRules": {
"type": "product_ids",
"value": ["sock-1", "sock-2"]
},
"allowedProducts": ["sock-1","sock-2"]
}
],
"pricing": {
"type": "fixed_price",
"value": 49.99
},
"requireAllContainers": true
},
"config": { "note": "Bundle applies to footwear + socks" }
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "bundle@example.com",
"tags": ["bundle","footwear"]
}
]`)
rules, err := DecodePromotionRules(jsonData)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
if len(rules[0].Actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(rules[0].Actions))
}
act := rules[0].Actions[0]
if act.Type != ActionBundleDiscount {
t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount)
}
if act.BundleConfig == nil {
t.Fatalf("bundleConfig nil")
}
if act.BundleConfig.Pricing.Type != "fixed_price" {
t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type)
}
if act.BundleConfig.Pricing.Value != 49.99 {
t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value)
}
if !act.BundleConfig.RequireAllContainers {
t.Errorf("RequireAllContainers expected true")
}
if len(act.BundleConfig.Containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers))
}
if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" {
t.Errorf("config.note mismatch: %v", act.Config)
}
}
func TestConditionValueInvalidTypes(t *testing.T) {
cases := []struct {
name string
raw string
}{
{"object", `{}`},
{"booleanTrue", `true`},
{"booleanFalse", `false`},
{"null", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var cv ConditionValue
if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if s, ok := cv.AsString(); ok {
t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name)
}
if n, ok := cv.AsFloat64(); ok {
t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name)
}
if ss, ok := cv.AsStringSlice(); ok {
t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name)
}
if fs, ok := cv.AsFloat64Slice(); ok {
t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name)
}
})
}
}
func TestPromotionRuleRoundTrip(t *testing.T) {
orig, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("initial decode failed: %v", err)
}
data, err := json.Marshal(orig)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
round, err := DecodePromotionRules(data)
if err != nil {
t.Fatalf("round-trip decode failed: %v", err)
}
if len(orig) != len(round) {
t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round))
}
// spot-check first rule
if orig[0].Name != round[0].Name {
t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name)
}
if len(orig[0].Conditions) != len(round[0].Conditions) {
t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions))
}
}
func floatPtr(f float64) *float64 { return &f }

413
pkg/promotions/types.go Normal file
View File

@@ -0,0 +1,413 @@
package promotions
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// -----------------------------
// Enum-like string types
// -----------------------------
type ConditionOperator string
const (
OpEquals ConditionOperator = "="
OpNotEquals ConditionOperator = "!="
OpGreaterThan ConditionOperator = ">"
OpLessThan ConditionOperator = "<"
OpGreaterOrEqual ConditionOperator = ">="
OpLessOrEqual ConditionOperator = "<="
OpContains ConditionOperator = "contains"
OpNotContains ConditionOperator = "not_contains"
OpIn ConditionOperator = "in"
OpNotIn ConditionOperator = "not_in"
)
type ConditionType string
const (
CondCartTotal ConditionType = "cart_total"
CondItemQuantity ConditionType = "item_quantity"
CondCustomerSegment ConditionType = "customer_segment"
CondProductCategory ConditionType = "product_category"
CondProductID ConditionType = "product_id"
CondCustomerLifetime ConditionType = "customer_lifetime_value"
CondOrderCount ConditionType = "order_count"
CondDateRange ConditionType = "date_range"
CondDayOfWeek ConditionType = "day_of_week"
CondTimeOfDay ConditionType = "time_of_day"
CondGroup ConditionType = "group" // synthetic value for groups
)
type ActionType string
const (
ActionPercentageDiscount ActionType = "percentage_discount"
ActionFixedDiscount ActionType = "fixed_discount"
ActionFreeShipping ActionType = "free_shipping"
ActionBuyXGetY ActionType = "buy_x_get_y"
ActionTieredDiscount ActionType = "tiered_discount"
ActionBundleDiscount ActionType = "bundle_discount"
)
type LogicOperator string
const (
LogicAND LogicOperator = "&&"
LogicOR LogicOperator = "||"
)
type PromotionStatus string
const (
StatusActive PromotionStatus = "active"
StatusInactive PromotionStatus = "inactive"
StatusScheduled PromotionStatus = "scheduled"
StatusExpired PromotionStatus = "expired"
)
// -----------------------------
// Condition Value (union)
// -----------------------------
//
// Represents: string | number | []string | []number
// We store raw JSON and lazily interpret.
type ConditionValue struct {
Raw json.RawMessage
}
func (cv *ConditionValue) UnmarshalJSON(b []byte) error {
if len(b) == 0 {
return errors.New("empty ConditionValue")
}
// Just store raw; interpretation happens via helpers.
cv.Raw = append(cv.Raw[0:0], b...)
return nil
}
// Helpers to interpret value:
func (cv ConditionValue) AsString() (string, bool) {
// Treat explicit JSON null as invalid
if string(cv.Raw) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(cv.Raw, &s); err == nil {
return s, true
}
return "", false
}
func (cv ConditionValue) AsFloat64() (float64, bool) {
if string(cv.Raw) == "null" {
return 0, false
}
var f float64
if err := json.Unmarshal(cv.Raw, &f); err == nil {
return f, true
}
// Attempt integer decode into float64
var i int64
if err := json.Unmarshal(cv.Raw, &i); err == nil {
return float64(i), true
}
return 0, false
}
func (cv ConditionValue) AsStringSlice() ([]string, bool) {
if string(cv.Raw) == "null" {
return nil, false
}
var arr []string
if err := json.Unmarshal(cv.Raw, &arr); err == nil {
return arr, true
}
return nil, false
}
func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) {
if string(cv.Raw) == "null" {
return nil, false
}
var arrNum []float64
if err := json.Unmarshal(cv.Raw, &arrNum); err == nil {
return arrNum, true
}
// Try []int -> []float64
var arrInt []int64
if err := json.Unmarshal(cv.Raw, &arrInt); err == nil {
out := make([]float64, len(arrInt))
for i, v := range arrInt {
out[i] = float64(v)
}
return out, true
}
return nil, false
}
func (cv ConditionValue) String() string {
if s, ok := cv.AsString(); ok {
return s
}
if f, ok := cv.AsFloat64(); ok {
return strconv.FormatFloat(f, 'f', -1, 64)
}
if ss, ok := cv.AsStringSlice(); ok {
return fmt.Sprintf("%v", ss)
}
if fs, ok := cv.AsFloat64Slice(); ok {
return fmt.Sprintf("%v", fs)
}
return string(cv.Raw)
}
// -----------------------------
// BaseCondition
// -----------------------------
type BaseCondition struct {
ID string `json:"id"`
Type ConditionType `json:"type"`
Operator ConditionOperator `json:"operator"`
Value ConditionValue `json:"value"`
Label *string `json:"label,omitempty"`
}
func (b BaseCondition) IsGroup() bool { return false }
// -----------------------------
// ConditionGroup
// -----------------------------
type ConditionGroup struct {
ID string `json:"id"`
Type string `json:"type"` // always "group"
Operator LogicOperator `json:"operator"`
Conditions Conditions `json:"conditions"`
}
// Custom unmarshaller ensures nested polymorphic conditions are decoded
// using the Conditions type (which applies the raw element discriminator).
func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
type alias struct {
ID string `json:"id"`
Type string `json:"type"`
Operator LogicOperator `json:"operator"`
Conditions json.RawMessage `json:"conditions"`
}
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
// Basic validation
if a.Type != "group" {
return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type)
}
var conds Conditions
if len(a.Conditions) > 0 {
if err := json.Unmarshal(a.Conditions, &conds); err != nil {
return err
}
}
g.ID = a.ID
g.Type = a.Type
switch strings.ToLower(string(a.Operator)) {
case "and":
g.Operator = LogicAND
case "or":
g.Operator = LogicOR
default:
g.Operator = a.Operator
}
g.Conditions = conds
return nil
}
func (g ConditionGroup) IsGroup() bool { return true }
// -----------------------------
// Condition interface + slice
// -----------------------------
type Condition interface {
IsGroup() bool
}
// Internal wrapper to help decode each element.
type rawCond struct {
Type string `json:"type"`
}
// Custom unmarshaler for a Condition slice
type Conditions []Condition
func (cs *Conditions) UnmarshalJSON(b []byte) error {
var rawList []json.RawMessage
if err := json.Unmarshal(b, &rawList); err != nil {
return err
}
out := make([]Condition, 0, len(rawList))
for _, elem := range rawList {
var hdr rawCond
if err := json.Unmarshal(elem, &hdr); err != nil {
return err
}
if hdr.Type == "group" {
var grp ConditionGroup
if err := json.Unmarshal(elem, &grp); err != nil {
return err
}
// Recursively decode grp.Conditions (already handled because field type is []Condition)
out = append(out, grp)
} else {
var bc BaseCondition
if err := json.Unmarshal(elem, &bc); err != nil {
return err
}
out = append(out, bc)
}
}
*cs = out
return nil
}
// -----------------------------
// Bundle Structures
// -----------------------------
type BundleQualifyingRules struct {
Type string `json:"type"` // "category" | "product_ids" | "tag" | "all"
Value interface{} `json:"value"` // string or []string
}
type BundleContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Quantity int `json:"quantity"`
SelectionType string `json:"selectionType"` // "any" | "specific"
QualifyingRules BundleQualifyingRules `json:"qualifyingRules"`
AllowedProducts []string `json:"allowedProducts,omitempty"`
}
type BundlePricing struct {
Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount"
Value float64 `json:"value"`
}
type BundleConfig struct {
Containers []BundleContainer `json:"containers"`
Pricing BundlePricing `json:"pricing"`
RequireAllContainers bool `json:"requireAllContainers"`
}
// -----------------------------
// Action
// -----------------------------
type Action struct {
ID string `json:"id"`
Type ActionType `json:"type"`
Value interface{} `json:"value"` // number or string
Config map[string]interface{} `json:"config,omitempty"`
BundleConfig *BundleConfig `json:"bundleConfig,omitempty"`
Label *string `json:"label,omitempty"`
}
// -----------------------------
// Promotion Rule
// -----------------------------
type PromotionRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status PromotionStatus `json:"status"`
Priority int `json:"priority"`
StartDate string `json:"startDate"`
EndDate *string `json:"endDate"` // null -> nil
Conditions Conditions `json:"conditions"`
Actions []Action `json:"actions"`
UsageLimit *int `json:"usageLimit,omitempty"`
UsageCount int `json:"usageCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CreatedBy string `json:"createdBy"`
Tags []string `json:"tags"`
}
// -----------------------------
// Promotion Stats
// -----------------------------
type PromotionStats struct {
TotalPromotions int `json:"totalPromotions"`
ActivePromotions int `json:"activePromotions"`
TotalRevenue float64 `json:"totalRevenue"`
TotalOrders int `json:"totalOrders"`
AverageDiscount float64 `json:"averageDiscount"`
}
// -----------------------------
// Utility: Decode array of rules
// -----------------------------
func DecodePromotionRules(data []byte) ([]PromotionRule, error) {
var rules []PromotionRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, err
}
return rules, nil
}
// Example helper to inspect conditions programmatically.
func WalkConditions(conds []Condition, fn func(c Condition) bool) {
for _, c := range conds {
if !fn(c) {
return
}
if grp, ok := c.(ConditionGroup); ok {
WalkConditions(grp.Conditions, fn)
}
}
}
type PromotionState struct {
Promotions []PromotionRule `json:"promotions"`
}
type StateFile struct {
State PromotionState `json:"state"`
Version int `json:"version"`
}
func (sf *StateFile) GetPromotion(id string) (*PromotionRule, bool) {
for _, v := range sf.State.Promotions {
if v.ID == id {
return &v, true
}
}
return nil, false
}
func LoadStateFile(fileName string) (*StateFile, error) {
f, err := os.Open(fileName)
if err != nil {
return nil, err
}
defer f.Close()
dec := json.NewDecoder(f)
sf := &StateFile{}
err = dec.Decode(sf)
if err != nil {
return nil, err
}
return sf, nil
}

View File

@@ -10,6 +10,9 @@ import (
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"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"
)
@@ -26,6 +29,14 @@ type RemoteHost struct {
missedPings int
}
const name = "proxy"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
)
func NewRemoteHost(host string) (*RemoteHost, error) {
target := fmt.Sprintf("%s:1337", host)
@@ -49,7 +60,7 @@ func NewRemoteHost(host string) (*RemoteHost, error) {
return &RemoteHost{
host: host,
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
httpBase: fmt.Sprintf("http://%s:8080", host),
conn: conn,
transport: transport,
client: client,
@@ -146,8 +157,22 @@ func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
log.Printf("proxy target: %s, method: %s", target, r.Method)
ctx, span := tracer.Start(r.Context(), "remote_proxy")
defer span.End()
span.SetAttributes(
attribute.String("component", "proxy"),
attribute.String("cartid", fmt.Sprintf("%d", id)),
attribute.String("host", h.host),
attribute.String("method", r.Method),
attribute.String("target", target),
)
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
req, err := http.NewRequestWithContext(ctx, r.Method, target, r.Body)
if err != nil {
span.RecordError(err)
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
@@ -161,25 +186,27 @@ func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (b
}
res, err := h.client.Do(req)
if err != nil {
span.RecordError(err)
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
span.SetAttributes(attribute.Int("status_code", res.StatusCode))
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
span.RecordError(copyErr)
return true, copyErr
}
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
return true, nil
}
func (r *RemoteHost) IsHealthy() bool {

View File

@@ -3,6 +3,7 @@ package voucher
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"unicode"
@@ -137,12 +138,7 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool {
// anyItem returns true if predicate matches any item.
func anyItem(items []Item, pred func(Item) bool) bool {
for _, it := range items {
if pred(it) {
return true
}
}
return false
return slices.ContainsFunc(items, pred)
}
// ParseRules parses a rule expression into a RuleSet.

View File

@@ -28,6 +28,32 @@ func TestParseRules_SimpleSku(t *testing.T) {
}
}
func TestRuleCartTotal(t *testing.T) {
rs, err := ParseRules("min_total>=500000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rs.Conditions) != 1 {
t.Fatalf("expected 1 condition got %d", len(rs.Conditions))
}
c := rs.Conditions[0]
if c.Kind != RuleMinTotal {
t.Fatalf("expected kind cart total got %s", c.Kind)
}
ctx := EvalContext{
Items: []Item{
Item{
Sku: "123",
},
},
CartTotalInc: 400000,
}
applied := rs.Applies(ctx)
if applied {
t.Fatalf("expected")
}
}
func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) {
rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500")
if err != nil {

View File

@@ -1,7 +1,10 @@
package voucher
import (
"encoding/json"
"errors"
"fmt"
"os"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
@@ -12,16 +15,14 @@ type Rule struct {
}
type Voucher struct {
Code string `json:"code"`
Value int64 `json:"discount"`
TaxValue int64 `json:"taxValue"`
TaxRate int `json:"taxRate"`
rules []Rule `json:"rules"`
Code string `json:"code"`
Value int64 `json:"value"`
Rules string `json:"rules"`
Description string `json:"description,omitempty"`
}
type Service struct {
// Add fields here
}
var ErrInvalidCode = errors.New("invalid vouchercode")
@@ -30,10 +31,54 @@ func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
if code == "" {
return nil, ErrInvalidCode
}
value := int64(250_00)
sf, err := LoadStateFile("data/vouchers.json")
if err != nil {
return nil, err
}
v, ok := sf.GetVoucher(code)
if !ok {
return nil, fmt.Errorf("no voucher found for code: %s", code)
}
return &messages.AddVoucher{
Code: code,
Value: value,
VoucherRules: make([]*messages.VoucherRule, 0),
Code: code,
Value: v.Value,
Description: v.Description,
VoucherRules: []string{
v.Rules,
},
}, nil
}
type State struct {
Vouchers []Voucher `json:"vouchers"`
}
type StateFile struct {
State State `json:"state"`
Version int `json:"version"`
}
func (sf *StateFile) GetVoucher(code string) (*Voucher, bool) {
for _, v := range sf.State.Vouchers {
if v.Code == code {
return &v, true
}
}
return nil, false
}
func LoadStateFile(fileName string) (*StateFile, error) {
f, err := os.Open(fileName)
if err != nil {
return nil, err
}
defer f.Close()
dec := json.NewDecoder(f)
sf := &StateFile{}
err = dec.Decode(sf)
if err != nil {
return nil, err
}
return sf, nil
}

View File

@@ -2,115 +2,117 @@ syntax = "proto3";
package messages;
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
message ClearCartRequest {
import "google/protobuf/any.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;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
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;
}
message RemoveItem {
uint32 Id = 1;
}
message RemoveItem { uint32 Id = 1; }
message ChangeQuantity {
uint32 Id = 1;
int32 quantity = 2;
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;
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;
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;
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 RemoveDelivery { uint32 id = 1; }
message CreateCheckoutOrder {
string terms = 1;
string checkout = 2;
string confirmation = 3;
string push = 4;
string validation = 5;
string country = 6;
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;
string orderId = 1;
string status = 2;
}
message Noop {
// Intentionally empty - used for ownership acquisition or health pings
// Intentionally empty - used for ownership acquisition or health pings
}
message InitializeCheckout {
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}
message VoucherRule {
string type = 2;
string description = 3;
string condition = 4;
string action = 5;
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated VoucherRule voucherRules = 3;
string code = 1;
int64 value = 2;
repeated string voucherRules = 3;
string description = 4;
}
message RemoveVoucher {
uint32 id = 1;
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;
}