Author SHA1 Message Date
mats 7fd6b22c6b Update checkout_server.go
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m41s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m41s
2025-11-18 22:33:08 +01:00
mats ba0e820956 get raw bytes
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m38s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m41s
2025-11-18 22:27:49 +01:00
mats 7a7c577374 Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m50s
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m52s
2025-11-18 22:10:12 +01:00
mats da2b68b40e Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m33s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2m5s
2025-11-18 21:53:38 +01:00
mats 5816b6cd3b Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-18 21:53:10 +01:00
mats b135156837 Update cart-grain.go
Build and Publish / Metadata (push) Failing after 1m36s
Build and Publish / BuildAndDeployAmd64 (push) Has been skipped
Build and Publish / BuildAndDeployArm64 (push) Has been skipped
2025-11-18 21:49:01 +01:00
mats c4c116fc43 unique ids
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2s
2025-11-18 21:30:50 +01:00
mats 0994c4f484 update messages
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m9s
2025-11-18 21:08:42 +01:00
mats 78436f39ae Update pool-server.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m19s
2025-11-18 20:45:05 +01:00
mats 8905c5809c Update pool-server.go
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-11-18 20:44:44 +01:00
mats 4eb621bae4 logs
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m59s
2025-11-18 18:19:31 +01:00
mats dd8e23aadd Update k8s-host-discovery.go
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 7m10s
2025-11-18 17:06:30 +01:00
mats ea247e2600 update tests and discovery
Build and Publish / Metadata (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-18 16:40:25 +01:00
mats 718225164f move
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-13 22:40:23 +01:00
mats cdfd263318 fetcher span
Build and Publish / Metadata (push) Successful in 18s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5m55s
2025-11-13 21:54:19 +01:00
mats e25ca3304e update
Build and Publish / Metadata (push) Successful in 16s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 21:52:29 +01:00
mats af5d4cd325 more otel
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m49s
2025-11-13 21:40:20 +01:00
mats cebd3ea80f less pod logs
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m40s
2025-11-13 18:35:40 +01:00
mats a0f5ae0c61 more logs
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 18:34:25 +01:00
mats 67c9b2a7df more otel
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m4s
2025-11-13 18:28:09 +01:00
mats e703b71d35 Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 18:23:09 +01:00
mats 5bd0bdb44c notification url 2025-11-13 18:14:43 +01:00
mats 0c9780f36c Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m51s
2025-11-11 17:47:49 +01:00
mats e0d8afae92 change name
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-11 17:27:05 +01:00
mats 6d9f9bec11 Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 17:26:43 +01:00
mats a54435ebbb test external ip
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m3s
2025-11-11 17:20:29 +01:00
mats 529e70fc68 more testing
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m38s
2025-11-11 17:01:57 +01:00
mats 3f9c790dc2 update again. with more logs
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 16:57:45 +01:00
mats 5223fef2fa Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m0s
2025-11-11 16:44:41 +01:00
mats 7161c2a8b6 Update amqp-order-handler.go 2025-11-11 16:00:59 +01:00
mats cc6d48c879 update urls
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m53s
2025-11-11 13:32:31 +01:00
mats 756a43b342 reserve inventory when order placed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-11-11 11:47:05 +01:00
mats 81246fe497 Update cart-grain.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m0s
2025-11-11 09:52:52 +01:00
mats 3fa0009b95 Update deployment.yaml
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-11 09:44:40 +01:00
mats 19fc5a9553 correct the discount sums
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 09:40:12 +01:00
mats ab5d9cb2b7 try to reserve
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-11 09:29:51 +01:00
mats 5d4d917f6a Update main.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-11-11 09:06:51 +01:00
mats af7ce20557 update
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 38s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m56s
2025-11-11 08:51:56 +01:00
mats ed9a02227e update deployment
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m59s
2025-11-10 22:34:38 +01:00
mats 34e0445857 Update main.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m2s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m12s
2025-11-10 22:28:58 +01:00
mats 61457bce6b inventory deployment
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m6s
2025-11-10 21:03:29 +01:00
mats 8bf29020dd Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
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
mats 44d7c1faad add listener and remove memory inventory service 2025-11-10 19:47:15 +01:00
mats caab742461 add missing code
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
mats b272282b1f better metrics
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
mats 43fcf69139 more traces
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
mats 7d9fd0ebb4 fix proxy
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
mats e662c7dafa log
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
mats d969da428f Update otel.go
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
mats d0325e302e change to otel metrics 2025-11-07 12:36:53 +01:00
mats 1aa12ff8d9 Update pool-server.go
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
mats 68eca49cd0 Update pool-server.go
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
mats bb80c9ab13 change
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
mats dce00fb5e3 change to debug service
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
mats 81e2fb5faa do all requests with context
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
mats 01d8d86c7c manual handler
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
mats 0c4d9e2245 Update checkout_server.go
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
mats 8aab59a5f4 Update pool-server.go
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
mats c1599af40b Update pool-server.go 2025-11-05 18:32:55 +01:00
mats acf2a3a8c1 Update pool-server.go
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
mats 0a86bd380a Update main.go
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
mats 42913a079f update handler
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
mats c71b668a87 slask
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
mats 89ee3e725f test
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
mats aef90e2bbb update
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
mats 834bf9f7bc prom meter
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
mats de77a3b707 change to prometheus
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
mats 162a2638fa more changes
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
mats 5533d241e9 again
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
mats b36125d664 change type
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
mats cd0ee22ddc own
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
mats 00fcacf1be test again
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
mats 2378635790 remove otel for now
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
mats da28e993cd add oltp everything
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
mats 00c2ff70da update
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
mats eb4061f1b8 update
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
mats e155736313 set otel variables
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
mats 5e7591335c update
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
mats c68f726a96 otel
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
mats 82d564b136 test otel
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
mats eb1f7750df Update main.go 2025-11-04 18:20:29 +01:00
mats ce0bac477a Update main.go
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
mats 7c0e3e84a2 implement redis inventory with threadsafe reservation
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
mats c67ebd7a5f initial inventory service 2025-11-04 12:48:41 +01:00
mats 42e38504a3 test without rollout
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
mats b7ae36e53c allow error responses when proxying
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
mats d9fb49ec0b update
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
mats 2202c149b8 cleanup
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
mats e91433eda7 process
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
mats 7eb000fd17 only after mutations
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
mats 9ecd91c163 testing promotion actions
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
mats 246a5ebd85 format
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
mats e1de5a00a0 eval
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
mats 99c9f611e7 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
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
mats df0cd58dcd update totals on remove 2025-10-20 09:36:30 +02:00
mats 86f97f2888 Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
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
mats af3eb0d7bf fix close if no queue is used 2025-10-19 16:46:14 +02:00
mats a0c82dc351 missing file
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
mats 0c127e9d38 add description all the way
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
mats 662b381a34 probably fix discounts
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
mats e127251a60 add state file parser
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
mats b7f0990269 simpler rules
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
mats d58409e3fc add total test
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
mats 2ce45656d9 test
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
mats dc352e3b74 voucher change
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
mats 915d845014 update
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
mats 4a54661f24 add voucher store
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
mats 918aa7d265 promotion types
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
mats a1833d6685 missed save
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
mats 614be25ae8 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
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
mats 71fc23bf50 update 2025-10-16 22:49:12 +02:00
mats db918730d5 update to fix ingresses
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
mats 8ecad3060f update mapping
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
mats c060680768 update
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
mats 15089862d5 cleanup
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
mats cdb0241c8a more fancy
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
mats 07a7ec5781 send mutation
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
mats fa89670553 update checkout handler
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
mats 9ab0c08e79 handle dynamic json data and examples for subscription details
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
matsandmats 8682daf481 feature/backoffice (#6)
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
mats 8c2bcf5e75 send all on results on amqp
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
mats 47adb12112 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
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
mats 4835041f14 voucher saftey 2025-10-14 23:17:32 +02:00
matsandmats 104f9fbb4c missing updates (#5)
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
70 changed files with 6218 additions and 1022 deletions
+8 -43
View File
@@ -3,66 +3,32 @@ run-name: ${{ gitea.actor }} build 🚀
on: [push]
jobs:
Metadata:
runs-on: arm64
outputs:
version: ${{ steps.meta.outputs.version }}
git_commit: ${{ steps.meta.outputs.git_commit }}
build_date: ${{ steps.meta.outputs.build_date }}
steps:
- name: Checkout
uses: actions/checkout@v4
- id: meta
name: Derive build metadata
run: |
GIT_COMMIT=$(git rev-parse HEAD)
if git describe --tags --exact-match >/dev/null 2>&1; then
VERSION=$(git describe --tags --exact-match)
else
VERSION=$(git rev-parse --short=12 HEAD)
fi
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
BuildAndDeployAmd64:
needs: Metadata
runs-on: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build amd64 image
run: |
docker build \
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
--progress=plain \
-t registry.knatofs.se/go-cart-actor-amd64:latest \
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
.
- name: Push amd64 images
run: |
docker push registry.knatofs.se/go-cart-actor-amd64:latest
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 deployment (pin to version)
- name: Rollout amd64 backoffice deployment
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 restart deployment/cart-backoffice-x86 -n cart
BuildAndDeployArm64:
needs: Metadata
runs-on: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build arm64 image
run: |
docker build \
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
--progress=plain \
-t registry.knatofs.se/go-cart-actor:latest \
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
@@ -70,8 +36,7 @@ jobs:
- name: Push arm64 images
run: |
docker push registry.knatofs.se/go-cart-actor:latest
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
- 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
# - 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
+2 -1
View File
@@ -1,4 +1,5 @@
__debug*
go-cart-actor
data/*.prot
data/*.go*
data/*.go*
data/se/*
+16
View File
@@ -59,6 +59,20 @@ 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
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-inventory ./cmd/inventory
############################
# Runtime Stage
############################
@@ -67,6 +81,8 @@ 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
COPY --from=build /out/go-cart-inventory /go-cart-inventory
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337
-31
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
View File
Binary file not shown.
+280
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(r.Context(), 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,
},
})
}
+89
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
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"))
}
}
}
+148 -1
View File
@@ -1,5 +1,152 @@
package main
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() {
// Your code here
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
}
+31 -28
View File
@@ -9,53 +9,56 @@ import (
)
type AmqpOrderHandler struct {
Url string
Connection *amqp.Connection
Channel *amqp.Channel
conn *amqp.Connection
queue *amqp.Queue
}
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) DefineQueue() 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
}
func (h *AmqpOrderHandler) Close() error {
if h.Channel != nil {
h.Channel.Close()
}
if h.Connection != nil {
return h.Connection.Close()
queue, err := ch.QueueDeclare(
"order-queue", // name
false,
false,
false,
false,
nil,
)
if err != nil {
return fmt.Errorf("failed to declare an exchange: %w", err)
}
h.queue = &queue
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,
"orders", // exchange
"new", // routing key
false, // mandatory
false, // immediate
return ch.PublishWithContext(ctx,
"", // exchange
h.queue.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
//DeliveryMode: amqp.,
ContentType: "application/json",
Body: body,
})
if err != nil {
return fmt.Errorf("failed to publish a message: %w", err)
}
return nil
}
+7 -6
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
@@ -12,8 +14,6 @@ type CheckoutMeta struct {
Terms string
Checkout string
Confirmation string
Validation string
Push string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
@@ -33,7 +33,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")
}
@@ -67,7 +67,7 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
Name: it.Meta.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price.IncVat),
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
@@ -105,8 +105,9 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
Terms: meta.Terms,
Checkout: meta.Checkout,
Confirmation: meta.Confirmation,
Validation: meta.Validation,
Push: meta.Push,
Notification: "https://cart.tornberg.me/notification",
Validation: "https://cart.tornberg.me/validate",
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
},
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
"git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
)
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func (a *App) getGrainFromOrder(ctx context.Context, order *CheckoutOrder) (*cart.CartGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.pool.Get(ctx, uint64(cartId))
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
return &CartChangeEvent{
CartId: cart.CartId(id),
Mutations: msg,
}, nil
})
amqpListener.DefineTopics()
a.pool.AddListener(amqpListener)
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
err = inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
a.pool.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
err = confirmOrder(r.Context(), order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(r.Context(), a.server, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
}))
mux.HandleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/notification", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
_, err = inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory check failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
}
w.WriteHeader(http.StatusOK)
})
}
+60
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/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.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}
+21 -7
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -30,11 +31,14 @@ const (
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
)
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
if err != nil {
return nil, err
}
spanCtx, span := tracer.Start(ctx, "Get klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -64,13 +68,15 @@ 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
}
spanCtx, span := tracer.Start(ctx, "Create klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -82,13 +88,16 @@ 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
}
spanCtx, span := tracer.Start(ctx, "Update klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -100,12 +109,14 @@ 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
}
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.SetBasicAuth(k.UserName, k.Password)
_, err = http.DefaultClient.Do(req)
@@ -113,11 +124,14 @@ 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
}
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
defer span.End()
req = req.WithContext(spanCtx)
id := uuid.New()
req.SetBasicAuth(k.UserName, k.Password)
+124 -302
View File
@@ -1,28 +1,30 @@
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"
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
"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"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
@@ -30,14 +32,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,26 +39,16 @@ func init() {
}
type App struct {
pool *actor.SimpleGrainPool[CartGrain]
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
klarnaClient *KlarnaClient
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
@@ -76,86 +60,57 @@ 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(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
_, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
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(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
defer span.End()
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)
err := diskStorage.LoadEvents(ctx, id, ret)
return ret, err
},
@@ -171,66 +126,64 @@ func main() {
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
app := &App{
pool: pool,
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService)
app := &App{
pool: pool,
server: syncedServer,
klarnaClient: klarnaClient,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Printf("no connection to amqp defined")
} else {
app.HandleCheckoutRequests(amqpUrl, mux, inventoryService)
}
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
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 +209,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,49 +216,64 @@ 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()
}
}
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
func triggerOrderCompleted(ctx context.Context, syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
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)
}
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
_, applyErr := syncedServer.Apply(ctx, uint64(cid), mutation)
return applyErr
}
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
func confirmOrder(ctx context.Context, order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
orderToSend, err := json.Marshal(order)
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
+177 -2
View File
@@ -16,7 +16,7 @@
}
],
"paths": {
"/cart/": {
"/cart": {
"get": {
"summary": "Get (or create) current cart (cookie based)",
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
@@ -263,6 +263,76 @@
}
}
},
"/cart/voucher": {
"put": {
"summary": "Add voucher to cart",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
}
}
},
"responses": {
"200": {
"description": "Voucher added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/subscription-details": {
"put": {
"summary": "Upsert subscription details",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertSubscriptionDetails"
}
}
}
},
"responses": {
"200": {
"description": "Subscription details updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/voucher/{voucherId}": {
"delete": {
"summary": "Remove voucher from cart",
"parameters": [{ "$ref": "#/components/parameters/VoucherIdParam" }],
"responses": {
"200": {
"description": "Voucher removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}": {
"get": {
"summary": "Get cart by explicit id",
@@ -452,6 +522,53 @@
}
}
},
"/cart/byid/{id}/voucher": {
"put": {
"summary": "Add voucher (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
}
}
},
"responses": {
"200": {
"description": "Voucher added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/voucher/{voucherId}": {
"delete": {
"summary": "Remove voucher (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/VoucherIdParam" }
],
"responses": {
"200": {
"description": "Voucher removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid ids" },
"500": { "description": "Server error" }
}
}
},
"/healthz": {
"get": {
"summary": "Liveness & capacity probe",
@@ -517,6 +634,12 @@
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
},
"VoucherIdParam": {
"name": "voucherId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
},
"schemas": {
@@ -542,7 +665,17 @@
"processing": { "type": "boolean" },
"paymentInProgress": { "type": "boolean" },
"orderReference": { "type": "string" },
"paymentStatus": { "type": "string" }
"paymentStatus": { "type": "string" },
"vouchers": {
"type": "array",
"items": { "$ref": "#/components/schemas/Voucher" }
},
"subscriptionDetails": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/SubscriptionDetails"
}
}
},
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
},
@@ -670,6 +803,48 @@
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["provider", "items"]
},
"AddVoucherRequest": {
"type": "object",
"properties": {
"code": { "type": "string" }
},
"required": ["code"]
},
"UpsertSubscriptionDetails": {
"type": "object",
"properties": {
"id": { "type": "string" },
"offeringCode": { "type": "string" },
"signingType": { "type": "string" },
"data": { "type": "object" }
},
"required": ["offeringCode", "signingType"]
},
"Voucher": {
"type": "object",
"properties": {
"code": { "type": "string" },
"applied": { "type": "boolean" },
"rules": {
"type": "array",
"items": { "type": "string" }
},
"description": { "type": "string" },
"id": { "type": "integer", "format": "int64" },
"value": { "type": "integer", "format": "int64" }
},
"required": ["code", "applied", "rules", "id", "value"]
},
"SubscriptionDetails": {
"type": "object",
"properties": {
"id": { "type": "string" },
"offeringCode": { "type": "string" },
"signingType": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
}
}
},
+117
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
}
+245 -95
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -11,31 +12,55 @@ 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"
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
"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]
pod_name string
klarnaClient *KlarnaClient
actor.GrainPool[*cart.CartGrain]
pod_name string
klarnaClient *KlarnaClient
inventoryService inventory.InventoryService
}
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService) *PoolServer {
return &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
inventoryService: inventoryService,
}
}
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
return s.Apply(uint64(id), mutation...)
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.Get(uint64(id))
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
@@ -43,16 +68,17 @@ 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
}
data, err := s.ApplyLocal(id, msg)
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
@@ -73,14 +99,14 @@ 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)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
@@ -93,14 +119,14 @@ 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)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.SetDelivery{
data, err := s.ApplyLocal(r.Context(), id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
@@ -111,7 +137,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)
@@ -123,7 +149,7 @@ func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Reques
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
reply, err := s.ApplyLocal(r.Context(), id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
@@ -138,27 +164,27 @@ 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)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
reply, err := s.ApplyLocal(r.Context(), id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id 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 {
return err
}
reply, err := s.ApplyLocal(id, &changeQuantity)
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
if err != nil {
return err
}
@@ -176,14 +202,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 +223,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,23 +232,23 @@ 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...)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
if err != nil {
return err
}
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(r.Context(), id, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
if err != nil {
return err
}
@@ -236,17 +262,17 @@ 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
}
reply, err := s.ApplyLocal(id, msg)
reply, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
@@ -287,24 +313,61 @@ func getLocale(country string) string {
return "sv-se"
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func (s *PoolServer) CreateOrUpdateCheckout(r *http.Request, id cart.CartId) (*CheckoutOrder, error) {
host := getOriginalHost(r)
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.Get(uint64(id))
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return nil, err
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
failingRequest, err := s.inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
return nil, err
}
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
@@ -313,15 +376,15 @@ 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(r.Context(), grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(id, &messages.InitializeCheckout{
return s.ApplyLocal(ctx, id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
@@ -341,12 +404,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 +421,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 +451,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 +466,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,35 +494,71 @@ 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(r.Context(), cartId, &messages.PreConditionFailed{
Operation: "AddVoucher",
Error: err.Error(),
})
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, msg)
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@@ -469,7 +568,45 @@ 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(r.Context(), 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, cartId)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
s.ApplyCheckoutStarted(r.Context(), order, cartId)
return fn(order, w)
}
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
return err
}
return fn(order, w)
}))
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
idStr := r.PathValue("voucherId")
id, err := strconv.ParseInt(idStr, 10, 64)
@@ -478,7 +615,7 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@@ -488,45 +625,58 @@ 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.SetName(pattern)
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
}
+38 -11
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,16 @@ 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)
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
defer span.End()
req = req.WithContext(innerCtx)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
@@ -37,36 +46,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 +127,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 +139,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
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
"github.com/matst80/slask-finder/pkg/index"
"github.com/matst80/slask-finder/pkg/messaging"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
amqp "github.com/rabbitmq/amqp091-go"
)
type Server struct {
service *inventory.RedisInventoryService
}
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
// Parse path: /inventory/{sku}/{location}
path := r.URL.Path
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) != 3 || parts[0] != "inventory" {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
sku := inventory.SKU(parts[1])
locationID := inventory.LocationID(parts[2])
quantity, err := srv.service.GetInventory(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]int64{"quantity": quantity}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
var country = "se"
var redisAddress = "10.10.3.18:6379"
var redisPassword = "slaskredis"
func init() {
// Override redis config from environment variables if set
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
redisAddress = addr
}
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
redisPassword = password
}
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
country = ctry
}
}
func main() {
var ctx = context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword, // no password set
DB: 0, // use default DB
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
s, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Unable to connect to inventory redis: %v", err)
return
}
server := &Server{service: s}
// Set up HTTP routes
http.HandleFunc("/livez", server.livezHandler)
http.HandleFunc("/readyz", server.readyzHandler)
http.HandleFunc("/inventory/", server.getInventoryHandler)
stockhandler := &StockHandler{
MainStockLocationID: inventory.LocationID(country),
rdb: rdb,
ctx: ctx,
svc: *s,
}
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
if ok {
log.Printf("Connecting to rabbitmq")
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
Properties: amqp.NewConnectionProperties(),
})
//a.conn = conn
if err != nil {
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
}
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open a channel: %v", err)
}
// items listener
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
var items []*index.DataItem
err := json.Unmarshal(d.Body, &items)
if err == nil {
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
wg := &sync.WaitGroup{}
for _, item := range items {
stockhandler.HandleItem(item, wg)
}
wg.Wait()
log.Print("Batch done...")
} else {
log.Printf("Failed to unmarshal upsert message %v", err)
}
return err
})
if err != nil {
log.Fatalf("Failed to listen to item_added topic: %v", err)
}
}
// Start HTTP server
log.Println("Starting HTTP server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"log"
"strconv"
"strings"
"sync"
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
"github.com/matst80/slask-finder/pkg/types"
"github.com/redis/go-redis/v9"
)
type StockHandler struct {
rdb *redis.Client
ctx context.Context
svc inventory.RedisInventoryService
MainStockLocationID inventory.LocationID
}
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
wg.Go(func() {
ctx := s.ctx
pipe := s.rdb.Pipeline()
centralStockString, ok := item.GetStringFieldValue(3)
if !ok {
centralStockString = "0"
}
centralStockString = strings.Replace(centralStockString, "+", "", -1)
centralStockString = strings.Replace(centralStockString, "<", "", -1)
centralStockString = strings.Replace(centralStockString, ">", "", -1)
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
if err != nil {
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
centralStock = 0
} else {
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
}
for id, value := range item.GetStock() {
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
}
_, err = pipe.Exec(ctx)
if err != nil {
log.Printf("unable to update stock: %v", err)
}
})
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+221 -5
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,14 @@ spec:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- 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 +255,8 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
@@ -164,14 +264,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 +284,14 @@ spec:
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "redis.home:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- 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 +320,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 +330,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 +369,100 @@ 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
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-inventory
arch: amd64
name: cart-inventory-x86
spec:
replicas: 1
selector:
matchLabels:
app: cart-inventory
arch: amd64
template:
metadata:
labels:
app: cart-inventory
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-inventory-amd64
imagePullPolicy: Always
command: ["/go-cart-inventory"]
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
resources:
limits:
memory: "256Mi"
cpu: "500m"
requests:
memory: "50Mi"
cpu: "500m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: RABBIT_HOST
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
- name: REDIS_ADDRESS
value: "redis.home:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
+53 -27
View File
@@ -1,33 +1,51 @@
module git.tornberg.me/go-cart-actor
go 1.25.1
go 1.25.3
require (
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee
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-20251113121253-1d0cc78309a0
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
k8s.io/apimachinery v0.34.1
k8s.io/client-go v0.34.1
k8s.io/api v0.34.2
k8s.io/apimachinery v0.34.2
k8s.io/client-go v0.34.2
)
require (
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.14.4 // 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.3 // 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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20250909171706-0a81c39169bc // 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/getkin/kin-openapi v0.133.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/jsonreference v0.21.3 // indirect
github.com/go-openapi/swag v0.25.1 // indirect
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
@@ -43,39 +61,47 @@ 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.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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
github.com/mailru/easyjson v0.9.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
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/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/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
github.com/spf13/cobra v1.7.0 // 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.9.0 // 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/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.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.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // 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
+139 -34
View File
@@ -1,24 +1,44 @@
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=
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251110193851-19d7ad0de6e5 h1:54ZKuqppO6reMmnWOYJaFMlPJK947xnPrv3zDbSuknQ=
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251110193851-19d7ad0de6e5/go.mod h1:jrDU55O7sdN2RJr99upmig/FAla/mW1Cdju7834TXug=
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee h1:9K/INdO9y/hAjlERsYkJWAla6BUEAPXtChVLfYtWdGI=
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee/go.mod h1:jrDU55O7sdN2RJr99upmig/FAla/mW1Cdju7834TXug=
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/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
github.com/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/bits-and-blooms/bitset v1.24.3 h1:Bte86SlO3lwPQqww+7BE9ZuUCKIjfqnG5jtEyqA9y9Y=
github.com/bits-and-blooms/bitset v1.24.3/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=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dprotaso/go-yit v0.0.0-20250909171706-0a81c39169bc h1:YxqE1wh+qGVXQFinuRq5lT77h6baDtBnAVh61LlXp1o=
github.com/dprotaso/go-yit v0.0.0-20250909171706-0a81c39169bc/go.mod h1:5NQLChvz4dnEIQ8WcHIFbZ1bp0GEUZHiBH+EpTZ4lBc=
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 +46,9 @@ 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/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
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=
@@ -34,6 +57,8 @@ github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
@@ -95,8 +120,14 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -116,8 +147,12 @@ 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/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matst80/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/matst80/slask-finder v0.0.0-20251113121253-1d0cc78309a0 h1:ZBbVCFAvccFLOck4e64BtNXdexouVHfQqQzlZaEIY40=
github.com/matst80/slask-finder v0.0.0-20251113121253-1d0cc78309a0/go.mod h1:554hsg38gorr5GH/BLGs4pPqVpmrHTNWq5qoferDeDA=
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=
@@ -133,8 +168,11 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -144,6 +182,7 @@ github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
@@ -154,37 +193,52 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
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/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
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/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
github.com/speakeasy-api/jsonpath v0.6.2/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/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5/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,31 +249,57 @@ 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.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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=
@@ -230,14 +310,20 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -254,16 +340,22 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -271,16 +363,24 @@ 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/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
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/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/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,15 +412,20 @@ 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=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
+9 -6
View File
@@ -1,6 +1,7 @@
package actor
import (
"context"
"errors"
"fmt"
"log"
@@ -25,8 +26,8 @@ type DiskStorage[V any] struct {
}
type LogStorage[V any] interface {
LoadEvents(id uint64, grain Grain[V]) error
AppendEvent(id uint64, msg ...proto.Message) error
LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error
AppendMutations(id uint64, msg ...proto.Message) error
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
@@ -86,7 +87,7 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
}
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
path := s.logPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No log -> nothing to replay
@@ -99,16 +100,18 @@ func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
}
defer fh.Close()
return s.Load(fh, func(msg proto.Message) {
s.registry.Apply(grain, msg)
s.registry.Apply(ctx, grain, msg)
})
}
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)
+4 -2
View File
@@ -1,6 +1,7 @@
package actor
import (
"context"
"net/http"
"github.com/gogo/protobuf/proto"
@@ -12,8 +13,8 @@ type MutationResult[V any] struct {
}
type GrainPool[V any] interface {
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(id uint64) (V, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(ctx context.Context, id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Hostname() string
TakeOwnership(id uint64)
@@ -22,6 +23,7 @@ type GrainPool[V any] interface {
Negotiate(otherHosts []string)
GetLocalIds() []uint64
RemoveHost(host string)
AddRemoteHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()
+119 -5
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,
+47
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)
}
}
+66 -7
View File
@@ -1,12 +1,14 @@
package actor
import (
"context"
"fmt"
"log"
"reflect"
"sync"
"github.com/gogo/protobuf/proto"
"go.opentelemetry.io/otel/attribute"
)
type ApplyResult struct {
@@ -15,11 +17,30 @@ type ApplyResult struct {
Error error `json:"error,omitempty"`
}
type MutationProcessor interface {
Process(ctx context.Context, grain any) error
}
type BasicMutationProcessor[V any] struct {
processor func(ctx context.Context, grain V) error
}
func NewMutationProcessor[V any](process func(ctx context.Context, grain V) error) MutationProcessor {
return &BasicMutationProcessor[V]{
processor: process,
}
}
func (p *BasicMutationProcessor[V]) Process(ctx context.Context, grain any) error {
return p.processor(ctx, grain.(V))
}
type MutationRegistry interface {
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
Apply(ctx context.Context, 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()
@@ -165,33 +192,65 @@ func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error) {
parentCtx, span := tracer.Start(ctx, "apply mutations")
defer span.End()
span.SetAttributes(
attribute.String("component", "registry"),
attribute.Int("mutations", len(msg)),
)
results := make([]ApplyResult, 0, len(msg))
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))
_, msgSpan := tracer.Start(parentCtx, rt.Name())
r.mutationRegistryMu.RLock()
entry, ok := r.mutationRegistry[rt]
r.mutationRegistryMu.RUnlock()
if !ok {
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
continue
} else {
err := entry.Handle(grain, m)
if err != nil {
msgSpan.RecordError(err)
}
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
err := entry.Handle(grain, m)
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
msgSpan.End()
}
// if entry.updateTotals {
// grain.UpdateTotals()
// }
if len(results) > 0 {
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
defer processSpan.End()
for _, processor := range r.processors {
err := processor.Process(processCtx, grain)
if err != nil {
return results, err
}
}
}
return results, nil
}
+6 -5
View File
@@ -1,6 +1,7 @@
package actor
import (
"context"
"errors"
"reflect"
"slices"
@@ -21,8 +22,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{} },
@@ -78,7 +79,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
// Apply happy path
state := &cartState{}
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(state, add); err != nil {
if _, err := reg.Apply(context.Background(), state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if state.calls != 1 {
@@ -94,12 +95,12 @@ func TestRegisteredMutationBasics(t *testing.T) {
}
// Apply nil message
if _, err := reg.Apply(state, nil); err == nil {
if _, err := reg.Apply(context.Background(), state, nil); err == nil {
t.Fatalf("expected error for nil mutation message")
}
// Apply unregistered message
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
if _, err := reg.Apply(context.Background(), state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}
+32 -10
View File
@@ -1,6 +1,7 @@
package actor
import (
"context"
"fmt"
"log"
"maps"
@@ -15,8 +16,9 @@ type SimpleGrainPool[V any] struct {
localMu sync.RWMutex
grains map[uint64]Grain[V]
mutationRegistry MutationRegistry
spawn func(id uint64) (Grain[V], error)
spawn func(ctx context.Context, id uint64) (Grain[V], error)
spawnHost func(host string) (Host, error)
listeners []LogListener
storage LogStorage[V]
ttl time.Duration
poolSize int
@@ -34,7 +36,7 @@ type SimpleGrainPool[V any] struct {
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(id uint64) (Grain[V], error)
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
SpawnHost func(host string) (Host, error)
TTL time.Duration
PoolSize int
@@ -66,6 +68,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 +158,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")
@@ -348,7 +367,7 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
// go p.statsUpdate()
}
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
@@ -356,7 +375,7 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
return grain, nil
}
grain, err := p.spawn(id)
grain, err := p.spawn(ctx, id)
if err != nil {
return nil, err
}
@@ -371,23 +390,26 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
grain, err := p.getOrClaimGrain(id)
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
grain, err := p.getOrClaimGrain(ctx, id)
if err != nil {
return nil, err
}
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
if err != nil {
return nil, err
}
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
@@ -399,8 +421,8 @@ func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*Mutat
}
// Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(id)
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(ctx, id)
if err != nil {
return nil, err
}
@@ -1,8 +1,7 @@
package main
package cart
import (
"encoding/json"
"fmt"
"slices"
"sync"
"time"
@@ -31,13 +30,14 @@ type ItemMeta struct {
}
type CartItem struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId *uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Tax int
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Discount *Price `json:"discount,omitempty"`
@@ -45,6 +45,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 +63,43 @@ type CartNotification struct {
Content string `json:"content"`
}
type SubscriptionDetails struct {
Id string `json:"id,omitempty"`
Version uint16 `json:"version"`
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 +131,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 +150,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 +184,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 +253,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)
}
+54
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
}
@@ -1,4 +1,4 @@
package main
package cart
import (
"testing"
@@ -34,8 +34,8 @@ func TestCartGrainUpdateTotalsBasic(t *testing.T) {
}
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
if c.TotalDiscount.IncVat != 250 {
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
if c.TotalDiscount.IncVat != 500 {
t.Fatalf("TotalDiscount expected 500 got %d", c.TotalDiscount.IncVat)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package cart
import (
"crypto/rand"
@@ -1,4 +1,4 @@
package main
package cart
import (
"encoding/json"
@@ -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
}
@@ -50,6 +63,7 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
ItemId: uint32(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Tax: int(taxRate * 100),
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
@@ -63,6 +77,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)),
@@ -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
@@ -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
}
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
+7
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
}
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package cart
import (
"fmt"
+526
View File
@@ -0,0 +1,526 @@
package cart
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/gogo/protobuf/proto"
"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(context.Background(), g, msg)
if err != nil {
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
}
if len(results) != 1 {
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
}
return results[0]
}
// Expect success (nil error inside ApplyResult).
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error != nil {
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
}
}
// Expect an error matching substring.
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error == nil {
t.Fatalf("expected error applying %T, got nil", msg)
}
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
}
}
// ----------------------
// Tests
// ----------------------
func TestMutationRegistryCoverage(t *testing.T) {
reg := newRegistry()
expected := []string{
"AddItem",
"ChangeQuantity",
"RemoveItem",
"InitializeCheckout",
"OrderCreated",
"RemoveDelivery",
"SetDelivery",
"SetPickupPoint",
"ClearCartRequest",
"AddVoucher",
"RemoveVoucher",
"UpsertSubscriptionDetails",
}
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
for _, want := range expected {
if !slices.Contains(names, want) {
t.Fatalf("registry missing mutation %s; got %v", want, names)
}
}
// Create() by name returns correct concrete type.
for _, name := range expected {
msg, ok := reg.Create(name)
if !ok {
t.Fatalf("Create failed for %s", name)
}
rt := reflect.TypeOf(msg)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
if rt.Name() != name {
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
}
}
// Unregistered create
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
}
// GetTypeName sanity
add := &messages.AddItem{}
nm, ok := reg.GetTypeName(add)
if !ok || nm != "AddItem" {
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
}
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{})
if err != nil {
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
}
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
}
}
func TestAddItemAndMerging(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Merge scenario (same SKU + same store pointer)
add1 := msgAddItem("SKU-1", 1000, 2, nil)
applyOK(t, reg, g, add1)
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
// Different store pointer -> new line
store := "S1"
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
if len(g.Items) != 2 {
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
}
// Same store pointer & SKU -> merge with second line
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
}
// Invalid quantity
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
}
func TestChangeQuantityBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
id := g.Items[0].Id
// Increase quantity
applyOK(t, reg, g, msgChangeQty(id, 5))
if g.Items[0].Quantity != 5 {
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
}
// Remove item by setting <=0
applyOK(t, reg, g, msgChangeQty(id, 0))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
// Not found
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
}
func TestRemoveItemBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgRemoveItem(id))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
}
func TestDeliveryMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
i1 := g.Items[0].Id
// Explicit items
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
}
// Attempt to attach an already-delivered item
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
// Attach remaining item via empty list (auto include items without delivery)
applyOK(t, reg, g, msgSetDelivery("DHL"))
if len(g.Deliveries) != 2 {
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
}
// Non-existent item
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
// No eligible items left
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
// Set pickup point on first delivery
did := g.Deliveries[0].Id
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
}
// Bad delivery id
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
// Remove delivery
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
}
// Remove delivery not found
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
}
func TestClearCart(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
applyOK(t, reg, g, msgClearCart())
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
}
}
func TestVoucherMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
if len(g.Vouchers) != 1 {
t.Fatalf("voucher not stored")
}
if g.TotalDiscount.IncVat != 5000 {
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
}
// Duplicate voucher code
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
// Add a large voucher (should not apply because value > total price)
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
if len(g.Vouchers) != 2 {
t.Fatalf("expected second voucher stored")
}
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove existing voucher
firstId := g.Vouchers[0].Id
applyOK(t, reg, g, msgRemoveVoucher(firstId))
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
t.Fatalf("voucher id %d not removed", firstId)
}
// After removing PROMO, BIG remains but is not applied (exceeds price)
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
t.Fatalf("totals incorrect after removal discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove not applied
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
}
func TestCheckoutMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
}
func TestSubscriptionDetailsMutation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Upsert new (Id == nil)
msgNew := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFF1",
SigningType: "TYPE1",
}
applyOK(t, reg, g, msgNew)
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
}
// Capture created id
var createdId string
for k := range g.SubscriptionDetails {
createdId = k
}
// Update existing
msgUpdate := &messages.UpsertSubscriptionDetails{
Id: &createdId,
OfferingCode: "OFF2",
SigningType: "TYPE2",
}
applyOK(t, reg, g, msgUpdate)
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
}
// Update non-existent
badId := "NON_EXISTENT"
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
// Nil mutation should be ignored and produce zero results.
resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil))
if errNil != nil {
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
}
if len(resultsNil) != 0 {
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
}
}
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
func TestRegistryDefensiveErrors(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Nil grain
results, err := reg.Apply(context.Background(), nil, &messages.AddItem{})
if err == nil {
t.Fatalf("expected error for nil grain")
}
if len(results) != 0 {
t.Fatalf("expected no results for nil grain")
}
// Nil message slice
results, _ = reg.Apply(context.Background(), g, nil)
if len(results) != 0 {
t.Fatalf("expected no results when message slice nil")
}
}
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Valid JSON on create
jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value": "eyJvayI6dHJ1ZX0="}}`
var validCreate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil {
t.Fatal(err)
}
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
jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id)
var updateValid messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &updateValid)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Invalid JSON on create
jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}`
var invalidCreate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil {
t.Fatal(err)
}
res := applyOne(t, reg, g, &invalidCreate)
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on create, got %v", res.Error)
}
// Invalid JSON on update
jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id)
var badUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil {
t.Fatal(err)
}
res2 := applyOne(t, reg, g, &badUpdate)
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
}
// Empty Data should not overwrite existing meta
jsonStr5 := fmt.Sprintf(`{"id": "%s", "data": {"value": ""}}`, id)
var emptyUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &emptyUpdate)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
}
}
@@ -0,0 +1,86 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/protobuf/types/known/anypb"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
// Helper to get data bytes from Data field
getDataBytes := func(data *anypb.Any) ([]byte, error) {
if data == nil {
return nil, nil
}
if len(data.Value) > 0 {
return data.Value, nil
}
if data.TypeUrl != "" {
return []byte(data.TypeUrl), nil
}
return nil, nil
}
// Create new subscription details when Id is nil.
if m.Id == nil {
// Validate JSON if provided.
var meta json.RawMessage
dataBytes, err := getDataBytes(m.Data)
if err != nil {
return err
}
if dataBytes != nil {
if !json.Valid(dataBytes) {
return fmt.Errorf("subscription details invalid json")
}
meta = json.RawMessage(dataBytes)
}
id := MustNewCartId().String()
g.SubscriptionDetails[id] = &SubscriptionDetails{
Id: id,
Version: 1,
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")
}
changed := false
if m.OfferingCode != "" {
existing.OfferingCode = m.OfferingCode
changed = true
}
if m.SigningType != "" {
existing.SigningType = m.SigningType
changed = true
}
dataBytes, err := getDataBytes(m.Data)
if err != nil {
return err
}
if dataBytes != nil {
if !json.Valid(dataBytes) {
return fmt.Errorf("subscription details invalid json")
}
existing.Meta = dataBytes
changed = true
}
if changed {
existing.Version++
}
return nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package cart
import (
"encoding/json"
@@ -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())
}
}
@@ -133,3 +133,106 @@ func TestPriceMultiplyMethod(t *testing.T) {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}
func TestGetTaxAmount(t *testing.T) {
tests := []struct {
total int64
tax int
expected int64
desc string
}{
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
{1200, 2500, 240, "25% VAT on 1200"},
{0, 2500, 0, "zero total"},
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
}
for _, tt := range tests {
result := GetTaxAmount(tt.total, tt.tax)
if result != tt.expected {
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
}
}
}
func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
// Zero VAT rate
p := NewPriceFromIncVat(1000, 0)
if p.IncVat != 1000 {
t.Errorf("expected IncVat 1000, got %d", p.IncVat)
}
if len(p.VatRates) != 1 || p.VatRates[0] != 0 {
t.Errorf("expected VAT 0 for rate 0, got %v", p.VatRates)
}
if p.ValueExVat() != 1000 {
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
}
// High VAT rate, e.g., 50%
p = NewPriceFromIncVat(1500, 50)
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
if p.VatRates[50] != expectedVat {
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
}
if p.ValueExVat() != 1500-expectedVat {
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
}
}
func TestPriceValueExVatAndTotalVat(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
exVat := p.ValueExVat()
totalVat := p.TotalVat()
if exVat != 10000 {
t.Errorf("expected exVat 10000, got %d", exVat)
}
if totalVat != 3700 {
t.Errorf("expected totalVat 3700, got %d", totalVat)
}
if exVat+totalVat != p.IncVat {
t.Errorf("exVat + totalVat should equal IncVat: %d + %d != %d", exVat, totalVat, p.IncVat)
}
// Empty VAT rates
p2 := Price{IncVat: 500, VatRates: nil}
if p2.ValueExVat() != 500 {
t.Errorf("expected exVat 500 for no VAT, got %d", p2.ValueExVat())
}
if p2.TotalVat() != 0 {
t.Errorf("expected totalVat 0, got %d", p2.TotalVat())
}
}
func TestMultiplyPriceWithZeroQty(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 0)
if multiplied.IncVat != 0 {
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
}
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
}
}
func TestPriceAddSubtractEdgeCases(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
}
// Subtract more than added (negative VAT)
acc.Subtract(a)
acc.Subtract(b)
acc.Subtract(a) // Subtract extra a
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
}
}
+19 -8
View File
@@ -2,6 +2,8 @@ package discovery
import (
"context"
"slices"
"sync"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -33,13 +35,10 @@ func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{}
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
@@ -55,10 +54,22 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
isReady := slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
})
m.Lock()
oldState := ipsThatAreReady[pod.Status.PodIP]
ipsThatAreReady[pod.Status.PodIP] = isReady
m.Unlock()
if oldState != isReady {
ch <- HostChange{
Host: pod.Status.PodIP,
IsReady: isReady,
}
}
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
Host: pod.Status.PodIP,
IsReady: isReady,
}
}
}()
+5 -8
View File
@@ -2,9 +2,8 @@ package discovery
import (
"context"
"slices"
"sync"
"k8s.io/apimachinery/pkg/watch"
)
// MockDiscovery is an in-memory Discovery implementation for tests.
@@ -56,14 +55,12 @@ func (m *MockDiscovery) AddHost(host string) {
if m.closed {
return
}
for _, h := range m.hosts {
if h == host {
return
}
if slices.Contains(m.hosts, host) {
return
}
m.hosts = append(m.hosts, host)
if m.started {
m.events <- HostChange{Host: host, Type: watch.Added}
m.events <- HostChange{Host: host, IsReady: true}
}
}
@@ -86,7 +83,7 @@ func (m *MockDiscovery) RemoveHost(host string) {
}
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
if m.started {
m.events <- HostChange{Host: host, Type: watch.Deleted}
m.events <- HostChange{Host: host, IsReady: false}
}
}
+5
View File
@@ -1,5 +1,10 @@
package discovery
type HostChange struct {
Host string
IsReady bool
}
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
+1 -1
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 v6.33.1
// source: control_plane.proto
package messages
+1 -1
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.33.1
// source: control_plane.proto
package messages
+358 -194
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 v6.33.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,30 +926,29 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
return false
}
type VoucherRule struct {
type InventoryReserved 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"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
Message *string `protobuf:"bytes,3,opt,name=message,proto3,oneof" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VoucherRule) Reset() {
*x = VoucherRule{}
func (x *InventoryReserved) Reset() {
*x = InventoryReserved{}
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VoucherRule) String() string {
func (x *InventoryReserved) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VoucherRule) ProtoMessage() {}
func (*InventoryReserved) ProtoMessage() {}
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
func (x *InventoryReserved) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@@ -952,35 +960,28 @@ func (x *VoucherRule) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead.
func (*VoucherRule) Descriptor() ([]byte, []int) {
// Deprecated: Use InventoryReserved.ProtoReflect.Descriptor instead.
func (*InventoryReserved) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *VoucherRule) GetType() string {
func (x *InventoryReserved) GetId() string {
if x != nil {
return x.Type
return x.Id
}
return ""
}
func (x *VoucherRule) GetDescription() string {
func (x *InventoryReserved) GetStatus() string {
if x != nil {
return x.Description
return x.Status
}
return ""
}
func (x *VoucherRule) GetCondition() string {
if x != nil {
return x.Condition
}
return ""
}
func (x *VoucherRule) GetAction() string {
if x != nil {
return x.Action
func (x *InventoryReserved) GetMessage() string {
if x != nil && x.Message != nil {
return *x.Message
}
return ""
}
@@ -989,7 +990,8 @@ 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
}
@@ -1038,13 +1040,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"`
@@ -1089,152 +1098,301 @@ 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[15]
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[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 UpsertSubscriptionDetails.ProtoReflect.Descriptor instead.
func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{15}
}
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[16]
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[16]
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{16}
}
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,
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61,
0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb7, 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, 0x1e, 0x0a, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 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, 0x66, 0x0a, 0x11, 0x49,
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x64,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 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, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x7c, 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, 0x22, 0x0a, 0x0c, 0x76,
0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x09, 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12,
0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
0x6e, 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, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62,
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02,
0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e,
0x67, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66,
0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67,
0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64,
0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52,
0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0x74, 0x0a, 0x12,
0x50, 0x72, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x61, 0x69, 0x6c,
0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x69, 0x6e, 0x70,
0x75, 0x74, 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,
})
var (
@@ -1249,32 +1407,36 @@ 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, 17)
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
(*InventoryReserved)(nil), // 12: messages.InventoryReserved
(*AddVoucher)(nil), // 13: messages.AddVoucher
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
(*UpsertSubscriptionDetails)(nil), // 15: messages.UpsertSubscriptionDetails
(*PreConditionFailed)(nil), // 16: messages.PreConditionFailed
(*anypb.Any)(nil), // 17: 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
17, // 1: messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
17, // 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 +1448,15 @@ 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[12].OneofWrappers = []any{}
file_messages_proto_msgTypes[15].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: 17,
NumExtensions: 0,
NumServices: 0,
},
+724
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
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
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
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
}
+35 -10
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,20 @@ 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)
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 +184,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 {
+2 -6
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.
+26
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 {
+55 -10
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
}
+83 -75
View File
@@ -2,115 +2,123 @@ 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;
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}
message VoucherRule {
string type = 2;
string description = 3;
string condition = 4;
string action = 5;
message InventoryReserved {
string id = 1;
string status = 2;
optional string message = 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;
}