Author SHA1 Message Date
mats 5e36af2524 wip 2025-12-04 22:09:26 +01:00
mats d78685cd8f checkout backoffice
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 21:06:56 +01:00
mats f67eeb3c49 major changes
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 20:56:54 +01:00
mats 6d5358b53b less ping logs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 19:08:47 +01:00
mats d0d604f5f0 strange
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 19:07:15 +01:00
mats 2b8e692526 better adyen handling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-04 18:58:10 +01:00
mats c842571dbc fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:49:30 +01:00
mats bd194f2bb8 maybe fix adyen error
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:48:24 +01:00
mats 94d478ee6a ahh
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:31:51 +01:00
mats e9fb00d5c7 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m38s
2025-12-04 18:12:50 +01:00
mats 3d9d703a76 better
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:08:34 +01:00
mats 9a61b088c9 s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-04 18:02:42 +01:00
mats 3becd1c80c fix
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-12-04 18:02:06 +01:00
mats dcc7cc2dd0 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 17:51:00 +01:00
mats ce81e294f2 test
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m52s
2025-12-04 17:42:51 +01:00
mats fccb0eed06 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-04 17:14:25 +01:00
mats 978ef3025d test
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-12-04 17:08:31 +01:00
mats 0ef29596c1 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-04 14:19:00 +01:00
mats fbc773dbca more logs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-12-04 10:35:23 +01:00
mats 5037c07a47 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 10:31:11 +01:00
mats e9189364ab handle events
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-04 09:34:49 +01:00
mats 445858c1e3 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
2025-12-04 09:20:30 +01:00
mats 82b64ee8d4 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 6m18s
2025-12-04 08:51:59 +01:00
mats 7606c1f9df update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-12-03 23:12:40 +01:00
mats 0e1f9b0d3c update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-12-03 23:04:37 +01:00
mats 95d8e0ffb5 add adyen part sessions
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-03 22:56:30 +01:00
mats c61fe1bb7b update payment on completed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-12-03 22:38:14 +01:00
mats 3af837d824 load prev state
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-03 22:30:13 +01:00
mats 401286c56a update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 22:06:24 +01:00
mats bb4ed5eb23 fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-03 21:05:49 +01:00
mats f828f4809b update stuff
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m15s
2025-12-03 20:54:32 +01:00
mats 825630a91a allow some updates
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-12-03 20:19:37 +01:00
mats 78bc05025d update json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-03 20:12:41 +01:00
mats 5ad7131df2 clear cookie if failing
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 19:51:22 +01:00
mats 6321ad76a1 update endpoints
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m47s
2025-12-03 19:29:33 +01:00
mats 94a04a154c update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m33s
2025-12-03 19:17:27 +01:00
mats 86fdfc8532 price marchalling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 19:07:04 +01:00
mats c57643ab63 bajs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 19:01:57 +01:00
mats 9fde6d6bd7 by idstring
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-12-03 18:54:11 +01:00
mats 1cca11de35 update internal url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m6s
2025-12-03 18:48:07 +01:00
mats 1b4e769573 dont reserv other than ps5
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 18:46:47 +01:00
mats 8f2a354f9d update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-12-03 18:27:37 +01:00
mats 4940b61ee0 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-03 18:14:04 +01:00
mats ad24d503ba update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 17:45:38 +01:00
mats ca7d23f122 crash if not correct type
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m52s
2025-12-03 10:22:41 +01:00
mats c7049793e2 use correct image
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m4s
2025-12-03 10:02:23 +01:00
mats dbdd759e16 fix docker and deployment
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-12-03 09:54:09 +01:00
matsandmats ee5f54f0dd refactor/checkout (#8)
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m40s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: #8
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-12-03 09:45:48 +01:00
mats ebd1508294 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-12-02 09:26:30 +01:00
mats d8791788fc update discovery
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-12-02 09:08:36 +01:00
mats ee1b96fece slask
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-02 09:04:42 +01:00
mats 060b3dfbf0 more changes
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m59s
2025-12-01 20:15:48 +01:00
mats c227870f13 add more payment related shit
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-12-01 18:35:32 +01:00
mats d23bfe62a1 try to update inventory on spawn
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-01 13:47:54 +01:00
mats accdc41064 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-12-01 10:18:22 +01:00
mats e419cde351 less pooltime
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-01 09:38:29 +01:00
mats f0c487b5e1 test a version
Build and Publish / BuildAndDeployArm64 (push) Has started running
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
2025-12-01 09:36:59 +01:00
mats 84268eede4 update url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-11-29 16:10:31 +01:00
mats aaa730e833 change return url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-11-29 16:01:55 +01:00
mats c11161b0ca add result endpoint
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-29 15:43:51 +01:00
mats cdb678f018 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-11-29 14:43:34 +01:00
mats c9695d338d Merge pull request 'feature/pubsub' (#7) from feature/pubsub into main
Build and Publish / BuildAndDeployArm64 (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 12s
Reviewed-on: #7
2025-11-28 17:45:12 +01:00
mats e1072c5ba1 cart items
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-28 17:01:32 +01:00
mats aea2adcf92 incorrect reference
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-11-28 16:48:21 +01:00
mats 90a525bd98 capture and split notification if it has multiple hosts
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 16:44:03 +01:00
mats 330093bdec more
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-11-28 14:33:26 +01:00
mats 47e69f18a5 add function to apply mutations over grpc
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-28 14:07:43 +01:00
mats 2c0f6c160a update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-11-28 08:11:18 +01:00
mats 23160479b3 add more
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:10:08 +01:00
mats d8db3c9289 builder
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:08:11 +01:00
mats 43b7e749c4 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-28 08:01:55 +01:00
mats c3d8773c2e add adyen
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m43s
2025-11-28 00:34:00 +01:00
mats c2873ebfc0 fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-27 20:28:10 +01:00
mats db61413988 update inventory listener
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m31s
2025-11-27 16:01:04 +01:00
mats 65dc8edf48 fix mutation context
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-27 15:58:31 +01:00
mats fd29300314 try to reserve when entering checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 23s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2m54s
2025-11-27 13:47:48 +01:00
mats aea168160e update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m45s
2025-11-27 12:45:34 +01:00
mats c2a137d8d4 Merge branch 'feature/pubsub' of git-ssh.tornberg.me:mats/go-cart-actor into feature/pubsub 2025-11-27 12:14:11 +01:00
mats 51d7de03d6 update 2025-11-27 12:14:09 +01:00
mats 3912135b35 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 21:43:18 +01:00
mats afa79e7b56 handle limited quantity
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 21:35:40 +01:00
mats a17d5fc570 add the reservationservice
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m56s
2025-11-26 20:42:12 +01:00
mats fac94acfe1 now we should have logs!
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:35:39 +01:00
mats beb03a7cd7 logs in pod
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-26 20:34:11 +01:00
mats 87625ffd5d do reservations
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 20:29:14 +01:00
mats 9db4abdb05 update service
Build and Publish / BuildAndDeployAmd64 (push) Successful in 30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:17:46 +01:00
mats a6f6d3c190 add service to deployment
Build and Publish / BuildAndDeployAmd64 (push) Successful in 32s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 19:42:45 +01:00
mats 400079ec98 add reservations to the model
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m48s
2025-11-26 19:25:20 +01:00
mats 95426acd4a update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 37s
Build and Publish / BuildAndDeployArm64 (push) Failing after 4m7s
2025-11-26 19:19:59 +01:00
mats 7e2726244f update inventory service 2025-11-26 18:54:07 +01:00
mats 59e01e763f remove promotions for now
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-11-25 23:16:54 +01:00
mats d5d2b3e711 redo pubsub
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-11-25 23:06:36 +01:00
mats f640cd7d2c update proto 2025-11-25 22:16:13 +01:00
mats 82650bd3dd start listener
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-25 21:17:25 +01:00
mats 990708597b update subscriptions
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 21:13:44 +01:00
mats e7e572b0f7 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m52s
2025-11-25 20:30:45 +01:00
mats b0907aee41 update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 27s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m10s
2025-11-25 20:27:04 +01:00
mats ff8a332e3b udpate
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m21s
2025-11-25 20:17:02 +01:00
mats 4ca6c4ca23 update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 18s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 20:16:13 +01:00
mats e74f5ae30e add git
Build and Publish / BuildAndDeployAmd64 (push) Failing after 47s
Build and Publish / BuildAndDeployArm64 (push) Failing after 4m51s
2025-11-25 19:46:20 +01:00
mats 8d976dd250 no custom mounts
Build and Publish / BuildAndDeployAmd64 (push) Failing after 6s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
2025-11-25 19:44:47 +01:00
mats fc678797ba update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 7s
Build and Publish / BuildAndDeployArm64 (push) Failing after 9s
2025-11-25 19:41:08 +01:00
mats 374bd4272b update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10s
Build and Publish / BuildAndDeployArm64 (push) Failing after 11s
2025-11-25 19:34:39 +01:00
mats ffd7ace839 update 2025-11-25 18:58:17 +01:00
mats c74968cfce update deps
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-25 18:36:43 +01:00
mats f63ad5bc84 update backoffice to update inventory
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 18:34:03 +01:00
mats 4dcc6fe1bd move fields
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-11-21 17:08:21 +01:00
mats 886e3ce48f add giftcards 2025-11-21 10:14:33 +01:00
mats 896bf7672b more updates
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-11-20 22:41:00 +01:00
mats c859796b1e update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-20 22:37:01 +01:00
mats a01feb615b Update openapi.json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-11-20 22:16:09 +01:00
mats f475b56659 more correct openapi json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m48s
2025-11-20 22:07:34 +01:00
mats 430da09264 update openapi
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-11-20 21:37:42 +01:00
mats 729c67f992 more functions 2025-11-20 21:34:39 +01:00
mats 60cd6cfd51 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m3s
2025-11-20 21:20:35 +01:00
mats 1c8e9cc974 update the code
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-11-20 15:27:40 +01:00
mats 874963812f more update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m27s
2025-11-19 18:50:31 +01:00
mats 67f63244bb update registry url, and cart url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-11-19 18:49:01 +01:00
mats 270539a2fe Delete cart 2025-11-19 08:30:50 +01:00
mats dd5de0b1a1 tidy
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m29s
2025-11-19 08:27:30 +01:00
mats 3e78dd8b00 update stockhandling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-19 08:25:32 +01:00
mats 13dcb1ec45 Update mutation_upsert_subscriptiondetails.go
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-19 08:11:49 +01:00
mats 4fa78e786f allow own ids 2025-11-19 08:10:28 +01:00
mats c917bc62fb revert
Build and Publish / BuildAndDeployArm64 (push) Failing after 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 13s
2025-11-19 07:59:06 +01:00
mats ac8b870eb5 update more
Build and Publish / BuildAndDeployArm64 (push) Failing after 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
2025-11-19 07:46:07 +01:00
mats cdece4e0d2 Update build.yaml
Build and Publish / BuildAndDeployArm64 (push) Failing after 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
2025-11-18 23:19:26 +01:00
mats c9d9d62bfb rollout
Build and Publish / BuildAndDeployArm64 (push) Failing after 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
2025-11-18 23:18:27 +01:00
mats e662192b56 better pbany parsing
Build and Publish / BuildAndDeployArm64 (push) Failing after 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
2025-11-18 23:07:28 +01:00
mats a7aab5161b ai pubsub
Build and Publish / BuildAndDeployArm64 (push) Failing after 16s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
2025-11-18 22:48:18 +01:00
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
matsandmats f5014fe906 Complete refactor to new grpc control plane and only http proxy for carts (#4)
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 22:31:12 +02:00
145 changed files with 20947 additions and 6055 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"rules": [
{
"description": "Project Overview",
"rule": "This is a distributed cart management system in Go using the actor model. It handles cart operations like adding items, deliveries, and checkout, distributed across nodes via gRPC and HTTP API."
},
{
"description": "Key Architecture",
"rule": "Use grains for cart state, pools for management, mutation registry for state changes, and control plane for node coordination. Ownership via consistent hashing ring."
},
{
"description": "Coding Standards",
"rule": "Follow Go conventions. Use mutation registry for all state changes. Regenerate protobuf code after proto changes; never edit .pb.go files. Handle errors properly, use cookies for cart IDs in HTTP."
},
{
"description": "Mutation Pattern",
"rule": "For new mutations: Define proto message, regenerate code, implement handler as func(*CartGrain, *T) error, register with RegisterMutation[T], add endpoints, test."
},
{
"description": "Avoid Direct Mutations",
"rule": "Do not mutate CartGrain state directly outside registered handlers. Use the registry for consistency."
},
{
"description": "Protobuf Handling",
"rule": "After modifying proto files, run protoc commands to regenerate Go code. Ensure protoc-gen-go and protoc-gen-go-grpc are installed."
},
{
"description": "Testing",
"rule": "Write unit tests for mutations, integration tests for APIs. Run go test ./... regularly."
},
{
"description": "Testability and Configurability",
"rule": "Design code to be testable and configurable, following examples like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing."
},
{
"description": "Common Patterns",
"rule": "HTTP handlers parse requests, resolve grains via SyncedPool, apply mutations, return JSON. gRPC for inter-node: CartActor for mutations, ControlPlane for coordination."
}
]
}
+68
View File
@@ -0,0 +1,68 @@
# .dockerignore for go-cart-actor
#
# Goal: Keep Docker build context lean & reproducible.
# Adjust as project structure evolves.
# Version control & CI metadata
.git
.git/
.gitignore
.github
# Local tooling / editors
.vscode
.idea
*.iml
# Build artifacts / outputs
bin/
build/
dist/
out/
coverage/
*.coverprofile
# Temporary files
*.tmp
*.log
tmp/
.tmp/
# Dependency/vendor caches (not used; rely on go modules download)
vendor/
# Examples / scripts (adjust if you actually need them in build context)
examples/
scripts/
# Docs (retain README.md explicitly)
docs/
CHANGELOG*
**/*.md
!README.md
# Tests (not needed for production build)
**/*_test.go
# Node / frontend artifacts (if any future addition)
node_modules/
# Docker / container metadata not needed inside image
Dockerfile
# Editor swap/backup files
*~
*.swp
# Go race / profiling outputs
*.pprof
# Security / secret placeholders (ensure real secrets never copied)
*.secret
*.key
*.pem
# Keep proto and generated code (do NOT ignore proto/)
!proto/
# End of file
+37 -20
View File
@@ -1,30 +1,47 @@
name: Build and Publish
run-name: ${{ gitea.actor }} is building 🚀
on: [push]
run-name: ${{ gitea.actor }} build 🚀
on:
push:
branches:
- main
jobs:
BuildAndDeployAmd64:
runs-on: amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build docker image
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
- name: Push to registry
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
- name: Deploy to Kubernetes
- uses: actions/checkout@v5
- name: Build amd64 image
run: |
docker build \
--progress=plain \
-t registry.k6n.net/go-cart-actor-amd64:latest \
.
- name: Push amd64 images
run: |
docker push registry.k6n.net/go-cart-actor-amd64:latest
- name: Apply deployment manifests
run: kubectl apply -f deployment/deployment.yaml -n cart
- name: Rollout amd64 deployment
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
- name: Rollout amd64 backoffice deployment
run: |
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
kubectl rollout restart deployment/cart-actor-x86 -n cart
kubectl rollout restart deployment/checkout-actor-x86 -n cart
BuildAndDeploy:
BuildAndDeployArm64:
runs-on: arm64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build docker image
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
- name: Push to registry
run: docker push registry.knatofs.se/go-cart-actor
- name: Rollout arm64 deployment
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
- uses: actions/checkout@v4
- name: Build arm64 image
run: |
docker build \
--progress=plain \
-t registry.k6n.net/go-cart-actor:latest \
.
- name: Push arm64 images
run: |
docker push registry.k6n.net/go-cart-actor:latest
- name: Rollout arm64 deployment (pin to version)
run: |
kubectl rollout status deployment/cart-actor-arm64 -n cart
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
+50
View File
@@ -0,0 +1,50 @@
# GitHub Copilot Instructions for Go Cart Actor
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
## Project Structure
- `cmd/`: Entry points for the application.
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
- `proto/`: Protocol Buffer definitions for messages and services.
- `api-tests/`: Tests for the HTTP API.
- `deployment/`: Deployment configurations.
- `k6/`: Load testing scripts.
## Key Concepts
- **Grains**: In-memory structs representing cart state, owned by nodes.
- **Pools**: Local and synced pools manage grains and handle ownership.
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
## Coding Guidelines
- Follow standard Go conventions (gofmt, go vet, golint).
- Never say "your right", just be correct and clear.
- Don't edit the *.gb.go files manually, they are generated by the proto files.
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
- Use meaningful variable names and add comments for complex logic.
- Handle errors explicitly; use Go's error handling patterns.
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
- Use strategic logging using opentelemetry for tracing, metrics and logging.
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
## Common Patterns
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
## Avoid
- Direct state mutations outside the registry.
- Hardcoding values; use configuration or constants.
- Ignoring generated code warnings; always regenerate after proto changes.
- Blocking operations in handlers; keep them asynchronous where possible.
## Testing
- Write unit tests for mutations and handlers.
- Use integration tests for API endpoints.
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
- Run `go test ./...` to ensure all tests pass.
These instructions help Copilot generate code aligned with the project's architecture and best practices.
+1
View File
@@ -2,3 +2,4 @@ __debug*
go-cart-actor
data/*.prot
data/*.go*
data/se/*
+165
View File
@@ -0,0 +1,165 @@
go-cart-actor/CGM_LINE_ITEMS_SPEC.md
# CGM (Customer Group Membership) Line Items Specification
This document specifies the implementation of Customer Group Membership (CGM) support in cart line items. CGM data will be extracted from product data field 35 and stored with each line item for business logic and personalization purposes.
## Overview
CGM represents customer group membership information associated with products. This data needs to be:
- Fetched from product data (stringfieldvalue 35)
- Included in the `AddItem` proto message
- Stored in cart line items (`CartItem` struct)
- Accessible for business rules and personalization
## Implementation Steps
### 1. Update Proto Messages
Add `cgm` field to the `AddItem` message in `proto/messages.proto`:
```protobuf
message AddItem {
// ... existing fields ...
string cgm = 25; // Customer Group Membership from field 35
// ... existing fields ...
}
```
**Note**: Use field number 25 (next available after existing fields).
### 2. Update Product Fetcher
Modify `cmd/cart/product-fetcher.go` to extract CGM from field 35:
```go
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
// ... existing code ...
cgm, _ := item.GetStringFieldValue(35) // Extract CGM from field 35
return &messages.AddItem{
// ... existing fields ...
Cgm: cgm, // Add CGM field
// ... existing fields ...
}, nil
}
```
### 3. Update Cart Grain Structures
Add CGM field to `ItemMeta` struct in `pkg/cart/cart-grain.go`:
```go
type ItemMeta struct {
Name string `json:"name"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
// ... existing fields ...
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
// ... existing fields ...
}
```
### 4. Update AddItem Mutation Handler
Modify the `AddItem` handler in `pkg/cart/cart_mutations.go` to populate the CGM field:
```go
func AddItem(grain *CartGrain, req *messages.AddItem) error {
// ... existing validation ...
item := &CartItem{
// ... existing fields ...
Meta: &ItemMeta{
Name: req.Name,
Brand: req.Brand,
// ... existing meta fields ...
Cgm: req.Cgm, // Add CGM to item meta
// ... existing meta fields ...
},
// ... existing fields ...
}
// ... rest of handler ...
}
```
### 5. Regenerate Proto Code
After updating `proto/messages.proto`, regenerate Go code:
```bash
cd proto
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
messages.proto cart_actor.proto control_plane.proto
```
### 6. Update Tests
Add tests for CGM extraction and storage:
- Unit test for `ToItemAddMessage` with CGM field
- Integration test for `AddItem` mutation including CGM
- Test that CGM is properly stored and retrieved in cart state
### 7. Update API Documentation
Update README.md and API examples to mention CGM field in line items.
## Data Flow
1. **Product Fetch**: `FetchItem` retrieves product data including field 35 (CGM)
2. **Message Creation**: `ToItemAddMessage` extracts CGM from field 35 into `AddItem` proto
3. **Mutation Processing**: `AddItem` handler stores CGM in `CartItem.Meta.Cgm`
4. **State Persistence**: CGM is included in cart JSON serialization
5. **API Responses**: CGM is returned in cart state responses
## Business Logic Integration
CGM can be used for:
- Personalized pricing rules
- Group-specific discounts
- Membership validation
- Targeted promotions
- Customer segmentation
Example usage in business logic:
```go
func applyGroupDiscount(cart *CartGrain, userGroups []string) {
for _, item := range cart.Items {
if item.Meta != nil && slices.Contains(userGroups, item.Meta.Cgm) {
// Apply group-specific discount
item.Price = applyDiscount(item.Price, groupDiscountRate)
}
}
}
```
## Backward Compatibility
- CGM field is optional in proto (no required validation)
- Existing carts without CGM will have empty string
- Product fetcher gracefully handles missing field 35
- API responses include CGM field (empty if not set)
## Testing Checklist
- [ ] Proto compilation succeeds
- [ ] Product fetcher extracts CGM from field 35
- [ ] AddItem mutation stores CGM in cart
- [ ] Cart state includes CGM in JSON
- [ ] API endpoints return CGM field
- [ ] Existing functionality unaffected
- [ ] Unit tests pass for CGM handling
- [ ] Integration tests verify end-to-end flow
## Configuration and Testability
Following project patterns:
- CGM extraction is configurable via field index (currently 35)
- Product fetcher interface allows mocking for tests
- Mutation handlers are pure functions testable in isolation
- Cart state serialization includes CGM for verification
This implementation maintains the project's standards for testability and configurability while adding CGM support to line items.
+87 -8
View File
@@ -1,17 +1,96 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
#
# Multi-stage build:
# 1. Build static binary with pinned Go version (matching go.mod).
# 2. Copy into distroless static nonroot runtime image.
#
# Build args (optional):
# VERSION - semantic/app version (default: dev)
# GIT_COMMIT - git SHA (default: unknown)
# BUILD_DATE - RFC3339 build timestamp
#
# Example build:
# docker build \
# --build-arg VERSION=$(git describe --tags --always) \
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
# -t go-cart-actor:dev .
#
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
# Ensure a .dockerignore exists to keep context lean.
FROM golang:alpine AS build-stage
WORKDIR /app
############################
# Build Stage
############################
FROM golang:1.25-alpine AS build
WORKDIR /src
RUN apk add --no-cache git
# Build metadata (can be overridden at build time)
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
# Ensure reproducible static build
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
# Dependency caching
COPY go.mod go.sum ./
RUN go mod download
COPY proto ./proto
COPY *.go ./
# Copy full source (relay on .dockerignore to prune)
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
# protoc --go_out=. --go_opt=paths=source_relative \
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
# proto/*.proto
FROM gcr.io/distroless/base-debian11
# Build with minimal binary size and embedded metadata
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor ./cmd/cart
RUN 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 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
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout
############################
# Runtime Stage
############################
# Using distroless static (nonroot) for minimal surface area.
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
WORKDIR /
COPY --from=build-stage /go-cart-actor /go-cart-actor
COPY --from=build /out/go-cart-actor /go-cart-actor
COPY --from=build /out/go-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
COPY --from=build /out/go-cart-inventory /go-cart-inventory
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337
USER nonroot:nonroot
ENTRYPOINT ["/go-cart-actor"]
+143
View File
@@ -0,0 +1,143 @@
# ------------------------------------------------------------------------------
# Makefile for go-cart-actor
#
# Key targets:
# make protogen - Generate protobuf + gRPC code into proto/
# make clean_proto - Remove generated proto *.pb.go files
# make verify_proto - Ensure no stray root-level *.pb.go files exist
# make build - Build the project
# make test - Run tests (verbose)
# make tidy - Run go mod tidy
# make regen - Clean proto, regenerate, tidy, verify, build
# make help - Show this help
#
# Conventions:
# - All .proto files live in $(PROTO_DIR)
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
# - go_package is set to: git.k6n.net/go-cart-actor/proto;messages
# ------------------------------------------------------------------------------
MODULE_PATH := git.k6n.net/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
# Tools (auto-detect; can override)
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
GO ?= go
# Colors (optional)
GREEN := \033[32m
RED := \033[31m
YELLOW := \033[33m
RESET := \033[0m
# ------------------------------------------------------------------------------
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
help:
@echo "Available targets:"
@echo " protogen Generate protobuf & gRPC code"
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
@echo " tidy Run go mod tidy"
@echo " build Build the module"
@echo " test Run tests (verbose)"
@echo " regen Clean proto, regenerate, tidy, verify, and build"
@echo " check_tools Verify protoc + plugins are installed"
check_tools:
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
echo "Install with:"; \
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
exit 1; \
fi
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
exit 1; \
fi
@echo "$(GREEN)All required tools detected.$(RESET)"
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/cart --go_opt=paths=source_relative \
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/cart.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/control --go_opt=paths=source_relative \
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/control_plane.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
@echo "$(GREEN)Clean complete.$(RESET)"
verify_proto:
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
@if ls *.pb.go >/dev/null 2>&1; then \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
ls -1 *.pb.go; \
exit 1; \
fi
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
tidy:
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
$(GO) mod tidy
@echo "$(GREEN)tidy complete.$(RESET)"
build:
@echo "$(YELLOW)Building...$(RESET)"
$(GO) build ./...
@echo "$(GREEN)Build success.$(RESET)"
test:
@echo "$(YELLOW)Running tests...$(RESET)"
$(GO) test -v ./...
@echo "$(GREEN)Tests completed.$(RESET)"
regen: clean_proto protogen tidy verify_proto build
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
# Utility: show proto sources and generated outputs
print_proto:
@echo "Proto sources:"
@ls -1 $(PROTOS)
@echo ""
@echo "Generated files (if any):"
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
# Prevent make from treating these as file targets if similarly named files appear.
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto
+238
View File
@@ -0,0 +1,238 @@
go-cart-actor/NEW_MUTATIONS_SPEC.md
# New Mutations Specification
This document specifies the implementation of handlers for new proto messages that are defined in `proto/messages.proto` but not yet registered in the mutation registry. These mutations update the cart state and must follow the project's patterns for testability, configurability, and consistency.
## Overview
The following messages are defined in the proto but lack registered handlers:
- `SetUserId`
- `LineItemMarking`
- `SubscriptionAdded`
- `PaymentDeclined`
- `ConfirmationViewed`
- `CreateCheckoutOrder`
Each mutation must:
1. Define a handler function with signature `func(*CartGrain, *T) error`
2. Be registered in `NewCartMultationRegistry()` using `actor.NewMutation`
3. Include unit tests
4. Optionally add HTTP/gRPC endpoints if client-invokable
5. Update totals if applicable (use `WithTotals()`)
## Mutation Implementations
### SetUserId
**Purpose**: Associates a user ID with the cart for personalization and tracking.
**Handler Implementation**:
```go
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
if req.UserId == "" {
return errors.New("user ID cannot be empty")
}
grain.UserId = req.UserId
return nil
}
```
**Registration**:
```go
actor.NewMutation(SetUserId, func() *messages.SetUserId {
return &messages.SetUserId{}
}),
```
**Notes**: This is a simple state update. No totals recalculation needed.
### LineItemMarking
**Purpose**: Adds or updates a marking (e.g., gift message, special instructions) on a specific line item.
**Handler Implementation**:
```go
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = &Marking{
Type: req.Type,
Text: req.Marking,
}
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
```
**Registration**:
```go
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
return &messages.LineItemMarking{}
}),
```
**Notes**: Assumes `CartGrain.Items` has a `Marking` field (single marking per item). If not, add it to the grain struct.
### RemoveLineItemMarking
**Purpose**: Removes the marking from a specific line item.
**Handler Implementation**:
```go
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
```
**Registration**:
```go
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{}
}),
```
**Notes**: Sets the marking to nil for the specified item.
### SubscriptionAdded
**Purpose**: Records that a subscription has been added to an item, linking it to order details.
**Handler Implementation**:
```go
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
for i, item := range grain.Items {
if item.Id == req.ItemId {
grain.Items[i].SubscriptionDetailsId = req.DetailsId
grain.Items[i].OrderReference = req.OrderReference
grain.Items[i].IsSubscribed = true
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.ItemId)
}
```
**Registration**:
```go
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{}
}),
```
**Notes**: Assumes fields like `SubscriptionDetailsId`, `OrderReference`, `IsSubscribed` exist on items. Add to grain if needed.
### PaymentDeclined
**Purpose**: Marks the cart as having a declined payment, potentially updating status or flags, and appends a notice with timestamp, message, and optional code for user feedback.
**Handler Implementation**:
```go
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
grain.PaymentStatus = "declined"
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
Timestamp: time.Now(),
Message: req.Message,
Code: req.Code,
})
// Optionally clear checkout order if in progress
if grain.CheckoutOrderId != "" {
grain.CheckoutOrderId = ""
}
return nil
}
```
**Registration**:
```go
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
return &messages.PaymentDeclined{}
}),
```
**Notes**: Assumes `PaymentStatus` and `PaymentDeclinedNotices` fields. Add status tracking and error notices list to grain. Notice struct has Timestamp, Message, and optional Code.
### ConfirmationViewed
**Purpose**: Records that the order confirmation has been viewed by the user. Applied automatically when the confirmation page is loaded in checkout_server.go.
**Handler Implementation**:
```go
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
grain.ConfirmationViewCount++
grain.ConfirmationLastViewedAt = time.Now()
return nil
}
```
**Registration**:
```go
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}),
```
**Notes**: Increments the view count and updates the last viewed timestamp each time. Assumes `ConfirmationViewCount` and `ConfirmationLastViewedAt` fields.
### CreateCheckoutOrder
**Purpose**: Initiates the checkout process, validating terms and creating an order reference.
**Handler Implementation**:
```go
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
if len(grain.Items) == 0 {
return errors.New("cannot checkout empty cart")
}
if req.Terms != "accepted" {
return errors.New("terms must be accepted")
}
// Validate other fields as needed
grain.CheckoutOrderId = generateOrderId()
grain.CheckoutStatus = "pending"
grain.CheckoutCountry = req.Country
return nil
}
```
**Registration**:
```go
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{}
}).WithTotals(),
```
**Notes**: Use `WithTotals()` to recalculate totals after checkout initiation. Assumes order ID generation function.
## Implementation Steps
For each mutation:
1. **Add Handler Function**: Implement in `pkg/cart/` (e.g., `cart_mutations.go`).
2. **Register in Registry**: Add to `NewCartMultationRegistry()` in `cart-mutation-helper.go`.
3. **Regenerate Proto**: Run `protoc` commands after any proto changes.
4. **Add Tests**: Create unit tests in `pkg/cart/` testing the handler logic.
5. **Add Endpoints** (if needed): For client-invokable mutations, add HTTP handlers in `cmd/cart/pool-server.go`. ConfirmationViewed is handled in `cmd/cart/checkout_server.go` when the confirmation page is viewed.
6. **Update Grain Struct**: Add any new fields to `CartGrain` in `pkg/cart/grain.go`.
7. **Run Tests**: Ensure `go test ./...` passes.
## Testing Guidelines
- Mock dependencies using interfaces.
- Test error cases (e.g., invalid IDs, empty carts).
- Verify state changes and totals recalculation.
- Use table-driven tests for multiple scenarios.
## Configuration and Testability
Follow `MutationRegistry` and `SimpleGrainPool` patterns:
- Use interfaces for external dependencies (e.g., ID generators).
- Inject configurations via constructor parameters.
- Avoid global state; make handlers pure functions where possible.
+337 -4
View File
@@ -6,7 +6,7 @@ A distributed cart management system using the actor model pattern.
- Go 1.24.2+
- Protocol Buffers compiler (`protoc`)
- protoc-gen-go plugin
- protoc-gen-go and protoc-gen-go-grpc plugins
### Installing Protocol Buffers
@@ -32,17 +32,20 @@ sudo apt install protobuf-compiler
```bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
```
## Working with Protocol Buffers
### Generating Go code from proto files
After modifying `proto/messages.proto`, regenerate the Go code:
After modifying any proto (`proto/messages.proto`, `proto/cart_actor.proto`, `proto/control_plane.proto`), regenerate the Go code (all three share the unified `messages` package):
```bash
cd proto
protoc --go_out=. --go_opt=paths=source_relative messages.proto
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
messages.proto cart_actor.proto control_plane.proto
```
### Protocol Buffer Messages
@@ -73,8 +76,338 @@ go build .
go test ./...
```
## HTTP API Quick Start (curl Examples)
Assuming the service is reachable at http://localhost:8080 and the cart API is mounted at /cart.
Most endpoints use an HTTP cookie named `cartid` to track the cart. The first request will set it.
### 1. Get (or create) a cart
```bash
curl -i http://localhost:8080/cart/
```
Response sets a `cartid` cookie and returns the current (possibly empty) cart JSON.
### 2. Add an item by SKU (implicit quantity = 1)
```bash
curl -i --cookie-jar cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
```
Stores cookie in `cookies.txt` for subsequent calls.
### 3. Add an item with explicit payload (country, quantity)
```bash
curl -i --cookie cookies.txt \
-H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-456","quantity":2,"country":"se"}' \
http://localhost:8080/cart/
```
### 4. Change quantity of an existing line
(First list the cart to find `id` of the line; here we use id=1 as an example)
```bash
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":1,"quantity":3}' \
http://localhost:8080/cart/
```
### 5. Remove an item
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/1
```
### 6. Set entire cart contents (overwrites items)
```bash
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"items":[{"sku":"TEST-SKU-AAA","quantity":1,"country":"se"},{"sku":"TEST-SKU-BBB","quantity":2,"country":"se"}]}' \
http://localhost:8080/cart/set
```
### 7. Add a delivery (provider + optional items)
If `items` is empty or omitted, all items without a delivery get this one.
```bash
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"provider":"standard","items":[1,2]}' \
http://localhost:8080/cart/delivery
```
### 8. Remove a delivery by deliveryId
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/delivery/1
```
### 9. Set a pickup point for a delivery
```bash
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":"PUP123","name":"Locker 5","address":"Main St 1","city":"Stockholm","zip":"11122","country":"SE"}' \
http://localhost:8080/cart/delivery/1/pickupPoint
```
### 10. Checkout (returns HTML snippet from Klarna)
```bash
curl -i --cookie cookies.txt http://localhost:8080/cart/checkout
```
### 11. Using a known cart id directly (bypassing cookie)
If you already have a cart id (e.g. 1720000000000000):
```bash
CART_ID=1720000000000000
curl -i http://localhost:8080/cart/byid/$CART_ID
curl -i -X POST -H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-XYZ","quantity":1,"country":"se"}' \
http://localhost:8080/cart/byid/$CART_ID
```
### 12. Clear cart cookie (forces a new cart on next request)
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/
```
Tip: Use `--cookie-jar` and `--cookie` to persist the session across multiple commands:
```bash
curl --cookie-jar cookies.txt http://localhost:8080/cart/
curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
```
## Important Notes
- Always regenerate protobuf Go code after modifying `.proto` files
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
- The generated `messages.pb.go` file should not be edited manually
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
---
## Architecture Overview
The system is a distributed, sharded (by cart id) actor model implementation:
- Each cart is a grain (an inmemory struct `*CartGrain`) that owns and mutates its own state.
- A **local grain pool** holds grains owned by the node.
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
- All internode communication is gRPC:
- Cart mutation & state RPCs (CartActor service).
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
### Key Processes
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
2. The pool resolves ownership:
- If local grain exists → use it.
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
---
## Grain & Mutation Model
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
- All mutations are registered via `RegisterMutation[T]` with signature:
```
func(*CartGrain, *T) error
```
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
- Adding a mutation:
1. Define proto message.
2. Generate code.
3. Register handler (optionally WithTotals).
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
5. (Optional) Add HTTP endpoint mapping to the mutation.
---
## Local Grain Pool
- Manages an inmemory map `map[CartId]*CartGrain`.
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
---
## Synced (Cluster) Pool
`SyncedPool` wraps a local pool and tracks:
- `remoteHosts`: known peer nodes (gRPC connections).
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
Responsibilities:
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
2. Periodic ping health checks (ControlPlane.Ping).
3. Ring-based deterministic ownership:
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
4. Remote spawning:
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
---
## Remote Grain Proxies
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
- `Apply` → Specific CartActor permutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
- `GetCurrentState` → `CartActor.GetState`.
Return path:
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
---
## Control Plane (InterNode Coordination)
Defined in `proto/control_plane.proto`:
| RPC | Purpose |
|-----|---------|
| `Ping` | Liveness; increments missed ping counter if failing. |
| `Negotiate` | Merges membership views; used after discovery events. |
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
### Ownership / Quorum Rules
- If total participating hosts < 3 → all must accept.
- Otherwise majority acceptance (`ok >= total/2`).
- On failure → local tentative grain is removed (rollback to avoid splitbrain).
---
## Request / Mutation Flow Examples
### Local Mutation
1. HTTP handler parses request → determines cart id.
2. `SyncedPool.Apply`:
- Finds local grain (or spawns new after quorum).
- Executes registry mutation.
3. Totals updated if flagged.
4. HTTP response returns updated JSON (via `ToCartState`).
### Remote Mutation
1. `SyncedPool.Apply` sees cart mapped to a remote host.
2. Routes to `RemoteGrainGRPC.Apply`.
3. Remote node executes mutation locally and returns updated state over gRPC.
4. Proxy materializes snapshot locally (not authoritative, readonly view).
### Checkout (SideEffecting, Non-Pure)
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
- Calls Klarna externally (not a mutation).
- Applies `InitializeCheckout` mutation to persist reference + status.
- Returns Klarna order JSON to client.
---
## Scaling & Deployment
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
### Adding Nodes
1. Node starts gRPC server (CartActor + ControlPlane).
2. After brief delay, begins discovery watch; on event:
- New host → dial + negotiate → seed remote cart ids.
3. Pings maintain health; failed hosts removed (proxies invalidated).
---
## Failure Handling
| Scenario | Behavior |
|----------|----------|
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
| Ownership negotiation fails | Tentative local grain discarded. |
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
| Missing mutation registration | Fast failure with explicit error message. |
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
---
## Mutation Registry Summary
- Central, type-safe registry prevents silent omission.
- Each handler:
- Validates input.
- Mutates `*CartGrain`.
- Returns error for rejection.
- Automatic totals recomputation reduces boilerplate and consistency risk.
- Coverage test (add separately) can enforce all proto mutations are registered.
---
## gRPC Interfaces
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
**Ports** (default / implied):
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
- Legacy frame/TCP code has been removed.
---
## Security & Future Enhancements
| Area | Potential Improvement |
|------|------------------------|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
| Backpressure | Rate-limit remote mutation calls per host. |
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
---
## Adding a New Node (Operational Checklist)
1. Deploy binary/container with same proto + registry.
2. Expose gRPC port.
3. Ensure discovery lists the new host.
4. Node dials peers, negotiates membership.
5. Remote cart proxies seeded.
6. Traffic routed automatically based on ownership.
---
## Adding a New Mutation (Checklist Recap)
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
2. Regenerate protobuf code.
3. Implement & register handler (`RegisterMutation`).
4. Add client (HTTP/gRPC) endpoint.
5. Write unit + integration tests.
6. (Optional) Add to coverage test list and docs.
---
## High-Level Data Flow Diagram (Text)
```
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
```
---
## Troubleshooting
| Symptom | Likely Cause | Action |
|---------|--------------|--------|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
---
+245
View File
@@ -0,0 +1,245 @@
# TODO / Roadmap
A living roadmap for improving the cart actor system. Focus areas:
1. Reliability & correctness
2. Simplicity of mutation & ownership flows
3. Developer experience (DX)
4. Operability (observability, tracing, metrics)
5. Performance & scalability
6. Security & multi-tenant readiness
---
## 1. Immediate Next Steps (High-Leverage)
| Priority | Task | Goal | Effort | Owner | Notes |
|----------|------|------|--------|-------|-------|
| P0 | Add mutation registry coverage test | Ensure no unregistered mutations silently fail | S | | Failing fast in CI |
| P0 | Add decodeJSON helper + 400 mapping for EOF | Reduce noisy 500 logs | S | | Improves client API clarity |
| P0 | Regenerate protos & prune unused messages (CreateCheckoutOrder, Checkout RPC remnants) | Eliminate dead types | S | | Avoid confusion |
| P0 | Add integration test: multi-node ownership negotiation | Validate quorum logic | M | | Spin up 23 nodes ephemeral |
| P1 | Export Prometheus metrics for per-mutation counts & latency | Operability | M | | Wrap registry handlers |
| P1 | Add graceful shutdown ordering (Closing → wait for acks → stop gRPC) | Reduce in-flight mutation failures | S | | Add context cancellation |
| P1 | Add coverage for InitializeCheckout / OrderCreated flows | Checkout reliability | S | | Simulate Klarna stub |
| P2 | Add optional batching client (apply multiple mutations locally then persist) | Performance | M | | Only if needed |
---
## 2. Simplification Opportunities
### A. RemoteGrain Proxy Mapping
Current: manual switch building each RPC call.
Simplify by:
- Generating a thin client adapter from proto RPC descriptors (codegen).
- Or using a registry similar to mutation registry but for “outbound call constructors”.
Benefit: adding a new mutation = add proto + register server handler + register outbound invoker (no switch edits).
### B. Ownership Negotiation
Current: ad hoc quorum rule in `SyncedPool`.
Simplify:
- Introduce explicit `OwnershipLease{holder, expiresAt, version}`.
- Use monotonic version increment—reject stale ConfirmOwner replies.
- Optional: add randomized backoff to reduce thundering herd on contested cart ids.
### C. CartId Handling
Current: ephemeral 16-byte array with trimmed string semantics.
Simplify:
- Use ULID / UUIDv7 (time-ordered, collision-resistant) for easier external correlation.
- Provide helper `NewCartIdString()` and keep internal fixed-size if still desired.
### D. Mutation Signatures
Current: registry assumes `func(*CartGrain, *T) error`.
Extension option: allow pure transforms returning a delta struct (for audit/logging):
```
type MutationResult struct {
Changed bool
Events []interface{}
}
```
Only implement if auditing/event-sourcing reintroduced.
---
## 3. Developer Experience Improvements
| Task | Rationale | Approach |
|------|-----------|----------|
| Makefile targets: `make run-single`, `make run-multi N=3` | Faster local cluster spin-up | Docker compose or background “mini cluster” scripts |
| Template for new mutation (generator) | Reduce boilerplate | `go:generate` scanning proto for new RPCs |
| Lint config (golangci-lint) | Catch subtle issues early | Add `.golangci.yml` |
| Pre-commit hook for proto regeneration check | Avoid stale generated code | Script compares git diff after `make protogen` |
| Example client (Go + curl snippets auto-generated) | Onboarding | Codegen a markdown from proto comments |
---
## 4. Observability / Metrics / Tracing
| Area | Metric / Trace | Notes |
|------|----------------|-------|
| Mutation registry | `cart_mutations_total{type,success}`; duration histogram | Wrap handler |
| Ownership negotiation | `cart_ownership_attempts_total{result}` | result=accepted,rejected,timeout |
| Remote latency | `cart_remote_mutation_seconds{method}` | Use client interceptors |
| Pings | `cart_remote_missed_pings_total{host}` | Already count, expose |
| Checkout flow | `checkout_attempts_total`, `checkout_failures_total` | Differentiate Klarna vs internal errors |
| Tracing | Span: HTTP handler → SyncedPool.Apply → (Remote?) gRPC → mutation handler | Add OpenTelemetry instrumentation |
---
## 5. Performance & Scalability
| Concern | Idea | Trade-Off |
|---------|------|-----------|
| High mutation rate on single cart | Introduce optional mutation queue (serialize explicitly) | Slight latency increase per op |
| Remote call overhead | Add client-side gRPC pooling & per-host circuit breaker | Complexity vs resilience |
| TTL purge efficiency | Use min-heap or timing wheel instead of slice scan | More code, better big-N performance |
| Batch network latency | Add `BatchMutate` RPC (list of mutations applied atomically) | Lost single-op simplicity |
---
## 6. Reliability Features
| Feature | Description | Priority |
|---------|-------------|----------|
| Lease fencing token | Include `ownership_version` in all remote mutate requests | M |
| Retry policy | Limited retry for transient network errors (idempotent mutations only) | L |
| Dead host reconciliation | On host removal, proactively attempt re-acquire of its carts | M |
| Drain mode | Node marks itself “draining” → refuses new ownership claims | M |
---
## 7. Security & Hardening
| Area | Next Step | Detail |
|------|-----------|--------|
| Transport | mTLS on gRPC | Use SPIFFE IDs or simple CA |
| AuthN/AuthZ | Interceptor enforcing service token | Inject metadata header |
| Input validation | Strengthen JSON decode responses | Disallow unknown fields globally |
| Rate limiting | Per-IP / per-cart throttling | Guard hotspot abuse |
| Multi-tenancy | Tenant id dimension in cart id or metadata | Partition metrics & ownership |
---
## 8. Testing Strategy Enhancements
| Gap | Improvement |
|-----|------------|
| No multi-node integration test in CI | Spin ephemeral in-process servers on randomized ports |
| Mutation regression | Table-driven tests auto-discover handlers via registry |
| Ownership race | Stress test: concurrent Apply on same new cart id from N goroutines |
| Checkout external dependency | Klarna mock server (HTTptest) + deterministic responses |
| Fuzzing | Fuzz `BuildCheckoutOrderPayload` & mutation handlers for panics |
---
## 9. Cleanup / Tech Debt
| Item | Action |
|------|--------|
| Remove deprecated proto remnants (CreateCheckoutOrder, Checkout RPC) | Delete & regenerate |
| Consolidate duplicate tax computations | Single helper with tax config |
| Delivery price hard-coded (4900) | Config or pricing strategy interface |
| Mixed naming (camel vs snake JSON historically) | Provide stable external API doc; accept old forms if needed |
| Manual remote mutation switch (if still present) | Replace with generated outbound registry |
| Mixed error responses (string bodies) | Standardize JSON: `{ "error": "...", "code": 400 }` |
---
## 10. Potential Future Features
| Feature | Value | Complexity |
|---------|-------|------------|
| Streaming `WatchState` RPC | Real-time cart updates for clients | Medium |
| Event sourcing / audit log | Replay, analytics, debugging | High |
| Promotion / coupon engine plugin | Business extensibility | Medium |
| Partial cart reservation / inventory lock | Stock accuracy under concurrency | High |
| Multi-currency pricing | Globalization | Medium |
| GraphQL facade | Client flexibility | Medium |
---
## 11. Suggested Prioritized Backlog (Condensed)
1. Coverage test + decode error mapping (P0)
2. Proto regeneration & cleanup (P0)
3. Metrics wrapper for registry (P1)
4. Multi-node ownership integration test (P1)
5. Delivery pricing abstraction (P2)
6. Lease version in remote RPCs (P2)
7. BatchMutate evaluation (P3)
8. TLS / auth hardening (P3) if going multi-tenant/public
9. Event sourcing (Evaluate after stability) (P4)
---
## 12. Simplifying the Developer Workflow
| Pain | Simplifier |
|------|------------|
| Manual mutation boilerplate | Code generator for registry stubs |
| Forgetting totals | Enforce WithTotals lint: fail if mutation touches items/deliveries without flag |
| Hard to inspect remote ownership | `/internal/ownership` debug endpoint (JSON of local + remoteIndex) |
| Hard to see mutation timings | Add `?debug=latency` header to return per-mutation durations |
| Cookie dev confusion (Secure flag) | Env var: `DEV_INSECURE_COOKIES=1` |
---
## 13. Example: Mutation Codegen Sketch (Future)
Input: cart_actor.proto
Output: `mutation_auto.go`
- Detect messages used in RPC wrappers (e.g., `AddItemRequest` → payload field).
- Generate `RegisterMutation` template if handler not found.
- Mark with `// TODO implement logic`.
---
## 14. Risk / Impact Matrix (Abbreviated)
| Change | Risk | Mitigation |
|--------|------|-----------|
| Replace remote switch with registry | Possible missing registration → runtime error | Coverage test gating CI |
| Lease introduction | Split-brain if version mishandled | Increment + assert monotonic; test race |
| BatchMutate | Large atomic operations starving others | Size limits & fair scheduling |
| Event sourcing | Storage + replay complexity | Start with append-only log + compaction job |
---
## 15. Contributing Workflow (Proposed)
1. Add / modify proto → run `make protogen`
2. Implement mutation logic → add `RegisterMutation` invocation
3. Add/Update tests (unit + integration)
4. Run `make verify` (lint, test, coverage, proto diff)
5. Open PR (template auto-checklist referencing this TODO)
6. Merge requires green CI + coverage threshold
---
## 16. Open Questions
| Question | Notes |
|----------|-------|
| Do we need sticky sessions for HTTP layer scaling? | Currently cart id routing suffices |
| Should deliveries prune invalid line references on SetCartRequest? | Inconsistency risk; add optional cleanup |
| Is checkout idempotency strict enough? | Multiple create vs update semantics |
| Add version field to CartState for optimistic concurrency? | Could enable external CAS writes |
---
## 17. Tracking
Mark any completed tasks with `[x]`:
- [ ] Coverage test
- [ ] Decode helper + 400 mapping
- [ ] Proto cleanup
- [ ] Registry metrics instrumentation
- [ ] Ownership multi-node test
- [ ] Lease versioning
- [ ] Delivery pricing abstraction
- [ ] TLS/mTLS internal
- [ ] BatchMutate design doc
---
_Last updated: roadmap draft refine after first metrics & scaling test run._
-83
View File
@@ -1,83 +0,0 @@
package main
import (
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
type AmqpOrderHandler struct {
Url string
connection *amqp.Connection
//channel *amqp.Channel
}
const (
topic = "order-placed"
)
func (t *AmqpOrderHandler) Connect() error {
conn, err := amqp.DialConfig(t.Url, amqp.Config{
//Vhost: "/",
Properties: amqp.NewConnectionProperties(),
})
if err != nil {
return err
}
t.connection = conn
ch, err := conn.Channel()
if err != nil {
return err
}
defer ch.Close()
if err := ch.ExchangeDeclare(
topic, // name
"topic", // type
true, // durable
false, // auto-delete
false, // internal
false, // noWait
nil, // arguments
); err != nil {
return err
}
if _, err = ch.QueueDeclare(
topic, // name of the queue
true, // durable
false, // delete when unused
false, // exclusive
false, // noWait
nil, // arguments
); err != nil {
return err
}
return nil
}
func (t *AmqpOrderHandler) Close() error {
log.Println("Closing master channel")
return t.connection.Close()
//return t.channel.Close()
}
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error {
ch, err := t.connection.Channel()
if err != nil {
return err
}
defer ch.Close()
return ch.Publish(
topic,
topic,
true,
false,
amqp.Publishing{
ContentType: "application/json",
Body: data,
},
)
}
+6 -8
View File
@@ -1,6 +1,6 @@
### Add item to cart
POST https://cart.tornberg.me/api/12345
POST https://cart.k6n.net/api/12345
Content-Type: application/json
{
@@ -9,7 +9,7 @@ Content-Type: application/json
}
### Update quanity of item in cart
PUT https://cart.tornberg.me/api/12345
PUT https://cart.k6n.net/api/12345
Content-Type: application/json
{
@@ -18,12 +18,12 @@ Content-Type: application/json
}
### Delete item from cart
DELETE https://cart.tornberg.me/api/1002/1
DELETE https://cart.k6n.net/api/1002/1
### Set delivery
POST https://cart.tornberg.me/api/1002/delivery
POST https://cart.k6n.net/api/1002/delivery
Content-Type: application/json
{
@@ -33,10 +33,8 @@ Content-Type: application/json
### Get cart
GET https://cart.tornberg.me/api/12345
GET https://cart.k6n.net/api/12345
### Remove delivery method
DELETE https://cart.tornberg.me/api/12345/delivery/2
DELETE https://cart.k6n.net/api/12345/delivery/2
-608
View File
@@ -1,608 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"slices"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
type CartId [16]byte
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
func (id *CartId) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
copy(id[:], []byte(str))
return nil
}
type StockStatus int
const (
OutOfStock StockStatus = 0
LowStock StockStatus = 1
InStock StockStatus = 2
)
type CartItem struct {
Id int `json:"id"`
ItemId int `json:"itemId,omitempty"`
ParentId int `json:"parentId,omitempty"`
Sku string `json:"sku"`
Name string `json:"name"`
Price int64 `json:"price"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
OrgPrice int64 `json:"orgPrice"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Tax int `json:"tax"`
TaxRate int `json:"taxRate"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
ArticleType string `json:"type,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
StoreId *string `json:"storeId,omitempty"`
}
type CartDelivery struct {
Id int `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []int `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId int
lastDeliveryId int
storageMessages []Message
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
TotalDiscount int64 `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"`
}
type Grain interface {
GetId() CartId
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
GetCurrentState() (*FrameWithPayload, error)
}
func (c *CartGrain) GetId() CartId {
return c.Id
}
func (c *CartGrain) GetLastChange() int64 {
if len(c.storageMessages) == 0 {
return 0
}
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
}
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
result, err := json.Marshal(c)
if err != nil {
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
return &ret, nil
}
ret := MakeFrameWithPayload(0, 200, result)
return &ret, nil
}
func getInt(data interface{}) (int, error) {
switch v := data.(type) {
case float64:
return int(v), nil
case int:
return v, nil
default:
return 0, fmt.Errorf("invalid type")
}
}
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
orgPrice, _ := getInt(item.Fields[5])
price, priceErr := getInt(item.Fields[4])
if priceErr != nil {
return nil, fmt.Errorf("invalid price")
}
stock := InStock
if item.StockLevel == "0" || item.StockLevel == "" {
stock = OutOfStock
} else if item.StockLevel == "5+" {
stock = LowStock
}
articleType, _ := item.Fields[1].(string)
outletGrade, ok := item.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.Fields[24].(string)
sellerName, _ := item.Fields[9].(string)
brand, _ := item.Fields[2].(string)
category, _ := item.Fields[10].(string)
category2, _ := item.Fields[11].(string)
category3, _ := item.Fields[12].(string)
category4, _ := item.Fields[13].(string)
category5, _ := item.Fields[14].(string)
return &messages.AddItem{
ItemId: int64(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: sku,
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: 2500,
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
}, nil
}
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
cartItem, err := getItemData(sku, qty, country)
if err != nil {
return nil, err
}
cartItem.StoreId = storeId
return c.HandleMessage(&Message{
Type: 2,
Content: cartItem,
}, false)
}
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
c.mu.RLock()
defer c.mu.RUnlock()
ret := make([]StorableMessage, 0)
for _, message := range c.storageMessages {
if *message.TimeStamp > since {
ret = append(ret, message)
}
}
return ret
}
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []int {
ret := make([]int, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []int {
ret := make([]int, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := false
for _, id := range hasDelivery {
if item.Id == id {
found = true
break
}
}
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
if message.TimeStamp == nil {
now := time.Now().Unix()
message.TimeStamp = &now
}
grainMutations.Inc()
var err error
switch message.Type {
case SetCartItemsType:
msg, ok := message.Content.(*messages.SetCartRequest)
if !ok {
err = fmt.Errorf("expected SetCartItems")
} else {
c.mu.Lock()
c.Items = make([]*CartItem, 0, len(msg.Items))
c.mu.Unlock()
for _, item := range msg.Items {
c.AddItem(item.Sku, int(item.Quantity), item.Country, item.StoreId)
}
}
case AddRequestType:
msg, ok := message.Content.(*messages.AddRequest)
if !ok {
err = fmt.Errorf("expected AddRequest")
} else {
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
}
}
case AddItemType:
msg, ok := message.Content.(*messages.AddItem)
if !ok {
err = fmt.Errorf("expected AddItem")
} else {
if msg.Quantity < 1 {
return nil, fmt.Errorf("invalid quantity")
}
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
c.mu.Lock()
c.lastItemId++
tax := 2500
if msg.Tax > 0 {
tax = int(msg.Tax)
}
taxAmount := GetTaxAmount(msg.Price, tax)
c.Items = append(c.Items, &CartItem{
Id: c.lastItemId,
ItemId: int(msg.ItemId),
Quantity: int(msg.Quantity),
Sku: msg.Sku,
Name: msg.Name,
Price: msg.Price,
TotalPrice: msg.Price * int64(msg.Quantity),
TotalTax: int64(taxAmount * int64(msg.Quantity)),
Image: msg.Image,
Stock: StockStatus(msg.Stock),
Disclaimer: msg.Disclaimer,
Brand: msg.Brand,
Category: msg.Category,
Category2: msg.Category2,
Category3: msg.Category3,
Category4: msg.Category4,
Category5: msg.Category5,
OrgPrice: msg.OrgPrice,
ArticleType: msg.ArticleType,
Outlet: msg.Outlet,
SellerId: msg.SellerId,
SellerName: msg.SellerName,
Tax: int(taxAmount),
TaxRate: tax,
StoreId: msg.StoreId,
})
c.UpdateTotals()
c.mu.Unlock()
}
}
case ChangeQuantityType:
msg, ok := message.Content.(*messages.ChangeQuantity)
if !ok {
err = fmt.Errorf("expected ChangeQuantity")
} else {
for i, item := range c.Items {
if item.Id == int(msg.Id) {
if msg.Quantity <= 0 {
//c.TotalPrice -= item.Price * int64(item.Quantity)
c.Items = append(c.Items[:i], c.Items[i+1:]...)
} else {
//diff := int(msg.Quantity) - item.Quantity
item.Quantity = int(msg.Quantity)
//c.TotalPrice += item.Price * int64(diff)
}
break
}
}
c.UpdateTotals()
}
case RemoveItemType:
msg, ok := message.Content.(*messages.RemoveItem)
if !ok {
err = fmt.Errorf("expected RemoveItem")
} else {
items := make([]*CartItem, 0, len(c.Items))
for _, item := range c.Items {
if item.Id == int(msg.Id) {
//c.TotalPrice -= item.Price * int64(item.Quantity)
} else {
items = append(items, item)
}
}
c.Items = items
c.UpdateTotals()
}
case SetDeliveryType:
msg, ok := message.Content.(*messages.SetDelivery)
if !ok {
err = fmt.Errorf("expected SetDelivery")
} else {
c.lastDeliveryId++
items := make([]int, 0)
withDelivery := c.ItemsWithDelivery()
if len(msg.Items) == 0 {
items = append(items, c.ItemsWithoutDelivery()...)
} else {
for _, id := range msg.Items {
for _, item := range c.Items {
if item.Id == int(id) {
if slices.Contains(withDelivery, item.Id) {
return nil, fmt.Errorf("item already has delivery")
}
items = append(items, int(item.Id))
break
}
}
}
}
if len(items) > 0 {
c.Deliveries = append(c.Deliveries, &CartDelivery{
Id: c.lastDeliveryId,
Provider: msg.Provider,
PickupPoint: msg.PickupPoint,
Price: 4900,
Items: items,
})
c.UpdateTotals()
}
}
case RemoveDeliveryType:
msg, ok := message.Content.(*messages.RemoveDelivery)
if !ok {
err = fmt.Errorf("expected RemoveDelivery")
} else {
deliveries := make([]*CartDelivery, 0, len(c.Deliveries))
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.Id) {
c.TotalPrice -= delivery.Price
} else {
deliveries = append(deliveries, delivery)
}
}
c.Deliveries = deliveries
c.UpdateTotals()
}
case SetPickupPointType:
msg, ok := message.Content.(*messages.SetPickupPoint)
if !ok {
err = fmt.Errorf("expected SetPickupPoint")
} else {
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.DeliveryId) {
delivery.PickupPoint = &messages.PickupPoint{
Id: msg.Id,
Address: msg.Address,
City: msg.City,
Zip: msg.Zip,
Country: msg.Country,
Name: msg.Name,
}
break
}
}
}
case CreateCheckoutOrderType:
msg, ok := message.Content.(*messages.CreateCheckoutOrder)
if !ok {
err = fmt.Errorf("expected CreateCheckoutOrder")
} else {
orderLines := make([]*Line, 0, len(c.Items))
c.PaymentInProgress = true
c.Processing = true
for _, item := range c.Items {
orderLines = append(orderLines, &Line{
Type: "physical",
Reference: item.Sku,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: int(item.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(item.TotalPrice),
TotalTaxAmount: int(item.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
})
}
for _, line := range c.Deliveries {
if line.Price > 0 {
orderLines = append(orderLines, &Line{
Type: "shipping_fee",
Reference: line.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(line.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(line.Price),
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
})
}
}
order := CheckoutOrder{
PurchaseCountry: "SE",
PurchaseCurrency: "SEK",
Locale: "sv-se",
OrderAmount: int(c.TotalPrice),
OrderTaxAmount: int(c.TotalTax),
OrderLines: orderLines,
MerchantReference1: c.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: msg.Terms,
Checkout: msg.Checkout,
Confirmation: msg.Confirmation,
Validation: msg.Validation,
Push: msg.Push,
},
}
orderPayload, err := json.Marshal(order)
if err != nil {
return nil, err
}
var klarnaOrder *CheckoutOrder
if c.OrderReference != "" {
log.Printf("Updating order id %s", c.OrderReference)
klarnaOrder, err = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
} else {
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
}
// log.Printf("Order result: %+v", klarnaOrder)
if nil != err {
log.Printf("error from klarna: %v", err)
return nil, err
}
if c.OrderReference == "" {
c.OrderReference = klarnaOrder.ID
c.PaymentStatus = klarnaOrder.Status
}
orderData, err := json.Marshal(klarnaOrder)
if nil != err {
return nil, err
}
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData)
return &result, nil
}
case OrderCompletedType:
msg, ok := message.Content.(*messages.OrderCreated)
if !ok {
log.Printf("expected OrderCompleted, got %T", message.Content)
err = fmt.Errorf("expected OrderCompleted")
} else {
c.OrderReference = msg.OrderId
c.PaymentStatus = msg.Status
c.PaymentInProgress = false
}
default:
err = fmt.Errorf("unknown message type %d", message.Type)
}
if err != nil {
return nil, err
}
if !isReplay {
c.mu.Lock()
c.storageMessages = append(c.storageMessages, *message)
c.mu.Unlock()
}
result, err := json.Marshal(c)
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
return &msg, err
}
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = 0
c.TotalTax = 0
c.TotalDiscount = 0
for _, item := range c.Items {
rowTotal := item.Price * int64(item.Quantity)
rowTax := int64(item.Tax) * int64(item.Quantity)
item.TotalPrice = rowTotal
item.TotalTax = rowTax
c.TotalPrice += rowTotal
c.TotalTax += rowTax
itemDiff := max(0, item.OrgPrice-item.Price)
c.TotalDiscount += itemDiff * int64(item.Quantity)
}
for _, delivery := range c.Deliveries {
c.TotalPrice += delivery.Price
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
}
}
-248
View File
@@ -1,248 +0,0 @@
package main
import (
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
func GetMessage(t uint16, data interface{}) *Message {
ts := time.Now().Unix()
return &Message{
TimeStamp: &ts,
Type: t,
Content: data,
}
}
func TestTaxAmount(t *testing.T) {
taxAmount := GetTaxAmount(12500, 2500)
if taxAmount != 2500 {
t.Errorf("Expected 2500, got %d\n", taxAmount)
}
}
func TestAddToCartShortCut(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
_, err = grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if grain.Items[0].Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity)
}
if len(grain.storageMessages) != 1 {
t.Errorf("Expected 1 storage message, got %d\n", len(grain.storageMessages))
}
shortCutMessage := GetMessage(AddRequestType, &messages.AddRequest{
Quantity: 2,
Sku: "123",
})
_, err = grain.HandleMessage(shortCutMessage, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if len(grain.storageMessages) != 2 {
t.Errorf("Expected 2 storage message, got %d\n", len(grain.storageMessages))
}
if grain.storageMessages[0].Type != AddItemType {
t.Errorf("Expected AddItemType, got %d\n", grain.storageMessages[0].Type)
}
if grain.storageMessages[1].Type != AddRequestType {
t.Errorf("Expected AddRequestType, got %d\n", grain.storageMessages[1].Type)
}
}
func TestAddRequestToGrain(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
msg := GetMessage(AddRequestType, &messages.AddRequest{
Quantity: 2,
Sku: "763281",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
t.Log(result)
}
func TestAddToCart(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
if grain.TotalPrice != 200 {
t.Errorf("Expected total price 200, got %d\n", grain.TotalPrice)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if grain.Items[0].Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity)
}
result, err = grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
if grain.Items[0].Quantity != 4 {
t.Errorf("Expected quantity 4, got %d\n", grain.Items[0].Quantity)
}
if grain.TotalPrice != 400 {
t.Errorf("Expected total price 400, got %d\n", grain.TotalPrice)
}
}
func TestSetDelivery(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
grain.HandleMessage(msg, false)
msg = GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{
Provider: "test",
Items: []int64{1},
})
_, err = grain.HandleMessage(setDelivery, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Deliveries) != 1 {
t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries))
}
if len(grain.Deliveries[0].Items) != 1 {
t.Errorf("Expected 1 items in delivery, got %d\n", len(grain.Deliveries[0].Items))
}
}
func TestSetDeliveryOnAll(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
grain.HandleMessage(msg, false)
msg = GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "1233",
Name: "Test item2",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{
Provider: "test",
Items: []int64{},
})
_, err = grain.HandleMessage(setDelivery, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Deliveries) != 1 {
t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries))
}
if len(grain.Deliveries[0].Items) != 2 {
t.Errorf("Expected 2 items in delivery, got %d\n", len(grain.Deliveries[0].Items))
}
}
+439
View File
@@ -0,0 +1,439 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"google.golang.org/protobuf/proto"
)
type FileServer struct {
// Define fields here
dataDir string
checkoutDataDir string
storage actor.LogStorage[cart.CartGrain]
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
}
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
return &FileServer{
dataDir: dataDir,
checkoutDataDir: checkoutDataDir,
storage: storage,
checkoutStorage: checkoutStorage,
}
}
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
}
func appendCheckoutFileInfo(info fs.FileInfo, out *CheckoutFileInfo) *CheckoutFileInfo {
out.Size = info.Size()
out.Modified = info.ModTime()
return out
}
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
func listCartFiles(dir string) ([]*CartFileInfo, error) {
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 listCheckoutFiles(dir string) ([]*CheckoutFileInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []*CheckoutFileInfo{}, nil
}
return nil, err
}
out := make([]*CheckoutFileInfo, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
id, valid := isValidFileId(e.Name())
if !valid {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, appendCheckoutFileInfo(info, &CheckoutFileInfo{
ID: fmt.Sprintf("%d", id),
CheckoutId: checkout.CheckoutId(id),
}))
}
return out, nil
}
func readRawLogLines(path string) ([]json.RawMessage, error) {
fh, err := os.Open(path)
if err != nil {
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) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
list, err := listCheckoutFiles(fs.checkoutDataDir)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// sort by modified desc
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
writeJSON(w, http.StatusOK, map[string]any{
"count": len(list),
"checkouts": list,
})
}
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
fileName := filepath.Join(fs.dataDir, "promotions.json")
if r.Method == http.MethodGet {
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 acceptAll(_ proto.Message, _ int, _ time.Time) bool {
return true
}
func acceptUntilIndex(maxIndex int) func(msg proto.Message, index int, when time.Time) bool {
return func(msg proto.Message, index int, when time.Time) bool {
return index < maxIndex
}
}
func acceptUntilTimestamp(until time.Time) func(msg proto.Message, index int, when time.Time) bool {
return func(msg proto.Message, index int, when time.Time) bool {
return when.Before(until)
}
}
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
}
// parse query parameters for filtering
query := r.URL.Query()
filterFunction := acceptAll
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
log.Printf("filter maxIndex: %s", maxIndexStr)
maxIndex, err := strconv.Atoi(maxIndexStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
return
}
filterFunction = acceptUntilIndex(maxIndex)
} else if untilStr := query.Get("until"); untilStr != "" {
log.Printf("filter until: %s", untilStr)
until, err := time.Parse(time.RFC3339, untilStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
return
}
filterFunction = acceptUntilTimestamp(until)
}
// reconstruct state from event log if present
grain := cart.NewCartGrain(id, time.Now())
err := fs.storage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
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,
},
})
}
func (fs *FileServer) CheckoutHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
return
}
id, ok := isValidId(idStr)
if !ok {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
return
}
// parse query parameters for filtering
query := r.URL.Query()
filterFunction := acceptAll
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
log.Printf("filter maxIndex: %s", maxIndexStr)
maxIndex, err := strconv.Atoi(maxIndexStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
return
}
filterFunction = acceptUntilIndex(maxIndex)
} else if untilStr := query.Get("until"); untilStr != "" {
log.Printf("filter until: %s", untilStr)
until, err := time.Parse(time.RFC3339, untilStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
return
}
filterFunction = acceptUntilTimestamp(until)
}
// reconstruct state from event log if present
grain := checkout.NewCheckoutGrain(id, cart.CartId(id), 0, time.Now(), nil)
err := fs.checkoutStorage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
path := filepath.Join(fs.checkoutDataDir, fmt.Sprintf("%d.events.log", id))
info, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusNotFound, JsonError{Error: "checkout not found"})
return
} else if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
lines, err := readRawLogLines(path)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"checkoutId": checkout.CheckoutId(id).String(),
"state": grain,
"mutations": lines,
"meta": map[string]any{
"size": info.Size(),
"modified": info.ModTime(),
"path": path,
},
})
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"math/rand"
"os"
"path/filepath"
"testing"
"time"
"git.k6n.net/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"))
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"time"
actor "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
)
type CartFileInfo struct {
ID string `json:"id"`
CartId cart.CartId `json:"cartId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
type CheckoutFileInfo struct {
ID string `json:"id"`
CheckoutId checkout.CheckoutId `json:"checkoutId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
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
}
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
func main() {
dataDir := envOrDefault("DATA_DIR", "data")
addr := envOrDefault("ADDR", ":8080")
amqpURL := os.Getenv("AMQP_URL")
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)
}
_ = os.MkdirAll(dataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
_ = os.MkdirAll(checkoutDataDir, 0755)
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
hub := NewHub()
go hub.Run()
mux := http.NewServeMux()
mux.HandleFunc("GET /carts", fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
inventorySku := inventory.SKU(r.PathValue("sku"))
pipe := rdb.Pipeline()
var payload struct {
Quantity int64 `json:"quantity"`
}
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
_, err = pipe.Exec(r.Context())
if err != nil {
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
return
}
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
})
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
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"log"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}
+315
View File
@@ -0,0 +1,315 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/promotions"
"git.k6n.net/go-cart-actor/pkg/proxy"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/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"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_spawned_total",
Help: "The total number of spawned grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
type MutationContext struct {
VoucherService voucher.Service
}
type CartChangeEvent struct {
CartId cart.CartId `json:"cartId"`
Mutations []actor.ApplyResult `json:"mutations"`
}
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
if string(update.SKU) == item.Sku {
if update.LocationID == "se" && item.StoreId == nil {
return true
}
if item.StoreId == nil {
return false
}
if *item.StoreId == string(update.LocationID) {
return true
}
}
return false
}
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
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))
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
// promotionService := promotions.NewPromotionService(nil)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating inventory reservation service: %v\n", err)
}
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
g.UpdateTotals()
g.Version++
// promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
// _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
// for _, action := range actions {
// log.Printf("apply: %+v", action)
// g.UpdateTotals()
// }
return nil
}),
)
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
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 := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
err := diskStorage.LoadEvents(ctx, id, ret)
if err == nil && inventoryService != nil {
refs := make([]*inventory.InventoryReference, 0)
for _, item := range ret.Items {
refs = append(refs, &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
})
}
_, span := tracer.Start(ctx, "update inventory")
defer span.End()
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
if err != nil {
log.Printf("unable to update inventory %v", err)
} else {
for _, update := range res {
for _, item := range ret.Items {
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
// maybe apply an update to give visibility to the cart
item.Stock = uint16(update.Quantity)
}
}
}
}
}
return ret, err
},
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
cart, err := grain.GetCurrentState()
if err != nil {
return err
}
inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
return nil
},
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host)
},
TTL: 5 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
app := &App{
pool: pool,
server: syncedServer,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
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)
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
})
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()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
srv := &http.Server{
Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
for _, change := range changes {
log.Printf("inventory change: %v", change)
inventoryPubSub.Publish(change)
}
})
go func() {
err := listener.Start()
if err != nil {
log.Fatalf("Unable to start inventory listener: %v", err)
}
}()
log.Print("Server started at port 8080")
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()
}
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"net/http"
"sync"
)
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
//go:embed openapi.json
var openapiJSON []byte
var (
openapiOnce sync.Once
openapiETag string
)
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
func initOpenAPIMetadata() {
sum := sha256.Sum256(openapiJSON)
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
}
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
// It supports GET and HEAD and implements basic ETag caching.
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
openapiOnce.Do(initOpenAPIMetadata)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("ETag", openapiETag)
if match := r.Header.Get("If-None-Match"); match != "" {
if bytes.Contains([]byte(match), []byte(openapiETag)) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiJSON)
}
// Optional: function to access raw spec bytes programmatically.
func OpenAPISpecBytes() []byte {
return openapiJSON
}
// Optional: function to access current ETag.
func OpenAPIETag() string {
openapiOnce.Do(initOpenAPIMetadata)
return openapiETag
}
+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
}
+530
View File
@@ -0,0 +1,530 @@
package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/cart"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"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[cart.CartGrain]
pod_name string
inventoryService inventory.InventoryService
reservationService inventory.CartReservationService
}
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
srv := &PoolServer{
GrainPool: pool,
pod_name: pod_name,
inventoryService: inventoryService,
reservationService: inventoryReservationService,
}
return srv
}
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 cart.CartId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku")
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *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(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
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(r.Context(), id, &changeQuantity)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type Item struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
StoreId *string `json:"storeId,omitempty"`
}
type SetCartItems struct {
Country string `json:"country"`
Items []Item `json:"items"`
}
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(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
}
mu.Lock()
msgs = append(msgs, msg)
mu.Unlock()
})
}
wg.Wait()
return msgs
}
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 {
return err
}
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
msgs = append(msgs, &messages.ClearCartRequest{})
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
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 cart.CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type AddRequest struct {
Sku string `json:"sku"`
Quantity int32 `json:"quantity"`
Country string `json:"country"`
StoreId *string `json:"storeId"`
}
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(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
// orderId := r.PathValue("orderId")
// if orderId == "" {
// return fmt.Errorf("orderId is empty")
// }
// order, err := KlarnaInstance.GetOrder(orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json")
// w.Header().Set("X-Pod-Name", s.pod_name)
// w.Header().Set("Cache-Control", "no-cache")
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.WriteHeader(http.StatusOK)
// return json.NewEncoder(w).Encode(order)
// }
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
// return err
// }
// s.ApplyCheckoutStarted(klarnaOrder, id)
// w.Header().Set("Content-Type", "application/json")
// return json.NewEncoder(w).Encode(klarnaOrder)
// }
//
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: r.TLS != nil,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusOK)
return nil
}
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, nil)
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
)
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 cart.CartId) error {
data := &AddVoucherRequest{}
json.NewDecoder(r.Body).Decode(data)
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
type SubscriptionDetailsRequest struct {
Id *string `json:"id,omitempty"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{
Id: sd.Id,
OfferingCode: sd.OfferingCode,
SigningType: sd.SigningType,
Data: &anypb.Any{Value: sd.Data},
}
}
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
data := &SubscriptionDetailsRequest{}
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.ToMessage())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
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)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
setUserId := messages.SetUserId{}
err := json.NewDecoder(r.Body).Decode(&setUserId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemIdStr := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
if err != nil {
return err
}
lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)}
err = json.NewDecoder(r.Body).Decode(&lineItemMarking)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemIdStr := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
if err != nil {
return err
}
removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)}
reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
return err
}
mutation := &messages.Mutation{}
err = proto.Unmarshal(data, mutation)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
func (s *PoolServer) Serve(mux *http.ServeMux) {
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
// 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)
// })
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
handleFunc("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("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
//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("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("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/cart"
"github.com/matst80/slask-finder/pkg/index"
)
// TODO make this configurable
func getBaseUrl(country string) string {
// if country == "se" {
// return "http://s10n-se:8080"
// }
if country == "no" {
return "http://s10n-no.s10n:8080"
}
if country == "se" {
return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
}
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
baseUrl := getBaseUrl(country)
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
}
defer res.Body.Close()
var item index.DataItem
err = json.NewDecoder(res.Body).Decode(&item)
return &item, err
}
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)
}
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, err
}
stk := item.GetStock()
stock := cart.StockStatus(0)
if storeId == nil {
centralStock, ok := stk[country]
if ok {
if !item.Buyable {
return nil, fmt.Errorf("item not available")
}
if centralStock == 0 && item.SaleStatus == "TBD" {
return nil, fmt.Errorf("no items available")
}
stock = cart.StockStatus(centralStock)
}
} else {
if !item.BuyableInStore {
return nil, fmt.Errorf("item not available in store")
}
storeStock, ok := stk[*storeId]
if ok {
stock = cart.StockStatus(storeStock)
}
}
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
return &messages.AddItem{
ItemId: uint32(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: item.GetSku(),
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: getTax(articleType),
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
StoreId: storeId,
SaleStatus: item.SaleStatus,
Cgm: cgm,
}, nil
}
func getTax(articleType string) int32 {
switch articleType {
case "ZDIE":
return 600
default:
return 2500
}
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type SessionRequest struct {
SessionId *string `json:"sessionId,omitempty"`
SessionResult string `json:"sessionResult"`
SessionData *string `json:"sessionData,omitempty"`
}
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// grain, err := s.Get(r.Context(), uint64(cartId))
// if err != nil {
// return err
// }
// if r.Method == http.MethodGet {
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// } else {
// payload := &SessionRequest{}
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
// return err
// }
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// }
// }
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
cartId, ok := cart.ParseCartId(item.MerchantReference)
if !ok {
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
return nil, errors.New("invalid cart id")
}
return &cartId, nil
}
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
var notificationRequest webhook.Webhook
service := s.adyenClient.Checkout()
if err := json.NewDecoder(r.Body).Decode(&notificationRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, notificationItem := range *notificationRequest.NotificationItems {
item := notificationItem.NotificationRequestItem
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
if !isValid {
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
return
} else {
// Marshal item data for PaymentEvent
dataBytes, err := json.Marshal(item)
if err != nil {
log.Printf("error marshaling item: %v", err)
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
return
}
switch item.EventCode {
case "CAPTURE":
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Capture status: %v", item.Success)
isSuccess := item.Success == "true"
// If successful, apply payment completed
//if isSuccess {
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
Currency: item.Amount.Currency,
ProcessorReference: &item.PspReference,
CompletedAt: timestamppb.New(time.Now()),
}); err != nil {
http.Error(w, "Message not parsed", http.StatusInternalServerError)
return
}
//}
case "AUTHORISATION":
isSuccess := item.Success == "true"
log.Printf("Handling auth: %+v", item)
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msgs := []proto.Message{
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
},
}
if isSuccess {
msgs = append(msgs, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
CompletedAt: timestamppb.Now(),
})
} else {
msgs = append(msgs, &messages.PaymentDeclined{
PaymentId: item.PspReference,
Message: item.Reason,
})
}
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
log.Printf("error applying authorization event: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// If successful authorization, trigger capture
if isSuccess {
pspReference := item.PspReference
uid := uuid.New().String()
ref := checkoutId.String()
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
Amount: adyenCheckout.Amount(item.Amount),
MerchantAccount: "ElgigantenECOM",
Reference: &ref,
})
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
if err != nil {
log.Printf("Error capturing payment: %v", err)
} else {
log.Printf("Payment captured successfully: %+v", res)
s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
OrderId: res.PaymentPspReference,
Status: item.EventCode,
})
}
}
default:
log.Printf("Unknown event code: %s", item.EventCode)
log.Printf("Item data: %+v", item)
isSuccess := item.Success == "true"
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
} else {
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}); err != nil {
log.Printf("error applying payment event: %v", err)
}
}
}
}
}
w.WriteHeader(http.StatusAccepted)
}
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received")
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
log.Printf("got payment session %+v", res)
dreq := service.PaymentsApi.PaymentsDetailsInput()
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
Details: adyenCheckout.PaymentCompletionDetails{
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
Payload: common.PtrString(r.URL.Query().Get("payload")),
},
})
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Payment details response: %+v", dres)
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
var redirectURL string
// Conditionally handle different result codes for the shopper
switch *dres.ResultCode {
case "Authorised":
redirectURL = "/result/success"
case "Pending", "Received":
redirectURL = "/result/pending"
case "Refused":
redirectURL = "/result/failed"
default:
reason := ""
if dres.RefusalReason != nil {
reason = *dres.RefusalReason
} else {
reason = *dres.ResultCode
}
log.Printf("Payment failed: %s", reason)
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpRes.StatusCode)
json.NewEncoder(w).Encode(httpRes.Status)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"context"
"fmt"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type AmqpOrderHandler struct {
conn *amqp.Connection
queue *amqp.Queue
}
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
return &AmqpOrderHandler{
conn: conn,
}
}
func (h *AmqpOrderHandler) DefineQueue() error {
ch, err := h.conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
defer ch.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()
return ch.PublishWithContext(ctx,
"", // exchange
h.queue.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
//DeliveryMode: amqp.,
ContentType: "application/json",
Body: body,
})
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
type CartClient struct {
httpClient *http.Client
baseUrl string
}
func NewCartClient(baseUrl string) *CartClient {
return &CartClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseUrl: baseUrl,
}
}
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
// data, err := proto.Marshal(mutation)
// if err != nil {
// return err
// }
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
// if err != nil {
// return err
// }
// req.Header.Set("Content-Type", "application/protobuf")
// resp, err := c.httpClient.Do(req)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
// }
// return nil
// }
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
// Call cart service to get grain
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("request to %s failed with status %s", url, resp.Status)
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
}
var grain cart.CartGrain
err = json.NewDecoder(resp.Body).Decode(&grain)
return &grain, err
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"context"
"testing"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
func TestGetCartGrain_RealService(t *testing.T) {
t.Skip("Skipping integration test against real service")
client := NewCartClient("https://cart.k6n.net")
// You would need a real cart ID that exists in the system
// For example: cartId := cart.NewCartId(123, 456)
cartId := cart.MustParseCartId("JkfG6bRNLMy")
grain, err := client.getCartGrain(context.Background(), cartId)
if err != nil {
t.Fatalf("Failed to get cart grain: %v", err)
}
if grain == nil {
t.Fatal("Expected grain to be non-nil")
}
t.Logf("Successfully retrieved cart grain: %+v", grain)
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
SiteUrl string
// Terms string
// Checkout string
// Confirmation string
ClientIp string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
//total.Add(d.Price)
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(total.IncVat),
OrderTaxAmount: int(total.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Notification: "https://cart.k6n.net/payment/klarna/notification",
Validation: "https://cart.k6n.net/payment/klarna/validate",
Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}",
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r)
country := getCountryFromHost(host)
return &CheckoutMeta{
ClientIp: getClientIp(r),
SiteUrl: fmt.Sprintf("https://%s", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
}
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
country := meta.Country
if country == "" {
country = "SE"
}
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(int64(it.Quantity)),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
Description: common.PtrString(it.Meta.Name),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
TaxPercentage: common.PtrInt64(25),
})
}
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
Value: total.IncVat,
Currency: currency,
},
CountryCode: common.PtrString(country),
MerchantAccount: "ElgigantenECOM",
Channel: common.PtrString("Web"),
ShopperIP: common.PtrString(meta.ClientIp),
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
LineItems: lineItems,
}, nil
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"log"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}
@@ -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,17 +68,19 @@ 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)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -82,17 +88,20 @@ 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)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -100,29 +109,34 @@ 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)
_, err = k.client.Do(req)
return err
}
// 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)
req.Header.Add("Klarna-Idempotency-Key", id.String())
_, err = http.DefaultClient.Do(req)
_, err = k.client.Do(req)
return err
}
+277
View File
@@ -0,0 +1,277 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"google.golang.org/protobuf/types/known/timestamppb"
)
/*
*
*
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
})
*/
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
// w.WriteHeader(http.StatusOK)
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
// return err
// }
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
// return json.NewEncoder(w).Encode(order)
// }
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
s.reserveInventory(r.Context(), grain)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
PaymentId: orderId,
Status: "completed",
ProcessorReference: &order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
CompletedAt: timestamppb.Now(),
})
// err = confirmOrder(r.Context(), order, orderHandler)
// if err != nil {
// log.Printf("Error confirming order: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
// err = triggerOrderCompleted(r.Context(), a.server, order)
// if err != nil {
// log.Printf("Error processing cart message: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
}
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.GetAnywhere(ctx, cartId)
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/checkout"
"git.k6n.net/go-cart-actor/pkg/proxy"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_spawned_total",
Help: "The total number of spawned checkout grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
server *CheckoutPoolServer
klarnaClient *KlarnaClient
cartClient *CartClient // For internal communication to cart
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
g.Version++
return nil
}),
)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
defer span.End()
grainSpawns.Inc()
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
// Load persisted events/state for this checkout if present
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
// Return the grain along with error (e.g., not found) so callers can decide
return ret, err
}
return ret, nil
},
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
return nil
},
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
},
TTL: 1 * time.Hour, // Longer TTL for checkout
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating checkout pool: %v\n", err)
}
adyenClient := adyen.NewClient(&common.Config{
ApiKey: os.Getenv("ADYEN_API_KEY"),
Environment: common.TestEnv,
})
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
cartClient := NewCartClient(cartInternalUrl)
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
syncedServer.inventoryService = inventoryService
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Fatalf("no connection to amqp defined")
}
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Checkout server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}
+543
View File
@@ -0,0 +1,543 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strconv"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_lookups_total",
Help: "The total number of lookups",
})
)
type CheckoutPoolServer struct {
actor.GrainPool[checkout.CheckoutGrain]
pod_name string
klarnaClient *KlarnaClient
adyenClient *adyen.APIClient
cartClient *CartClient
inventoryService *inventory.RedisInventoryService
}
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
srv := &CheckoutPoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
cartClient: cartClient,
adyenClient: adyenClient,
}
return srv
}
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetDelivery
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
deliveryId := r.PathValue("id")
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
if err != nil {
return err
}
msg := &messages.RemoveDelivery{
Id: uint32(uintDeliveryId),
}
result, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetPickupPoint
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InitializeCheckout
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InventoryReserved
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.OrderCreated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ConfirmationViewed
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ContactDetailsUpdated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
cartIdStr := r.PathValue("cartid")
if cartIdStr == "" {
http.Error(w, "cart id required", http.StatusBadRequest)
return
}
cartId, ok := cart.ParseCartId(cartIdStr)
if !ok {
http.Error(w, "invalid cart id", http.StatusBadRequest)
return
}
// Fetch cart state from cart service
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
if err != nil {
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Serialize cart state to Any
cartStateBytes, err := json.Marshal(cartGrain)
if err != nil {
logger.Error("failed to marshal cart state", "error", err)
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
return
}
// Create checkout with same ID as cart
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
cookie, err := r.Cookie(checkoutCookieName)
if err == nil {
parsed, ok := cart.ParseCartId(cookie.Value)
if ok {
checkoutId = parsed
}
}
// Initialize checkout with cart state wrapped in Any
cartStateAny := &messages.InitializeCheckout{
OrderId: "",
CartId: uint64(cartId),
Version: uint32(cartGrain.Version),
CartState: &anypb.Any{
TypeUrl: "type.googleapis.com/cart.CartGrain",
Value: cartStateBytes,
},
}
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
if err != nil {
setCheckoutCookie(w, 0, r.TLS != nil)
logger.Error("failed to initialize checkout", "error", err)
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
return
}
// Set checkout cookie
setCheckoutCookie(w, checkoutId, r.TLS != nil)
if err := s.WriteResult(w, &result.Result); err != nil {
logger.Error("failed to write result", "error", err)
}
}
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
var payment *checkout.Payment
if orderId != nil {
payment, _ = grain.FindPayment(*orderId)
}
if payment != nil && payment.PaymentId != "" {
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
method := "checkout"
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
PaymentId: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Provider: "klarna",
Method: &method,
StartedAt: timestamppb.New(time.Now()),
})
}
var (
tracer = otel.Tracer(name)
hmacKey = os.Getenv("ADYEN_HMAC")
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
)
func init() {
var err error
proxyCalls, err = meter.Int64Counter("proxy.calls",
metric.WithDescription("Number of proxy calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
type StartPayment struct {
Provider string `json:"provider"`
Method string `json:"method,omitempty"`
}
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payment, ok := grain.FindPayment(paymentId)
if !ok {
http.Error(w, "payment not found", http.StatusNotFound)
return nil
}
switch payment.Provider {
case "adyen":
payload := &SessionRequest{
SessionResult: "",
}
if r.Method != http.MethodGet {
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
if err != nil {
return err
}
if res.Status != nil && *res.Status == "completed" {
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
PaymentId: paymentId,
Status: *res.Status,
ProcessorReference: res.Id,
CompletedAt: timestamppb.Now(),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
}
return s.WriteResult(w, res)
case "klarna":
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
if err != nil {
return err
}
return s.WriteResult(w, current)
}
return errors.New("unsupported payment provider")
}
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payload := &StartPayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
switch payload.Provider {
case "adyen":
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
if err != nil {
logger.Error("unable to build adyen session", "error", err)
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
if err != nil {
logger.Error("unable to create adyen session", "error", err)
return err
}
sessionBytes, err := json.Marshal(session)
if err != nil {
logger.Error("unable to marshal session", "error", err)
return err
}
// Apply PaymentStarted mutation
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: session.Id,
Amount: session.Amount.Value,
Currency: session.Amount.Currency,
Provider: "adyen",
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: sessionBytes,
},
Method: &payload.Method,
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
case "klarna":
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
orderBytes, err := json.Marshal(order)
if err != nil {
logger.Error("unable to marshal order", "error", err)
return err
}
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
Provider: "klarna",
Method: &payload.Method,
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: orderBytes,
},
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
default:
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
return nil
}
}
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
//handleFunc("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"log"
"net/http"
"strings"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
if a.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
if err != nil {
logger.WarnContext(ctx, "placeorder inventory check failed")
return err
}
}
return nil
}
const checkoutCookieName = "checkoutid"
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
if checkoutId == 0 {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
} else {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
}
}
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id checkout.CheckoutId
cookie, err := r.Cookie(checkoutCookieName)
if err != nil || cookie.Value == "" {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
id = parsed
}
}
err = fn(w, r, id)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
defer span.End()
return fn(w, r, checkoutId)
}
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"sync"
"github.com/matst80/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 {
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
}
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) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
quantity, err := srv.inventoryService.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)
}
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summary)
}
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
}
r, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Unable to connect to reservation redis: %v", err)
return
}
server := &Server{inventoryService: s, reservationService: r}
// Set up HTTP routes
http.HandleFunc("/livez", server.livezHandler)
http.HandleFunc("/readyz", server.readyzHandler)
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
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"
"github.com/matst80/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)
}
})
}
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837
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.
-276
View File
@@ -1,276 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
type: Opaque
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
selector:
matchLabels:
app: cart-actor
arch: amd64
template:
metadata:
labels:
app: cart-actor
actor-pool: cart
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-no
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
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
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: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: BASE_URL
value: "https://s10n-no.tornberg.me"
- name: CART_BASE_URL
value: "https://cart-no.tornberg.me"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: arm64
name: cart-actor-arm64
spec:
replicas: 3
selector:
matchLabels:
app: cart-actor
arch: arm64
template:
metadata:
labels:
app: cart-actor
actor-pool: cart
arch: arm64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor-no
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor:latest
name: cart-actor-arm64
imagePullPolicy: Always
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
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: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: BASE_URL
value: "https://s10n-no.tornberg.me"
- name: CART_BASE_URL
value: "https://cart-no.tornberg.me"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: cart-actor
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx
tls:
- hosts:
- cart-no.tornberg.me
secretName: cart-actor-no-tls-secret
rules:
- host: cart-no.tornberg.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cart-actor
port:
number: 8080
+440 -51
View File
@@ -1,7 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
@@ -9,13 +9,117 @@ 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/
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/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: DATA_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
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
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
replicas: 3
selector:
matchLabels:
app: cart-actor
@@ -32,20 +136,18 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
- 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
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
lifecycle:
@@ -55,25 +157,23 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
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
@@ -84,6 +184,10 @@ spec:
memory: "70Mi"
cpu: "1200m"
env:
- name: DATA_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
@@ -91,6 +195,24 @@ 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: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
@@ -134,24 +256,22 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
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:latest
- image: registry.k6n.net/go-cart-actor:latest
name: cart-actor-arm64
imagePullPolicy: Always
lifecycle:
@@ -161,25 +281,23 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
@@ -192,6 +310,24 @@ 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: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
@@ -217,22 +353,10 @@ spec:
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
@@ -241,6 +365,151 @@ spec:
ports:
- name: web
port: 8080
- name: internal
port: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: checkout-actor
arch: amd64
name: checkout-actor-x86
spec:
replicas: 3
selector:
matchLabels:
app: checkout-actor
arch: amd64
template:
metadata:
labels:
app: checkout-actor
actor-pool: checkout
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/checkout-actor
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: checkout-actor-amd64
imagePullPolicy: Always
command: ["/go-checkout-actor"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 1
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=checkout,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: CART_INTERNAL_URL
value: "http://cart-actor:8080"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: checkout-actor
annotations:
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: checkout-actor
ports:
- name: web
port: 8080
---
kind: Service
apiVersion: v1
metadata:
name: cart-backoffice
spec:
selector:
app: cart-backoffice
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -248,19 +517,19 @@ metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx
tls:
- hosts:
- cart.tornberg.me
- cart.k6n.net
secretName: cart-actor-tls-secret
rules:
- host: cart.tornberg.me
- host: cart.k6n.net
http:
paths:
- path: /
@@ -270,3 +539,123 @@ spec:
name: cart-actor
port:
number: 8080
- path: /api/checkout
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
- path: /payment
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-backend-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- slask-cart.k6n.net
secretName: cart-backoffice-actor-tls-secret
rules:
- host: slask-cart.k6n.net
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
serviceAccountName: default
containers:
- image: registry.k6n.net/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"
---
kind: Service
apiVersion: v1
metadata:
name: inventory
spec:
selector:
app: cart-inventory
ports:
- name: web
port: 8080
+94 -18
View File
@@ -1,25 +1,101 @@
apiVersion: autoscaling/v1
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cart-scaler-amd
name: cart-scaler-amd
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-x86
minReplicas: 3
maxReplicas: 9
targetCPUUtilizationPercentage: 30
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-x86
minReplicas: 3
maxReplicas: 9
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"
---
apiVersion: autoscaling/v1
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cart-scaler-arm
name: cart-scaler-arm
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-arm64
minReplicas: 3
maxReplicas: 9
targetCPUUtilizationPercentage: 30
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-arm64
minReplicas: 3
maxReplicas: 9
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"
-83
View File
@@ -1,83 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
*Connection
Host string
Tries int
}
type DiscardedHostHandler struct {
mu sync.RWMutex
port int
hosts []*DiscardedHost
onConnection *func(string)
}
func (d *DiscardedHostHandler) run() {
for range time.Tick(time.Second) {
d.mu.RLock()
lst := make([]*DiscardedHost, 0, len(d.hosts))
for _, host := range d.hosts {
if host.Tries >= 0 || host.Tries < 5 {
go d.testConnection(host)
lst = append(lst, host)
} else {
if host.Tries > 0 {
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
}
}
}
d.mu.RUnlock()
d.mu.Lock()
d.hosts = lst
d.mu.Unlock()
}
}
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
conn, err := net.Dial("tcp", addr)
if err != nil {
host.Tries++
host.Tries = -1
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}
-18
View File
@@ -1,18 +0,0 @@
package main
import (
"testing"
"time"
)
func TestDiscardedHost(t *testing.T) {
dh := NewDiscardedHostHandler(8080)
dh.SetReconnectHandler(func(host string) {
t.Log(host)
})
dh.AppendHost("localhost")
time.Sleep(2 * time.Second)
if dh.hosts[0].Tries == 0 {
t.Error("Host not tested")
}
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"context"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"encoding/gob"
"fmt"
"log"
"os"
"time"
)
type DiskStorage struct {
stateFile string
lastSave int64
LastSaves map[CartId]int64
}
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{
stateFile: stateFile,
LastSaves: make(map[CartId]int64),
}
err := ret.loadState()
return ret, err
}
func saveMessages(messages []StorableMessage, id CartId) error {
if len(messages) == 0 {
return nil
}
log.Printf("%d messages to save for grain id %s", len(messages), id)
var file *os.File
var err error
path := getCartPath(id.String())
file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
for _, m := range messages {
err := m.Write(file)
if err != nil {
return err
}
}
return err
}
func getCartPath(id string) string {
return fmt.Sprintf("data/%s.prot", id)
}
func loadMessages(grain Grain, id CartId) error {
var err error
path := getCartPath(id.String())
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer file.Close()
for err == nil {
var msg Message
err = ReadMessage(file, &msg)
if err == nil {
grain.HandleMessage(&msg, true)
}
}
if err.Error() == "EOF" {
return nil
}
return err
}
func (s *DiskStorage) saveState() error {
tmpFile := s.stateFile + "_tmp"
file, err := os.Create(tmpFile)
if err != nil {
return err
}
defer file.Close()
err = gob.NewEncoder(file).Encode(s.LastSaves)
if err != nil {
return err
}
os.Remove(s.stateFile + ".bak")
os.Rename(s.stateFile, s.stateFile+".bak")
return os.Rename(tmpFile, s.stateFile)
}
func (s *DiskStorage) loadState() error {
file, err := os.Open(s.stateFile)
if err != nil {
return err
}
defer file.Close()
return gob.NewDecoder(file).Decode(&s.LastSaves)
}
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
lastSavedMessage, ok := s.LastSaves[id]
if ok && lastSavedMessage > grain.GetLastChange() {
return nil
}
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
if err != nil {
return err
}
ts := time.Now().Unix()
s.LastSaves[id] = ts
s.lastSave = ts
return nil
}
+94 -39
View File
@@ -1,63 +1,118 @@
module git.tornberg.me/go-cart-actor
module git.k6n.net/go-cart-actor
go 1.24.2
go 1.25.4
require (
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761
github.com/prometheus/client_golang v1.22.0
github.com/adyen/adyen-go-api-library/v21 v21.1.0
github.com/google/uuid v1.6.0
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
github.com/prometheus/client_golang v1.23.2
github.com/rabbitmq/amqp091-go v1.10.0
github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e
google.golang.org/protobuf v1.36.6
k8s.io/api v0.32.3
k8s.io/apimachinery v0.32.3
k8s.io/client-go v0.32.3
github.com/redis/go-redis/v9 v9.17.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.77.0
google.golang.org/protobuf v1.36.10
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.14.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // 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.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.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.9.0 // 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.2 // 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/pkg/errors v0.9.1 // 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.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/prometheus/common v0.67.4 // 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/pflag v1.0.10 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // 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
go.yaml.in/yaml/v4 v4.0.0-rc.3 // 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.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+237 -79
View File
@@ -1,11 +1,19 @@
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9 h1:U5gu3M9/khqtvgg6iRKo0+nxGEfPHWFHRlKrbZvFxIY=
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9/go.mod h1:+LVFV9FXH5cwN1VcU30WcNYRs5FhkEtL7/IqqTD42cU=
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -14,36 +22,84 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
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/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
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.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
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.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -52,128 +108,230 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 h1:kEvcfY+vCg+sCeFxHj5AzKbLMSmxsWU53OEPeCi28RU=
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761/go.mod h1:+DqYJ8l2i/6haKggOnecs2mU7T8CC3v5XW3R4UGCgo4=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
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=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
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/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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
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.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.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.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.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=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
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/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e h1:fAzVSmKQkWflN25ED65CH/C1T3iVWq2BQfN7eQsg4E4=
github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e/go.mod h1:gQsFrHrY6nviQu+VX7zKWDyhtLPNzngtYZ+C+7cywdk=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
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.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.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/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.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=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
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.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.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=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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.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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
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=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
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.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+327
View File
@@ -0,0 +1,327 @@
{
"uid": "cart-actors",
"title": "Cart Actor Cluster",
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 38,
"version": 1,
"editable": true,
"graphTooltip": 0,
"panels": [
{
"type": "row",
"title": "Overview",
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
"id": 1,
"collapsed": false
},
{
"type": "stat",
"title": "Active Grains",
"id": 2,
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_active_grains" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Grains In Pool",
"id": 3,
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grains_in_pool" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Pool Usage %",
"id": 4,
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
],
"units": "percent",
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "stat",
"title": "Connected Remotes",
"id": 5,
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "connected_remotes" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "row",
"title": "Mutations",
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
"id": 6,
"collapsed": false
},
{
"type": "timeseries",
"title": "Mutation Rate (1m)",
"id": 7,
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
],
"fieldConfig": { "defaults": { "unit": "ops" } }
},
{
"type": "stat",
"title": "Failure % (5m)",
"id": 8,
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
}
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "timeseries",
"title": "Mutation Latency Quantiles",
"id": 9,
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"refId": "B",
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p90"
},
{
"refId": "C",
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p99"
}
],
"fieldConfig": { "defaults": { "unit": "s" } }
},
{
"type": "row",
"title": "Event Log",
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
"id": 10,
"collapsed": false
},
{
"type": "timeseries",
"title": "Event Append Rate (5m)",
"id": 11,
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
]
},
{
"type": "timeseries",
"title": "Event Bytes Written Rate (5m)",
"id": 12,
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
],
"fieldConfig": { "defaults": { "unit": "Bps" } }
},
{
"type": "stat",
"title": "Existing Log Files",
"id": 13,
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Last Append Age (s)",
"id": 14,
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Failures Total",
"id": 15,
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p95 (5m)",
"id": 16,
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
},
{
"type": "row",
"title": "Grain Lifecycle",
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
"id": 17,
"collapsed": false
},
{
"type": "timeseries",
"title": "Spawn & Lookup Rates (1m)",
"id": 18,
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
]
},
{
"type": "stat",
"title": "Negotiations Rate (5m)",
"id": 19,
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
},
{
"type": "stat",
"title": "Mutations Total",
"id": 20,
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "row",
"title": "Event Log Errors",
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
"id": 21,
"collapsed": false
},
{
"type": "stat",
"title": "Unknown Event Types",
"id": 22,
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Event Mutation Errors",
"id": 23,
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Success Total",
"id": 24,
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p50 (5m)",
"id": 25,
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
}
],
"templating": {
"list": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": { "text": "Prometheus", "value": "Prometheus" }
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
}
}
-168
View File
@@ -1,168 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grains_in_pool",
Help: "The total number of grains in the pool",
})
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_pool_size",
Help: "The total number of mutations",
})
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grain_pool_usage",
Help: "The current usage of the grain pool",
})
)
type GrainPool interface {
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
Get(id CartId) (*FrameWithPayload, error)
}
type Ttl struct {
Expires time.Time
Grain *CartGrain
}
type GrainLocalPool struct {
mu sync.RWMutex
grains map[CartId]*CartGrain
expiry []Ttl
spawn func(id CartId) (*CartGrain, error)
Ttl time.Duration
PoolSize int
}
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
ret := &GrainLocalPool{
spawn: spawn,
grains: make(map[CartId]*CartGrain),
expiry: make([]Ttl, 0),
Ttl: ttl,
PoolSize: size,
}
cartPurge := time.NewTicker(time.Minute)
go func() {
<-cartPurge.C
ret.Purge()
}()
return ret
}
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
p.mu.Lock()
defer p.mu.Unlock()
for id := range availableWithLastChangeUnix {
if _, ok := p.grains[id]; !ok {
p.grains[id] = nil
p.expiry = append(p.expiry, Ttl{
Expires: time.Now().Add(p.Ttl),
Grain: nil,
})
}
}
}
func (p *GrainLocalPool) Purge() {
lastChangeTime := time.Now().Add(-p.Ttl)
keepChanged := lastChangeTime.Unix()
p.mu.Lock()
defer p.mu.Unlock()
for i := 0; i < len(p.expiry); i++ {
item := p.expiry[i]
if item.Expires.Before(time.Now()) {
if item.Grain.GetLastChange() > keepChanged {
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
p.expiry = append(p.expiry, item)
} else {
p.expiry = append(p.expiry[:i], item)
}
} else {
log.Printf("Item %s expired", item.Grain.GetId())
delete(p.grains, item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
} else {
p.expiry = p.expiry[:i]
}
}
} else {
break
}
}
}
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
return p.grains
}
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
var err error
// p.mu.RLock()
// defer p.mu.RUnlock()
grain, ok := p.grains[id]
grainLookups.Inc()
if grain == nil || !ok {
if len(p.grains) >= p.PoolSize {
if p.expiry[0].Expires.Before(time.Now()) {
delete(p.grains, p.expiry[0].Grain.GetId())
p.expiry = p.expiry[1:]
} else {
return nil, fmt.Errorf("pool is full")
}
}
grain, err = p.spawn(id)
p.mu.Lock()
p.grains[id] = grain
p.mu.Unlock()
}
go func() {
l := float64(len(p.grains))
ps := float64(p.PoolSize)
poolUsage.Set(l / ps)
poolGrains.Set(l)
poolSize.Set(ps)
}()
return grain, err
}
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
var result *FrameWithPayload
if err == nil && grain != nil {
for _, message := range messages {
result, err = grain.HandleMessage(&message, false)
}
}
return result, err
}
func (p *GrainLocalPool) Get(id CartId) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
if err != nil {
return nil, err
}
data, err := json.Marshal(grain)
if err != nil {
return nil, err
}
ret := MakeFrameWithPayload(0, 200, data)
return &ret, nil
}
-19
View File
@@ -1,19 +0,0 @@
package main
import (
"testing"
)
func TestIdGeneration(t *testing.T) {
// Generate a random ID
id := NewCartId()
// Generate a random ID
id2 := NewCartId()
// Compare the two IDs
if id == id2 {
t.Errorf("IDs are the same: %v == %v", id, id2)
} else {
t.Log("ID generation test passed", id, id2)
}
}
+174
View File
@@ -0,0 +1,174 @@
# k6 Load Tests for Cart API
This directory contains a k6 script (`cart_load_test.js`) to stress and observe the cart actor HTTP API.
## Contents
- `cart_load_test.js` primary k6 scenario script
- `README.md` this file
## Prerequisites
- Node not required (k6 runs standalone)
- k6 installed (>= v0.43 recommended)
- Prometheus + Grafana (optional) if you want to correlate with the dashboard you generated
- A running cart service exposing HTTP endpoints at (default) `http://localhost:8080/cart`
## Endpoints Exercised
The script exercises (per iteration):
1. `GET /cart` ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
2. `POST /cart` add item mutation (random SKU & quantity)
3. `GET /cart` fetch after mutations
4. `GET /checkout` occasionally (~2% of iterations) to simulate checkout start
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
## Environment Variables
| Variable | Purpose | Default |
|-----------------|----------------------------------------------|-------------------------|
| `BASE_URL` | Base URL root (either host or host/cart) | `http://localhost:8080/cart` |
| `VUS` | VUs for steady_mutations scenario | `20` |
| `DURATION` | Duration for steady_mutations scenario | `5m` |
| `RAMP_TARGET` | Peak VUs for ramp_up scenario | `50` |
You can also disable one scenario by editing `options.scenarios` inside the script.
Example run:
```bash
k6 run \
-e BASE_URL=https://cart.k6n.net/cart \
-e VUS=40 \
-e DURATION=1m \
-e RAMP_TARGET=120 \
k6/cart_load_test.js
```
## Metrics (Custom)
The script defines additional k6 metrics:
- `cart_add_item_duration` (Trend) latency of POST add item
- `cart_fetch_duration` (Trend) latency of GET cart state
- `cart_checkout_duration` (Trend) latency of checkout
- `cart_items_added` (Counter) successful add item operations
- `cart_checkout_calls` (Counter) successful checkout calls
Thresholds (in `options.thresholds`) enforce basic SLO:
- Mutation failure rate < 2%
- p90 mutation latency < 800 ms
- p99 overall HTTP latency < 1500 ms
Adjust thresholds to your environment if they trigger prematurely.
## Cookies & Stickiness
The script preserves:
- `cartid` cart identity (server sets expiry separately)
- `cartowner` owning host for sticky routing
If your load balancer or ingress enforces affinity based on these cookies, traffic will naturally concentrate on the originally claimed host for each cart under test.
## SKU Set
SKUs used (randomly selected each mutation):
```
778290 778345 778317 778277 778267 778376 778244 778384
778365 778377 778255 778286 778246 778270 778266 778285
778329 778425 778407 778418 778430 778469 778358 778351
778319 778307 778278 778251 778253 778261 778263 778273
778281 778294 778297 778302
```
To add/remove SKUs, edit the `SKUS` array. Keeping it non-empty and moderately sized helps randomization.
## Extending the Script
### Add Quantity Change
```js
function changeQuantity(itemId, newQty) {
const payload = JSON.stringify({ Id: itemId, Qty: newQty });
http.put(baseUrl() + '/', payload, { headers: headers() });
}
```
### Remove Item
```js
function removeItem(itemId) {
http.del(baseUrl() + '/' + itemId, null, { headers: headers() });
}
```
### Add Delivery
```js
function addDelivery(itemIds) {
const payload = JSON.stringify({ provider: "POSTNORD", items: itemIds });
http.post(baseUrl() + '/delivery', payload, { headers: headers() });
}
```
You can integrate these into the iteration loop with probabilities.
## Output Summary
`handleSummary` outputs a JSON summary to stdout:
- Average & p95 mutation latencies (if present)
- Fetch p95
- Checkout count
- Check statuses
Redirect or parse that output for CI pipelines.
## Running in CI
Use shorter durations (e.g. `DURATION=2m VUS=10`) to keep builds fast. Fail build on threshold breaches:
```bash
k6 run -e BASE_URL=$TARGET -e VUS=10 -e DURATION=2m k6/cart_load_test.js || exit 1
```
## Correlating with Prometheus / Grafana
During load, observe:
- `cart_mutations_total` growth and latency histograms
- Event log write rate (`cart_event_log_appends_total`)
- Pool usage (`cart_grain_pool_usage`) and spawn rate (`cart_grain_spawned_total`)
- Failure counters (`cart_mutation_failures_total`) ensure they remain low
If mutation latency spikes without high error rate, inspect external dependencies (e.g., product fetcher or Klarna endpoints).
## Common Tuning Tips
| Symptom | Potential Adjustment |
|------------------------------------|---------------------------------------------------|
| High latency p99 | Increase CPU/memory, optimize mutation handlers |
| Pool at capacity | Raise pool size argument or TTL |
| Frequent cart eviction mid-test | Confirm TTL is sliding (now 2h on mutation) |
| High replay duration | Consider snapshot + truncate event logs |
| Uneven host load | Verify `cartowner` cookie is respected upstream |
## Safety / Load Guardrails
- Start with low VUs (510) and short duration.
- Scale incrementally to find saturation points.
- If using production endpoints, coordinate off-peak runs.
## License / Attribution
This test script is tailored for your internal cart actor system; adapt freely. k6 is open-source (AGPL v3). Ensure compliance if redistributing.
---
Feel free to request:
- A variant script for spike tests
- WebSocket / long poll integration (if added later)
- Synthetic error injection harness
Happy load testing!
+244
View File
@@ -0,0 +1,244 @@
import http from "k6/http";
import { check, sleep, group } from "k6";
import { Counter, Trend } from "k6/metrics";
// ---------------- Configuration ----------------
export const options = {
// Adjust vus/duration for your environment
scenarios: {
steady_mutations: {
executor: "constant-vus",
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
duration: __ENV.DURATION || "5m",
gracefulStop: "30s",
},
ramp_up: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{
duration: "1m",
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
},
{
duration: "1m",
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
},
{ duration: "1m", target: 0 },
],
gracefulStop: "30s",
startTime: "30s",
},
},
thresholds: {
http_req_failed: ["rate<0.02"], // < 2% failures
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
"cart_add_item_duration{op:add}": ["p(90)<800"],
"cart_fetch_duration{op:get}": ["p(90)<600"],
},
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
};
// ---------------- Metrics ----------------
const addItemTrend = new Trend("cart_add_item_duration", true);
const fetchTrend = new Trend("cart_fetch_duration", true);
const checkoutTrend = new Trend("cart_checkout_duration", true);
const addedItemsCounter = new Counter("cart_items_added");
const checkoutCounter = new Counter("cart_checkout_calls");
// ---------------- SKUs ----------------
const SKUS = [
"778290",
"778345",
"778317",
"778277",
"778267",
"778376",
"778244",
"778384",
"778365",
"778377",
"778255",
"778286",
"778246",
"778270",
"778266",
"778285",
"778329",
"778425",
"778407",
"778418",
"778430",
"778469",
"778358",
"778351",
"778319",
"778307",
"778278",
"778251",
"778253",
"778261",
"778263",
"778273",
"778281",
"778294",
"778297",
"778302",
];
// ---------------- Helpers ----------------
function randomSku() {
return SKUS[Math.floor(Math.random() * SKUS.length)];
}
function randomQty() {
return 1 + Math.floor(Math.random() * 3); // 1..3
}
function baseUrl() {
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
// Allow user to pass either root host or full /cart path
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
}
function extractCookie(res, name) {
const cookies = res.cookies[name];
if (!cookies || cookies.length === 0) return null;
return cookies[0].value;
}
function withCookies(headers, cookieJar) {
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
const cookieStr = Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return { ...headers, Cookie: cookieStr };
}
// Maintain cart + owner cookies per VU
let cartState = {
cartid: null,
cartowner: null,
};
// Refresh cookies from response
function updateCookies(res) {
const cid = extractCookie(res, "cartid");
if (cid) cartState.cartid = cid;
const owner = extractCookie(res, "cartowner");
if (owner) cartState.cartowner = owner;
}
// Build headers
function headers() {
const h = { "Content-Type": "application/json" };
const jar = {};
if (cartState.cartid) jar["cartid"] = cartState.cartid;
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
return withCookies(h, jar);
}
// Ensure cart exists (GET /)
function ensureCart() {
if (cartState.cartid) return;
const res = http.get(baseUrl(), { headers: headers() });
updateCookies(res);
check(res, {
"ensure cart status 200": (r) => r.status === 200,
"ensure cart has id": () => !!cartState.cartid,
});
}
// Add random item
function addRandomItem() {
const payload = JSON.stringify({
sku: randomSku(),
quantity: randomQty(),
country: "no",
});
const start = Date.now();
const res = http.post(baseUrl(), payload, { headers: headers() });
const dur = Date.now() - start;
addItemTrend.add(dur, { op: "add" });
if (res.status === 200) {
addedItemsCounter.add(1);
}
updateCookies(res);
check(res, {
"add item status ok": (r) => r.status === 200,
});
}
// Fetch cart state
function fetchCart() {
const start = Date.now();
const res = http.get(baseUrl(), { headers: headers() });
const dur = Date.now() - start;
fetchTrend.add(dur, { op: "get" });
updateCookies(res);
check(res, { "fetch status ok": (r) => r.status === 200 });
}
// Occasional checkout trigger
function maybeCheckout() {
if (!cartState.cartid) return;
// // Small probability
// if (Math.random() < 0.02) {
// const start = Date.now();
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
// const dur = Date.now() - start;
// checkoutTrend.add(dur, { op: "checkout" });
// updateCookies(res);
// if (res.status === 200) checkoutCounter.add(1);
// check(res, { "checkout status ok": (r) => r.status === 200 });
// }
}
// ---------------- k6 lifecycle ----------------
export function setup() {
// Provide SKU list length for summary
return { skuCount: SKUS.length };
}
export default function (data) {
group("cart flow", () => {
// Create or reuse cart
ensureCart();
// Random number of item mutations per iteration (1..5)
const ops = 1 + Math.floor(Math.random() * 5);
for (let i = 0; i < ops; i++) {
addRandomItem();
}
// Fetch state
fetchCart();
// Optional checkout attempt
maybeCheckout();
});
// Small think time
sleep(Math.random() * 0.5);
}
export function teardown(data) {
// Optionally we could GET confirmation or clear cart cookie
// Not implemented for load purpose.
console.log(`Test complete. SKU count: ${data.skuCount}`);
}
// ---------------- Summary ----------------
export function handleSummary(data) {
return {
stdout: JSON.stringify(
{
metrics: {
mutations_avg: data.metrics.cart_add_item_duration?.avg,
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
checkout_count: data.metrics.cart_checkout_calls?.count,
},
checks: data.root_checks,
},
null,
2,
),
};
}
-413
View File
@@ -1,413 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
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 spawn(id CartId) (*CartGrain, error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: id,
Items: []*CartItem{},
storageMessages: []Message{},
TotalPrice: 0,
}
err := loadMessages(ret, id)
return ret, err
}
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *GrainLocalPool
storage *DiskStorage
}
func (a *App) Save() error {
hasChanges := false
a.pool.mu.RLock()
defer a.pool.mu.RUnlock()
for id, grain := range a.pool.GetGrains() {
if grain == nil {
continue
}
if grain.GetLastChange() > a.storage.LastSaves[id] {
hasChanges = true
err := a.storage.Store(id, grain)
if err != nil {
log.Printf("Error saving grain %s: %v\n", id, err)
}
}
}
if !hasChanges {
return nil
}
return a.storage.saveState()
}
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
err := a.Save()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.WriteHeader(http.StatusCreated)
}
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
func GetDiscovery() 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 NewK8sDiscovery(client)
}
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 getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
return "se"
}
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
baseUrl := fmt.Sprintf("https://%s", host)
cartBaseUrl := os.Getenv("CART_BASE_URL")
if cartBaseUrl == "" {
cartBaseUrl = "https://cart.tornberg.me"
}
country := getCountryFromHost(host)
return &messages.CreateCheckoutOrder{
Terms: fmt.Sprintf("%s/terms", baseUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
Country: country,
}
}
func main() {
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
if err != nil {
log.Printf("Error loading state: %v\n", err)
}
app := &App{
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
storage: storage,
}
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
if err != nil {
log.Fatalf("Error creating synced pool: %v\n", err)
}
hg, err := NewGrainHandler(app.pool, ":1337")
if err != nil {
log.Fatalf("Error creating handler: %v\n", err)
}
go func() {
for range time.Tick(time.Minute * 10) {
err := app.Save()
if err != nil {
log.Printf("Error saving: %v\n", err)
}
}
}()
orderHandler := &AmqpOrderHandler{
Url: amqpUrl,
}
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
// only for local
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
// syncedPool.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())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if !hg.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("handler not healthy"))
return
}
if !syncedPool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("pool not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/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
}
cartId := ToCartId(cookie.Value)
reply, err := syncedServer.pool.Process(cartId, Message{
Type: CreateCheckoutOrderType,
Content: getCheckoutOrder(r.Host, cartId),
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
err = json.Unmarshal(reply.Payload, &order)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
} else {
prevOrder, err := KlarnaInstance.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
order = prevOrder
}
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)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
})
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := KlarnaInstance.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)
w.Write([]byte(fmt.Sprintf(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 := KlarnaInstance.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(err, syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = KlarnaInstance.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"))
})
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
go syncedPool.Close()
app.Save()
done <- true
}()
go http.ListenAndServe(":8080", mux)
<-done
}
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
_, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{
Type: OrderCompletedType,
Content: &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
},
})
return err
}
func confirmOrder(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
}
return nil
}
-286
View File
@@ -1,286 +0,0 @@
package main
import (
"os"
"testing"
)
func TestGetCountryFromHost(t *testing.T) {
tests := []struct {
name string
host string
expected string
}{
{
name: "Norwegian host",
host: "s10n-no.tornberg.me",
expected: "no",
},
{
name: "Swedish host",
host: "s10n-se.tornberg.me",
expected: "se",
},
{
name: "Host with -no in the middle",
host: "api-no-staging.tornberg.me",
expected: "no",
},
{
name: "Host without country suffix",
host: "s10n.tornberg.me",
expected: "se",
},
{
name: "Host with different domain",
host: "example-no.com",
expected: "no",
},
{
name: "Empty host",
host: "",
expected: "se",
},
{
name: "Host with uppercase",
host: "S10N-NO.TORNBERG.ME",
expected: "no",
},
{
name: "Host with mixed case",
host: "S10n-No.Tornberg.Me",
expected: "no",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getCountryFromHost(tt.host)
if result != tt.expected {
t.Errorf("getCountryFromHost(%q) = %q, want %q", tt.host, result, tt.expected)
}
})
}
}
func TestGetCheckoutOrder(t *testing.T) {
// Save original environment variable and restore after test
originalCartBaseUrl := os.Getenv("CART_BASE_URL")
defer func() {
if originalCartBaseUrl == "" {
os.Unsetenv("CART_BASE_URL")
} else {
os.Setenv("CART_BASE_URL", originalCartBaseUrl)
}
}()
tests := []struct {
name string
host string
cartId CartId
cartBaseUrl string
expectedUrls struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}
}{
{
name: "Norwegian host with default cart base URL",
host: "s10n-no.tornberg.me",
cartId: ToCartId("test-cart-123"),
cartBaseUrl: "", // Use default
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-no.tornberg.me/terms",
checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "no",
},
},
{
name: "Swedish host with default cart base URL",
host: "s10n-se.tornberg.me",
cartId: ToCartId("test-cart-456"),
cartBaseUrl: "", // Use default
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-se.tornberg.me/terms",
checkout: "https://s10n-se.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-se.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "se",
},
},
{
name: "Norwegian host with custom cart base URL",
host: "s10n-no.tornberg.me",
cartId: ToCartId("test-cart-789"),
cartBaseUrl: "https://custom-cart.example.com",
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-no.tornberg.me/terms",
checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://custom-cart.example.com/validation",
push: "https://custom-cart.example.com/push?order_id={checkout.order.id}",
country: "no",
},
},
{
name: "Host without country code defaults to Swedish",
host: "s10n.tornberg.me",
cartId: ToCartId("test-cart-default"),
cartBaseUrl: "",
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n.tornberg.me/terms",
checkout: "https://s10n.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "se",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment variable for this test
if tt.cartBaseUrl == "" {
os.Unsetenv("CART_BASE_URL")
} else {
os.Setenv("CART_BASE_URL", tt.cartBaseUrl)
}
result := getCheckoutOrder(tt.host, tt.cartId)
// Verify the result is not nil
if result == nil {
t.Fatal("getCheckoutOrder returned nil")
}
// Check each URL field
if result.Terms != tt.expectedUrls.terms {
t.Errorf("Terms URL: got %q, want %q", result.Terms, tt.expectedUrls.terms)
}
if result.Checkout != tt.expectedUrls.checkout {
t.Errorf("Checkout URL: got %q, want %q", result.Checkout, tt.expectedUrls.checkout)
}
if result.Confirmation != tt.expectedUrls.confirmation {
t.Errorf("Confirmation URL: got %q, want %q", result.Confirmation, tt.expectedUrls.confirmation)
}
if result.Validation != tt.expectedUrls.validation {
t.Errorf("Validation URL: got %q, want %q", result.Validation, tt.expectedUrls.validation)
}
if result.Push != tt.expectedUrls.push {
t.Errorf("Push URL: got %q, want %q", result.Push, tt.expectedUrls.push)
}
if result.Country != tt.expectedUrls.country {
t.Errorf("Country: got %q, want %q", result.Country, tt.expectedUrls.country)
}
})
}
}
func TestGetCheckoutOrderIntegration(t *testing.T) {
// Test that both functions work together correctly
hosts := []string{"s10n-no.tornberg.me", "s10n-se.tornberg.me"}
cartId := ToCartId("integration-test-cart")
for _, host := range hosts {
t.Run(host, func(t *testing.T) {
// Get country from host
country := getCountryFromHost(host)
// Get checkout order
order := getCheckoutOrder(host, cartId)
// Verify that the country in the order matches what getCountryFromHost returns
if order.Country != country {
t.Errorf("Country mismatch: getCountryFromHost(%q) = %q, but order.Country = %q",
host, country, order.Country)
}
// Verify that all URLs contain the correct host
expectedBaseUrl := "https://" + host
if !containsPrefix(order.Terms, expectedBaseUrl) {
t.Errorf("Terms URL should start with %q, got %q", expectedBaseUrl, order.Terms)
}
if !containsPrefix(order.Checkout, expectedBaseUrl) {
t.Errorf("Checkout URL should start with %q, got %q", expectedBaseUrl, order.Checkout)
}
if !containsPrefix(order.Confirmation, expectedBaseUrl) {
t.Errorf("Confirmation URL should start with %q, got %q", expectedBaseUrl, order.Confirmation)
}
})
}
}
// Helper function to check if a string starts with a prefix
func containsPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
// Benchmark tests to measure performance
func BenchmarkGetCountryFromHost(b *testing.B) {
hosts := []string{
"s10n-no.tornberg.me",
"s10n-se.tornberg.me",
"api-no-staging.tornberg.me",
"s10n.tornberg.me",
}
for i := 0; i < b.N; i++ {
for _, host := range hosts {
getCountryFromHost(host)
}
}
}
func BenchmarkGetCheckoutOrder(b *testing.B) {
host := "s10n-no.tornberg.me"
cartId := ToCartId("benchmark-cart")
for i := 0; i < b.N; i++ {
getCheckoutOrder(host, cartId)
}
}
-315
View File
@@ -1,315 +0,0 @@
package main
import (
"fmt"
"io"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/protobuf/proto"
)
var Handlers = map[uint16]MessageHandler{
AddRequestType: &AddRequestHandler{},
AddItemType: &AddItemHandler{},
ChangeQuantityType: &ChangeQuantityHandler{},
SetDeliveryType: &SetDeliveryHandler{},
RemoveItemType: &RemoveItemHandler{},
RemoveDeliveryType: &RemoveDeliveryHandler{},
CreateCheckoutOrderType: &CheckoutHandler{},
SetCartItemsType: &SetCartItemsHandler{},
OrderCompletedType: &OrderCompletedHandler{},
}
func GetMessageHandler(t uint16) (MessageHandler, error) {
h, ok := Handlers[t]
if !ok {
return nil, fmt.Errorf("no handler for message type %d", t)
}
return h, nil
}
type MessageHandler interface {
Write(*Message, io.Writer) error
Read(data []byte) (interface{}, error)
Is(*Message) bool
}
type TypedMessageHandler struct {
Type uint16
}
type SetCartItemsHandler struct {
TypedMessageHandler
}
func (h *SetCartItemsHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.SetCartRequest))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *SetCartItemsHandler) Read(data []byte) (interface{}, error) {
msg := &messages.SetCartRequest{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *SetCartItemsHandler) Is(m *Message) bool {
if m.Type != AddRequestType {
return false
}
_, ok := m.Content.(*messages.SetCartRequest)
return ok
}
type AddRequestHandler struct {
TypedMessageHandler
}
func (h *AddRequestHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.AddRequest))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *AddRequestHandler) Read(data []byte) (interface{}, error) {
msg := &messages.AddRequest{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *AddRequestHandler) Is(m *Message) bool {
if m.Type != AddRequestType {
return false
}
_, ok := m.Content.(*messages.AddRequest)
return ok
}
type AddItemHandler struct {
TypedMessageHandler
}
func (h *AddItemHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.AddItem))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *AddItemHandler) Read(data []byte) (interface{}, error) {
msg := &messages.AddItem{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *AddItemHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.AddItem)
return ok
}
type ChangeQuantityHandler struct {
TypedMessageHandler
}
func (h *ChangeQuantityHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.ChangeQuantity))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *ChangeQuantityHandler) Read(data []byte) (interface{}, error) {
msg := &messages.ChangeQuantity{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *ChangeQuantityHandler) Is(m *Message) bool {
if m.Type != ChangeQuantityType {
return false
}
_, ok := m.Content.(*messages.ChangeQuantity)
return ok
}
type SetDeliveryHandler struct {
TypedMessageHandler
}
func (h *SetDeliveryHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.SetDelivery))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *SetDeliveryHandler) Read(data []byte) (interface{}, error) {
msg := &messages.SetDelivery{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *SetDeliveryHandler) Is(m *Message) bool {
if m.Type != ChangeQuantityType {
return false
}
_, ok := m.Content.(*messages.SetDelivery)
return ok
}
type RemoveItemHandler struct {
TypedMessageHandler
}
func (h *RemoveItemHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveItem))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *RemoveItemHandler) Read(data []byte) (interface{}, error) {
msg := &messages.RemoveItem{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *RemoveItemHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.RemoveItem)
return ok
}
type RemoveDeliveryHandler struct {
TypedMessageHandler
}
func (h *RemoveDeliveryHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveDelivery))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *RemoveDeliveryHandler) Read(data []byte) (interface{}, error) {
msg := &messages.RemoveDelivery{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *RemoveDeliveryHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.RemoveDelivery)
return ok
}
type CheckoutHandler struct {
TypedMessageHandler
}
func (h *CheckoutHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.CreateCheckoutOrder))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *CheckoutHandler) Read(data []byte) (interface{}, error) {
msg := &messages.CreateCheckoutOrder{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *CheckoutHandler) Is(m *Message) bool {
if m.Type != CreateCheckoutOrderType {
return false
}
_, ok := m.Content.(*messages.CreateCheckoutOrder)
return ok
}
type OrderCompletedHandler struct {
TypedMessageHandler
}
func (h *OrderCompletedHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.OrderCreated))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *OrderCompletedHandler) Read(data []byte) (interface{}, error) {
msg := &messages.OrderCreated{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *OrderCompletedHandler) Is(m *Message) bool {
if m.Type != OrderCompletedType {
return false
}
_, ok := m.Content.(*messages.OrderCreated)
return ok
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"bytes"
"testing"
messages "git.tornberg.me/go-cart-actor/proto"
)
func TestAddRequest(t *testing.T) {
h, err := GetMessageHandler(AddRequestType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: AddRequestType,
Content: &messages.AddRequest{
Quantity: 2,
Sku: "123",
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
r, ok := result.(*messages.AddRequest)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if r.Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", r.Quantity)
}
if r.Sku != "123" {
t.Errorf("Expected sku '123', got %s\n", r.Sku)
}
}
func TestItemRequest(t *testing.T) {
h, err := GetMessageHandler(AddItemType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: AddItemType,
Content: &messages.AddItem{
Quantity: 2,
Sku: "123",
Price: 100,
Name: "Test item",
Image: "test.jpg",
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
var r *messages.AddItem
ok := h.Is(&message)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if r.Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", r.Quantity)
}
if r.Sku != "123" {
t.Errorf("Expected sku '123', got %s\n", r.Sku)
}
}
func TestSetDeliveryMssage(t *testing.T) {
h, err := GetMessageHandler(SetDeliveryType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: SetDeliveryType,
Content: &messages.SetDelivery{
Provider: "test",
Items: []int64{1, 2},
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
r, ok := result.(*messages.SetDelivery)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if len(r.Items) != 2 {
t.Errorf("Expected 2 items, got %d\n", len(r.Items))
}
if r.Provider != "test" {
t.Errorf("Expected provider 'test', got %s\n", r.Provider)
}
}
-15
View File
@@ -1,15 +0,0 @@
package main
const (
AddRequestType = 1
AddItemType = 2
RemoveItemType = 4
RemoveDeliveryType = 5
ChangeQuantityType = 6
SetDeliveryType = 7
SetPickupPointType = 8
CreateCheckoutOrderType = 9
SetCartItemsType = 10
OrderCompletedType = 11
)
-94
View File
@@ -1,94 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"io"
"time"
)
type StorableMessage interface {
Write(w io.Writer) error
}
type Message struct {
Type uint16
TimeStamp *int64
Content interface{}
}
type MessageWriter struct {
io.Writer
}
type StorableMessageHeader struct {
Version uint16
Type uint16
TimeStamp int64
DataLength uint64
}
func GetData(fn func(w io.Writer) error) ([]byte, error) {
var buf bytes.Buffer
err := fn(&buf)
if err != nil {
return nil, err
}
b := buf.Bytes()
return b, nil
}
func (m Message) Write(w io.Writer) error {
h, err := GetMessageHandler(m.Type)
if err != nil {
return err
}
data, err := GetData(func(w io.Writer) error {
return h.Write(&m, w)
})
if err != nil {
return err
}
ts := time.Now().Unix()
if m.TimeStamp != nil {
ts = *m.TimeStamp
}
err = binary.Write(w, binary.LittleEndian, StorableMessageHeader{
Version: 1,
Type: m.Type,
TimeStamp: ts,
DataLength: uint64(len(data)),
})
w.Write(data)
return err
}
func ReadMessage(reader io.Reader, m *Message) error {
header := StorableMessageHeader{}
err := binary.Read(reader, binary.LittleEndian, &header)
if err != nil {
return err
}
messageBytes := make([]byte, header.DataLength)
_, err = reader.Read(messageBytes)
if err != nil {
return err
}
h, err := GetMessageHandler(header.Type)
if err != nil {
return err
}
content, err := h.Read(messageBytes)
if err != nil {
return err
}
m.Content = content
m.Type = header.Type
m.TimeStamp = &header.TimeStamp
return nil
}
-89
View File
@@ -1,89 +0,0 @@
package main
const (
RemoteGetState = FrameType(0x01)
RemoteHandleMutation = FrameType(0x02)
ResponseBody = FrameType(0x03)
RemoteGetStateReply = FrameType(0x04)
RemoteHandleMutationReply = FrameType(0x05)
RemoteCreateOrderReply = FrameType(0x06)
)
// type CartPacket struct {
// Version PackageVersion
// MessageType CartMessage
// DataLength uint32
// StatusCode uint32
// Id CartId
// }
// type Packet struct {
// Version PackageVersion
// MessageType PoolMessage
// DataLength uint32
// StatusCode uint32
// }
// var headerData = make([]byte, 4)
// func matchHeader(conn io.Reader) error {
// pos := 0
// for pos < 4 {
// l, err := conn.Read(headerData)
// if err != nil {
// return err
// }
// for i := 0; i < l; i++ {
// if headerData[i] == header[pos] {
// pos++
// if pos == 4 {
// return nil
// }
// } else {
// pos = 0
// }
// }
// }
// return nil
// }
// func ReadPacket(conn io.Reader, packet *Packet) error {
// err := matchHeader(conn)
// if err != nil {
// return err
// }
// return binary.Read(conn, binary.LittleEndian, packet)
// }
// func ReadCartPacket(conn io.Reader, packet *CartPacket) error {
// err := matchHeader(conn)
// if err != nil {
// return err
// }
// return binary.Read(conn, binary.LittleEndian, packet)
// }
// func GetPacketData(conn io.Reader, len uint32) ([]byte, error) {
// if len == 0 {
// return []byte{}, nil
// }
// data := make([]byte, len)
// _, err := conn.Read(data)
// return data, err
// }
// func ReceivePacket(conn io.Reader) (uint32, []byte, error) {
// var packet Packet
// err := ReadPacket(conn, &packet)
// if err != nil {
// return 0, nil, err
// }
// data, err := GetPacketData(conn, packet.DataLength)
// if err != nil {
// return 0, nil, err
// }
// return packet.MessageType, data, nil
// }
+131
View File
@@ -0,0 +1,131 @@
package actor
import (
"crypto/rand"
"encoding/json"
"fmt"
)
type GrainId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id GrainId) String() string {
return encodeBase62(uint64(id))
}
// MarshalJSON encodes the cart id as a JSON string.
func (id GrainId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (id *GrainId) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseGrainId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
}
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
func NewGrainId() (GrainId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewGrainId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewGrainId()
}
return GrainId(u), nil
}
// MustNewGrainId panics if generation fails.
func MustNewGrainId() GrainId {
id, err := NewGrainId()
if err != nil {
panic(err)
}
return id
}
// ParseGrainId parses a base62 string into a GrainId.
// Returns (0,false) for invalid input.
func ParseGrainId(s string) (GrainId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return GrainId(u), true
}
// MustParseGrainId panics on invalid base62 input.
func MustParseGrainId(s string) GrainId {
id, ok := ParseGrainId(s)
if !ok {
panic(fmt.Sprintf("invalid cart id: %q", s))
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}
+162
View File
@@ -0,0 +1,162 @@
package actor
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"google.golang.org/protobuf/proto"
)
type QueueEvent struct {
TimeStamp time.Time
Message proto.Message
}
type DiskStorage[V any] struct {
*StateStorage
path string
done chan struct{}
queue *sync.Map // map[uint64][]QueueEvent
}
type LogStorage[V any] interface {
LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error
LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error
AppendMutations(id uint64, msg ...proto.Message) error
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
return &DiskStorage[V]{
StateStorage: NewState(registry),
path: path,
done: make(chan struct{}),
}
}
func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
s.queue = &sync.Map{}
ticker := time.NewTicker(duration)
defer ticker.Stop()
for {
select {
case <-s.done:
s.save()
return
case <-ticker.C:
s.save()
}
}
}
func (s *DiskStorage[V]) save() {
carts := 0
lines := 0
s.queue.Range(func(key, value any) bool {
id := key.(uint64)
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return true
}
defer fh.Close()
if qe, ok := value.([]QueueEvent); ok {
for _, msg := range qe {
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
log.Printf("failed to append event to log file: %v", err)
}
lines++
}
}
carts++
s.queue.Delete(id)
return true
})
if lines > 0 {
log.Printf("Appended %d carts and %d lines to disk", carts, lines)
}
}
func (s *DiskStorage[V]) logPath(id uint64) string {
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
}
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
path := s.logPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No log -> nothing to replay
return nil
}
fh, err := os.Open(path)
if err != nil {
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
index := 0
return s.Load(fh, func(msg proto.Message, when time.Time) {
if condition(msg, index, when) {
s.registry.Apply(ctx, grain, msg)
}
index++
})
}
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
return nil
}
fh, err := os.Open(path)
if err != nil {
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
return s.Load(fh, func(msg proto.Message, _ time.Time) {
s.registry.Apply(ctx, grain, msg)
})
}
func (s *DiskStorage[V]) Close() {
if s.queue != nil {
s.save()
}
close(s.done)
}
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)
if found {
queue = data.([]QueueEvent)
}
for _, m := range msg {
queue = append(queue, QueueEvent{Message: m, TimeStamp: time.Now()})
}
s.queue.Store(id, queue)
return nil
} else {
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return err
}
defer fh.Close()
for _, m := range msg {
err = s.Append(fh, m, time.Now())
}
return err
}
}
+13
View File
@@ -0,0 +1,13 @@
package actor
import (
"time"
)
type Grain[V any] interface {
GetId() uint64
GetLastAccess() time.Time
GetLastChange() time.Time
GetCurrentState() (*V, error)
}
+46
View File
@@ -0,0 +1,46 @@
package actor
import (
"context"
"io"
"net/http"
"google.golang.org/protobuf/proto"
)
type MutationResult[V any] struct {
Result V `json:"result"`
Mutations []ApplyResult `json:"mutations,omitempty"`
}
type GrainPool[V any] interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(ctx context.Context, id uint64) (*V, error)
OwnerHost(id uint64) (Host[V], bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
IsHealthy() bool
Close()
IsKnown(string) bool
RemoveHost(host string)
AddRemoteHost(host string)
}
// Host abstracts a remote node capable of proxying cart requests.
type Host[V any] interface {
AnnounceExpiry(ids []uint64)
Negotiate(otherHosts []string) ([]string, error)
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(ctx context.Context, id uint64) (*V, error)
GetActorIds() []uint64
Close() error
Ping() bool
IsHealthy() bool
AnnounceOwnership(ownerHost string, ids []uint64)
}
+301
View File
@@ -0,0 +1,301 @@
package actor
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"time"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// ControlServer implements the ControlPlane gRPC services.
// 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",
}, err
}
// log.Printf("Ack count: %d", len(req.Ids))
return &messages.OwnerChangeAck{
Accepted: true,
Message: "ownership announced",
}, nil
}
func toAny[V any](grain V) (*anypb.Any, error) {
data, err := json.Marshal(grain)
if err != nil {
return nil, err
}
return &anypb.Any{
Value: data,
}, nil
}
func (s *ControlServer[V]) Get(ctx context.Context, req *messages.GetRequest) (*messages.GetReply, error) {
grain, err := s.pool.Get(ctx, req.Id)
if err != nil {
return nil, err
}
grainAny, err := toAny(grain)
if err != nil {
return nil, err
}
return &messages.GetReply{
Grain: grainAny,
}, nil
}
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
defer span.End()
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",
}, nil
}
// ControlPlane: Ping
func (s *ControlServer[V]) Ping(ctx context.Context, req *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: host,
UnixTime: time.Now().Unix(),
}, nil
}
func (s *ControlServer[V]) Apply(ctx context.Context, in *messages.ApplyRequest) (*messages.ApplyResult, error) {
msgs := make([]proto.Message, len(in.Messages))
for i, anyMsg := range in.Messages {
msg, err := anyMsg.UnmarshalNew()
if err != nil {
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
}
msgs[i] = msg
}
r, err := s.pool.Apply(ctx, in.Id, msgs...)
if err != nil {
return nil, err
}
grainAny, err := toAny(r)
if err != nil {
return nil, err
}
mutList := make([]*messages.MutationResult, len(in.Messages))
for i, msg := range r.Mutations {
mut, err := anypb.New(msg.Mutation)
if err != nil {
return nil, err
}
var errString *string
if msg.Error != nil {
s := msg.Error.Error()
errString = &s
}
mutList[i] = &messages.MutationResult{
Type: msg.Type,
Message: mut,
Error: errString,
}
}
return &messages.ApplyResult{
State: grainAny,
Mutations: mutList,
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
ctx, span := tracer.Start(ctx, "grpc_negotiate")
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
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.Empty) (*messages.ActorIdsReply, error) {
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
defer span.End()
ids := s.pool.GetLocalIds()
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) {
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,
Message: "removed host",
}, nil
}
type ServerConfig struct {
Addr string
Options []grpc.ServerOption
}
func NewServerConfig(addr string, options ...grpc.ServerOption) ServerConfig {
return ServerConfig{
Addr: addr,
Options: options,
}
}
func DefaultServerConfig() ServerConfig {
return NewServerConfig(":1337")
}
// StartGRPCServer configures and starts the unified gRPC server on the given address.
// It registers both the CartActor and ControlPlane services.
func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Server, error) {
lis, err := net.Listen("tcp", config.Addr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
grpcServer := grpc.NewServer(config.Options...)
server := &ControlServer[V]{
pool: pool,
}
messages.RegisterControlPlaneServer(grpcServer, server)
reflection.Register(grpcServer)
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
return grpcServer, nil
}
+150
View File
@@ -0,0 +1,150 @@
package actor
import (
"context"
"testing"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// MockGrainPool for testing
type mockGrainPool struct {
applied []proto.Message
}
func (m *mockGrainPool) Apply(ctx context.Context, id uint64, mutations ...proto.Message) (*MutationResult[mockGrain], error) {
m.applied = mutations
// Simulate successful application
return &MutationResult[mockGrain]{
Result: mockGrain{},
Mutations: []ApplyResult{
{Type: "AddItem", Mutation: &cart_messages.AddItem{ItemId: 1, Quantity: 2}, Error: nil},
{Type: "RemoveItem", Mutation: &cart_messages.RemoveItem{Id: 1}, Error: nil},
},
}, nil
}
func (m *mockGrainPool) Get(ctx context.Context, id uint64) (*mockGrain, error) {
return &mockGrain{}, nil
}
func (m *mockGrainPool) OwnerHost(id uint64) (Host[mockGrain], bool) {
return nil, false
}
func (m *mockGrainPool) TakeOwnership(id uint64) {}
func (m *mockGrainPool) Hostname() string { return "test-host" }
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) Negotiate(hosts []string) {}
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
func (m *mockGrainPool) RemoveHost(host string) {}
func (m *mockGrainPool) AddRemoteHost(host string) {}
func (m *mockGrainPool) IsHealthy() bool { return true }
func (m *mockGrainPool) IsKnown(host string) bool { return false }
func (m *mockGrainPool) Close() {}
type mockGrain struct{}
func TestApplyRequestWithMutations(t *testing.T) {
// Setup mock pool
pool := &mockGrainPool{}
// Create gRPC server
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
defer server.GracefulStop()
// Create client connection
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
defer conn.Close()
client := control_plane_messages.NewControlPlaneClient(conn)
// Prepare ApplyRequest with multiple Any messages
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
req := &control_plane_messages.ApplyRequest{
Id: 123,
Messages: []*anypb.Any{addItemAny, removeItemAny},
}
// Call Apply
resp, err := client.Apply(context.Background(), req)
if err != nil {
t.Fatalf("Apply failed: %v", err)
}
// Verify response
if resp.State == nil {
t.Errorf("expected State to be non-nil")
}
if len(resp.Mutations) != 2 {
t.Errorf("expected 2 mutation results, got %d", len(resp.Mutations))
}
for i, mut := range resp.Mutations {
if mut.Error != nil {
t.Errorf("expected no error in mutation %d, got %s", i, *mut.Error)
}
}
// Verify mutations were extracted and applied
if len(pool.applied) != 2 {
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
}
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
}
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
}
}
func TestGetRequest(t *testing.T) {
// Setup mock pool
pool := &mockGrainPool{}
// Create gRPC server
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
defer server.GracefulStop()
// Create client connection
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
defer conn.Close()
client := control_plane_messages.NewControlPlaneClient(conn)
// Prepare GetRequest
req := &control_plane_messages.GetRequest{
Id: 123,
}
// Call Get
resp, err := client.Get(context.Background(), req)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
// Verify response
if resp.Grain == nil {
t.Errorf("expected Grain to be non-nil")
}
}
+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)
}
}
+361
View File
@@ -0,0 +1,361 @@
package actor
import (
"context"
"fmt"
"log"
"reflect"
"sync"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/protobuf/proto"
)
type ApplyResult struct {
Type string `json:"type"`
Mutation proto.Message `json:"mutation"`
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(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)
RegisterTrigger(trigger ...TriggerHandler)
SetEventChannel(ch chan<- ApplyResult)
}
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
triggers map[reflect.Type][]TriggerHandler
processors []MutationProcessor
eventChannel chan<- ApplyResult
}
var (
ErrMutationNotRegistered = &MutationError{
Message: "mutation not registered",
Code: 255,
StatusCode: 500,
}
)
type MutationError struct {
Message string `json:"message"`
Code uint32 `json:"code"`
StatusCode uint32 `json:"status_code"`
}
func (m MutationError) Error() string {
return m.Message
}
// MutationOption configures additional behavior for a registered mutation.
type MutationOption func(*mutationOptions)
// mutationOptions holds flags adjustable per registration.
type mutationOptions struct {
updateTotals bool
}
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
func WithTotals() MutationOption {
return func(o *mutationOptions) {
o.updateTotals = true
}
}
type TriggerHandler interface {
Handle(state any, msg proto.Message) []proto.Message
Name() string
Type() reflect.Type
}
type RegisteredTrigger[V any, I proto.Message] struct {
name string
handler func(state any, msg proto.Message) []proto.Message
msgType reflect.Type
}
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
return &RegisteredTrigger[V, I]{
name: name,
handler: handler,
msgType: reflect.TypeOf((*I)(nil)).Elem(),
}
}
type MutationHandler interface {
Handle(state any, msg proto.Message) error
Name() string
Type() reflect.Type
Create() proto.Message
}
// RegisteredMutation stores metadata + the execution closure.
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() proto.Message
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
create := func() proto.Message {
var t T
rt := reflect.TypeOf(t)
if rt != nil && rt.Kind() == reflect.Pointer {
return reflect.New(rt.Elem()).Interface().(proto.Message)
}
log.Fatalf("expected to create proto message got %+v", rt)
return nil
}
instance := create()
rt := reflect.TypeOf(instance)
if rt.Kind() == reflect.Pointer {
rt = rt.Elem()
}
return &RegisteredMutation[V, T]{
name: rt.Name(),
handler: handler,
create: create,
msgType: rt,
}
}
func (m *RegisteredMutation[V, T]) Handle(state any, msg proto.Message) error {
return m.handler(state.(*V), msg.(T))
}
func (m *RegisteredMutation[V, T]) Name() string {
return m.name
}
func (m *RegisteredMutation[V, T]) Create() proto.Message {
return m.create()
}
func (m *RegisteredMutation[V, T]) Type() reflect.Type {
return m.msgType
}
func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
triggers: make(map[reflect.Type][]TriggerHandler),
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()
for _, handler := range handlers {
r.mutationRegistry[handler.Type()] = handler
}
}
func (r *ProtoMutationRegistry) RegisterTrigger(triggers ...TriggerHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, trigger := range triggers {
existingTriggers, ok := r.triggers[trigger.Type()]
if !ok {
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
} else {
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
}
}
}
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
r.eventChannel = ch
}
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
rt := indirectType(reflect.TypeOf(msg))
if handler, ok := r.mutationRegistry[rt]; ok {
return handler.Name(), true
}
return "", false
}
func (r *ProtoMutationRegistry) getHandler(typeName string) MutationHandler {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, handler := range r.mutationRegistry {
if handler.Name() == typeName {
return handler
}
}
return nil
}
func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
handler := r.getHandler(typeName)
if handler == nil {
log.Printf("missing handler for %s", typeName)
return nil, false
}
return handler.Create(), true
}
// ApplyRegistered attempts to apply a registered mutation.
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
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 {
// Error if any mutation element is nil.
if m == nil {
return results, fmt.Errorf("nil mutation message")
}
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
rv := reflect.ValueOf(m)
if rv.Kind() == reflect.Pointer && 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)
}
if r.eventChannel != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Handle panic from sending to closed channel
log.Printf("event channel closed: %v", r)
}
}()
for _, tr := range r.triggers[rt] {
for _, msg := range tr.Handle(grain, m) {
select {
case r.eventChannel <- msg:
default:
// Channel full or no receiver, skip to avoid blocking
}
}
}
}()
}
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
msgSpan.End()
}
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 error for unregistered mutations
for _, res := range results {
if res.Error == ErrMutationNotRegistered {
return results, res.Error
}
}
return results, nil
}
// RegisteredMutations returns metadata for all registered mutations (snapshot).
func (r *ProtoMutationRegistry) RegisteredMutations() []string {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]string, 0, len(r.mutationRegistry))
for _, entry := range r.mutationRegistry {
out = append(out, entry.Name())
}
return out
}
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
// Useful for coverage tests ensuring expected set matches actual set.
func (r *ProtoMutationRegistry) RegisteredMutationTypes() []reflect.Type {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]reflect.Type, 0, len(r.mutationRegistry))
for _, entry := range r.mutationRegistry {
out = append(out, entry.Type())
}
return out
}
func indirectType(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}
+207
View File
@@ -0,0 +1,207 @@
package actor
import (
"context"
"reflect"
"slices"
"testing"
"time"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
)
type cartState struct {
calls int
lastAdded *cart_messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := msg
state.lastAdded = cp
return nil
},
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
reg.RegisterMutations(addItemMutation)
// RegisteredMutations: membership (order not guaranteed)
names := reg.RegisteredMutations()
if !slices.Contains(names, "AddItem") {
t.Fatalf("RegisteredMutations missing AddItem, got %v", names)
}
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
// Create by name
msg, ok := reg.Create("AddItem")
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
// Create unknown
if m2, ok := reg.Create("Unknown"); ok || m2 != nil {
t.Fatalf("Create should fail for unknown mutation, got (%T,%v)", m2, ok)
}
// Apply happy path
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(context.Background(), state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if state.calls != 1 {
t.Fatalf("handler not invoked expected calls=1 got=%d", state.calls)
}
if state.lastAdded == nil || state.lastAdded.ItemId != 42 || state.lastAdded.Quantity != 3 {
t.Fatalf("state not updated correctly: %+v", state.lastAdded)
}
// Apply nil grain
if _, err := reg.Apply(context.Background(), nil, add); err == nil {
t.Fatalf("expected error for nil grain")
}
// Apply nil message
if _, err := reg.Apply(context.Background(), state, nil); err == nil {
t.Fatalf("expected error for nil mutation message")
}
// Apply unregistered message
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
if err != ErrMutationNotRegistered {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}
func TestEventChannel(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult, 10)
reg.SetEventChannel(eventCh)
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Receive from channel with timeout
select {
case res := <-eventCh:
if res.Type != "AddItem" {
t.Fatalf("expected type AddItem, got %s", res.Type)
}
if res.Error != nil {
t.Fatalf("expected no error, got %v", res.Error)
}
case <-time.After(time.Second):
t.Fatalf("expected to receive event on channel within timeout")
}
}
func TestEventChannelClosed(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult, 10)
reg.SetEventChannel(eventCh)
close(eventCh) // Close the channel to simulate external close
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
// This should not panic due to recover in goroutine
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Test passes if no panic occurs
}
func TestEventChannelUnbufferedNoListener(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult) // unbuffered
reg.SetEventChannel(eventCh)
// No goroutine reading from eventCh
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Since no listener, the send should go to default and not block
// Test passes if Apply completes without hanging
}
// Helpers
+78
View File
@@ -0,0 +1,78 @@
package actor
import (
"iter"
"slices"
"sync"
)
type ReceiverFunc[V any] func(event V)
type PubSub[V any] struct {
subscribers []*ReceiverFunc[V]
mu sync.RWMutex
}
// NewPubSub creates a new PubSub instance.
func NewPubSub[V any]() *PubSub[V] {
return &PubSub[V]{
subscribers: make([]*ReceiverFunc[V], 0),
}
}
// Subscribe adds a grain ID to the subscribers of a topic.
func (p *PubSub[V]) Subscribe(receiver ReceiverFunc[V]) {
if receiver == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
// log.Printf("adding subscriber")
p.subscribers = append(p.subscribers, &receiver)
}
// Unsubscribe removes a grain ID from the subscribers of a topic.
func (p *PubSub[V]) Unsubscribe(receiver ReceiverFunc[V]) {
p.mu.Lock()
defer p.mu.Unlock()
list := p.subscribers
prt := &receiver
for i, sub := range list {
if sub == nil {
continue
}
if sub == prt {
// log.Printf("removing subscriber")
p.subscribers = append(list[:i], list[i+1:]...)
break
}
}
p.subscribers = slices.DeleteFunc(p.subscribers, func(fn *ReceiverFunc[V]) bool {
return fn == nil
})
// If list is empty, could delete, but not necessary
}
// GetSubscribers returns a copy of the subscriber IDs for a topic.
func (p *PubSub[V]) GetSubscribers() iter.Seq[ReceiverFunc[V]] {
p.mu.RLock()
defer p.mu.RUnlock()
return func(yield func(ReceiverFunc[V]) bool) {
for _, sub := range p.subscribers {
if sub == nil {
continue
}
if !yield(*sub) {
return
}
}
}
}
// Publish sends an event to all subscribers of the topic.
func (p *PubSub[V]) Publish(event V) {
for notify := range p.GetSubscribers() {
notify(event)
}
}
+462
View File
@@ -0,0 +1,462 @@
package actor
import (
"context"
"fmt"
"log"
"maps"
"sync"
"time"
"google.golang.org/protobuf/proto"
)
type SimpleGrainPool[V any] struct {
// fields and methods
localMu sync.RWMutex
grains map[uint64]Grain[V]
mutationRegistry MutationRegistry
spawn func(ctx context.Context, id uint64) (Grain[V], error)
destroy func(grain Grain[V]) error
spawnHost func(host string) (Host[V], error)
listeners []LogListener
storage LogStorage[V]
ttl time.Duration
poolSize int
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]Host[V]
remoteHosts map[string]Host[V]
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
purgeTicker *time.Ticker
}
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
SpawnHost func(host string) (Host[V], error)
Destroy func(grain Grain[V]) error
TTL time.Duration
PoolSize int
MutationRegistry MutationRegistry
Storage LogStorage[V]
}
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
p := &SimpleGrainPool[V]{
grains: make(map[uint64]Grain[V]),
mutationRegistry: config.MutationRegistry,
storage: config.Storage,
spawn: config.Spawn,
spawnHost: config.SpawnHost,
destroy: config.Destroy,
ttl: config.TTL,
poolSize: config.PoolSize,
hostname: config.Hostname,
remoteOwners: make(map[uint64]Host[V]),
remoteHosts: make(map[string]Host[V]),
}
p.purgeTicker = time.NewTicker(time.Minute)
go func() {
for range p.purgeTicker.C {
p.purge()
}
}()
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))
p.localMu.Lock()
for id, grain := range p.grains {
if grain.GetLastAccess().Before(purgeLimit) {
purgedIds = append(purgedIds, id)
if err := p.destroy(grain); err != nil {
log.Printf("failed to destroy grain %d: %v", id, err)
}
delete(p.grains, id)
}
}
p.localMu.Unlock()
p.forAllHosts(func(remote Host[V]) {
remote.AnnounceExpiry(purgedIds)
})
}
// LocalUsage returns the number of resident grains and configured capacity.
func (p *SimpleGrainPool[V]) LocalUsage() (int, int) {
p.localMu.RLock()
defer p.localMu.RUnlock()
return len(p.grains), p.poolSize
}
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
p.localMu.RLock()
defer p.localMu.RUnlock()
ids := make([]uint64, 0, len(p.grains))
for _, g := range p.grains {
if g == nil {
continue
}
ids = append(ids, uint64(g.GetId()))
}
return ids
}
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, id := range ids {
delete(p.remoteOwners, id)
}
return nil
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !exists {
createdHost, err := p.AddRemote(host)
if err != nil {
return err
}
remoteHost = createdHost
}
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
p.localMu.Lock()
defer p.localMu.Unlock()
for _, id := range ids {
log.Printf("Handling ownership change for cart %d to host %s", id, host)
delete(p.grains, id)
p.remoteOwners[id] = remoteHost
}
return nil
}
// TakeOwnership takes ownership of a grain.
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[V], error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
}
if host == p.hostname {
return nil, fmt.Errorf("same host, this should not happen")
}
p.remoteMu.RLock()
existing, found := p.remoteHosts[host]
p.remoteMu.RUnlock()
if found {
return existing, nil
}
remote, err := p.spawnHost(host)
if err != nil {
log.Printf("AddRemote %s failed: %v", host, err)
return nil, err
}
p.remoteMu.Lock()
p.remoteHosts[host] = remote
p.remoteMu.Unlock()
// connectedRemotes.Set(float64(p.RemoteCount()))
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
go p.initializeRemote(remote)
go p.SendNegotiation()
return remote, nil
}
func (p *SimpleGrainPool[V]) initializeRemote(remote Host[V]) {
remotesIds := remote.GetActorIds()
p.remoteMu.Lock()
for _, id := range remotesIds {
p.localMu.Lock()
delete(p.grains, id)
p.localMu.Unlock()
if _, exists := p.remoteOwners[id]; !exists {
p.remoteOwners[id] = remote
}
}
p.remoteMu.Unlock()
}
func (p *SimpleGrainPool[V]) RemoveHost(host string) {
p.remoteMu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
go remote.Close()
delete(p.remoteHosts, host)
}
count := 0
for id, owner := range p.remoteOwners {
if owner.Name() == host {
count++
delete(p.remoteOwners, id)
}
}
log.Printf("Removing host %s, grains: %d", host, count)
p.remoteMu.Unlock()
if exists {
remote.Close()
}
// connectedRemotes.Set(float64(p.RemoteCount()))
}
func (p *SimpleGrainPool[V]) RemoteCount() int {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
return len(p.remoteHosts)
}
// RemoteHostNames returns a snapshot of connected remote host identifiers.
func (p *SimpleGrainPool[V]) RemoteHostNames() []string {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
hosts := make([]string, 0, len(p.remoteHosts))
for host := range p.remoteHosts {
hosts = append(hosts, host)
}
return hosts
}
func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
if host == p.hostname {
return true
}
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
remote.Ping()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if !remote.Ping() {
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Name())
p.Close()
p.RemoveHost(remote.Name())
return
}
continue
}
}
}
func (p *SimpleGrainPool[V]) IsHealthy() bool {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
for _, host := range otherHosts {
if host != p.hostname {
p.remoteMu.RLock()
_, ok := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !ok {
go p.AddRemote(host)
}
}
}
}
func (p *SimpleGrainPool[V]) SendNegotiation() {
//negotiationCount.Inc()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]Host[V], 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
p.forAllHosts(func(remote Host[V]) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
log.Printf("Negotiate with %s failed: %v", remote.Name(), err)
return
}
for _, h := range knownByRemote {
if !p.IsKnown(h) {
go p.AddRemote(h)
}
}
})
}
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
p.remoteMu.RLock()
rh := maps.Clone(p.remoteHosts)
p.remoteMu.RUnlock()
wg := sync.WaitGroup{}
for _, host := range rh {
wg.Go(func() { fn(host) })
}
for name, host := range rh {
if !host.IsHealthy() {
host.Close()
p.remoteMu.Lock()
delete(p.remoteHosts, name)
p.remoteMu.Unlock()
}
}
}
func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
if len(ids) == 0 {
return
}
p.forAllHosts(func(rh Host[V]) {
rh.AnnounceOwnership(p.hostname, ids)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
// go p.statsUpdate()
}
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
if exists && grain != nil {
return grain, nil
}
grain, err := p.spawn(ctx, id)
if err != nil {
return nil, err
}
go p.broadcastOwnership([]uint64{id})
p.localMu.Lock()
p.grains[id] = grain
p.localMu.Unlock()
return grain, nil
}
// // ErrNotOwner is returned when a cart belongs to another host.
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
grain, err := p.getOrClaimGrain(ctx, id)
if err != nil {
return nil, err
}
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
if err != nil {
return nil, err
}
if p.storage != nil {
go func() {
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
}
return &MutationResult[V]{
Result: *result,
Mutations: mutations,
}, nil
}
// Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(ctx, id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host[V], bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
return owner, ok
}
// Hostname returns the local hostname (pod IP).
func (p *SimpleGrainPool[V]) Hostname() string {
return p.hostname
}
// Close notifies remotes that this host is shutting down.
func (p *SimpleGrainPool[V]) Close() {
p.forAllHosts(func(rh Host[V]) {
rh.Close()
})
if p.purgeTicker != nil {
p.purgeTicker.Stop()
}
}
+98
View File
@@ -0,0 +1,98 @@
package actor
import (
"bufio"
"encoding/json"
"errors"
"io"
"time"
"google.golang.org/protobuf/proto"
)
type StateStorage struct {
registry MutationRegistry
}
type StorageEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation proto.Message `json:"mutation"`
}
type rawEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation json.RawMessage `json:"mutation"`
}
func NewState(registry MutationRegistry) *StateStorage {
return &StateStorage{
registry: registry,
}
}
var ErrUnknownType = errors.New("unknown type")
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error {
var err error
var evt *StorageEvent
scanner := bufio.NewScanner(r)
for err == nil {
evt, err = s.Read(scanner)
if err == nil {
onMessage(evt.Mutation, evt.TimeStamp)
}
}
if err == io.EOF {
return nil
}
return err
}
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
typeName, ok := s.registry.GetTypeName(mutation)
if !ok {
return ErrUnknownType
}
event := &StorageEvent{
Type: typeName,
TimeStamp: timeStamp,
Mutation: mutation,
}
jsonBytes, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := io.Write(jsonBytes); err != nil {
return err
}
io.Write([]byte("\n"))
return nil
}
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
var event rawEvent
if r.Scan() {
b := r.Bytes()
err := json.Unmarshal(b, &event)
if err != nil {
return nil, err
}
typeName := event.Type
mutation, ok := s.registry.Create(typeName)
if !ok {
return nil, ErrUnknownType
}
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
return nil, err
}
return &StorageEvent{
Type: typeName,
TimeStamp: event.TimeStamp,
Mutation: mutation,
}, r.Err()
}
return nil, io.EOF
}
+276
View File
@@ -0,0 +1,276 @@
package cart
import (
"encoding/json"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
// Legacy padded [16]byte CartId and its helper methods removed.
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
type StockStatus int
type ItemMeta struct {
Name string `json:"name"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
SellerName string `json:"sellerName,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
}
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"`
SellerId string `json:"sellerId,omitempty"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Cgm string `json:"cgm,omitempty"`
Tax int
Stock uint16 `json:"stock"`
Quantity uint16 `json:"qty"`
Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
SaleStatus string `json:"saleStatus"`
Marking *Marking `json:"marking,omitempty"`
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
}
type CartNotification struct {
LinkedId int `json:"id"`
Provider string `json:"provider"`
Title string `json:"title"`
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 Notice struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Code *string `json:"code,omitempty"`
}
type CartPaymentStatus string
const (
CartPaymentStatusPending CartPaymentStatus = "pending"
CartPaymentStatusFailed CartPaymentStatus = "failed"
CartPaymentStatusSuccess CartPaymentStatus = "success"
CartPaymentStatusCancelled CartPaymentStatus = "partial"
)
type Marking struct {
Type uint32 `json:"type"`
Text string `json:"text"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Currency string `json:"currency"`
Language string `json:"language"`
Version uint `json:"version"`
InventoryReserved bool `json:"inventoryReserved"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Processing bool `json:"processing"`
//PaymentInProgress uint16 `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
}
type Voucher struct {
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) {
// No rules -> applies to entire cart
if len(v.Rules) == 0 {
return cart.Items, true
}
// Build evaluation context once
ctx := voucher.EvalContext{
Items: make([]voucher.Item, 0, len(cart.Items)),
CartTotalInc: 0,
}
if cart.TotalPrice != nil {
ctx.CartTotalInc = cart.TotalPrice.IncVat
}
for _, it := range cart.Items {
category := ""
if it.Meta != nil {
category = it.Meta.Category
}
ctx.Items = append(ctx.Items, voucher.Item{
Sku: it.Sku,
Category: category,
UnitPrice: it.Price.IncVat,
})
}
// All voucher rules must pass (logical AND)
for _, expr := range v.Rules {
if expr == "" {
// Empty condition treated as pass (acts like a comment / placeholder)
continue
}
rs, err := voucher.ParseRules(expr)
if err != nil {
// Fail closed on parse error
return nil, false
}
if !rs.Applies(ctx) {
return nil, false
}
}
return cart.Items, true
}
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
return &CartGrain{
lastItemId: 0,
lastVoucherId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
SubscriptionDetails: make(map[string]*SubscriptionDetails),
}
}
func (c *CartGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CartGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CartGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func (c *CartGrain) HandleInventoryChange(change inventory.InventoryChange) {
for _, item := range c.Items {
l := "se"
if item.StoreId != nil {
l = *item.StoreId
}
if item.Sku == change.SKU && change.StockLocationID == l {
item.Stock = uint16(change.Value)
break
}
}
}
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice()
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
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 _, voucher := range c.Vouchers {
_, 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)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package cart
import (
"context"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
type CartMutationContext struct {
reservationService inventory.CartReservationService
}
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
return &CartMutationContext{
reservationService: reservationService,
}
}
func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) {
if quantity <= 0 || c.reservationService == nil {
return nil, nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
ttl := time.Minute * 15
endTime := time.Now().Add(ttl)
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
CartID: inventory.CartID(cartId.String()),
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(sku),
LocationID: l,
},
TTL: ttl,
Quantity: uint32(quantity),
})
if err != nil {
return nil, err
}
return &endTime, nil
}
func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil {
return true
}
return item.Cgm == "55010"
}
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
if c.reservationService == nil {
return nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
}
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(context.AddItem),
actor.NewMutation(context.ChangeQuantity),
actor.NewMutation(context.RemoveItem),
actor.NewMutation(ClearCart),
actor.NewMutation(AddVoucher),
actor.NewMutation(RemoveVoucher),
actor.NewMutation(UpsertSubscriptionDetails),
actor.NewMutation(SetUserId),
actor.NewMutation(LineItemMarking),
actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SubscriptionAdded),
// actor.NewMutation(SubscriptionRemoved),
)
return reg
}
+161
View File
@@ -0,0 +1,161 @@
package cart
import (
"crypto/rand"
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/actor"
)
// cart_id.go
//
// Breaking change:
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
//
// Rationale:
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
// - Provides compact, URL/cookie-friendly identifiers.
// - O(1) hashing and minimal memory footprint.
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
//
// Public API:
// type CartId uint64
// func NewCartId() (CartId, error)
// func MustNewCartId() CartId
// func ParseCartId(string) (CartId, bool)
// func MustParseCartId(string) CartId
// (CartId).String() string
// (CartId).MarshalJSON() / UnmarshalJSON()
//
// NOTE:
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
//
// ---------------------------------------------------------------------------
type CartId actor.GrainId
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id CartId) String() string {
return encodeBase62(uint64(id))
}
// MarshalJSON encodes the cart id as a JSON string.
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (id *CartId) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseCartId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
}
// NewCartId generates a new cryptographically random non-zero 64-bit id.
func NewCartId() (CartId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewCartId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewCartId()
}
return CartId(u), nil
}
// MustNewCartId panics if generation fails.
func MustNewCartId() CartId {
id, err := NewCartId()
if err != nil {
panic(err)
}
return id
}
// ParseCartId parses a base62 string into a CartId.
// Returns (0,false) for invalid input.
func ParseCartId(s string) (CartId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return CartId(u), true
}
// MustParseCartId panics on invalid base62 input.
func MustParseCartId(s string) CartId {
id, ok := ParseCartId(s)
if !ok {
panic(fmt.Sprintf("invalid cart id: %q", s))
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}
+185
View File
@@ -0,0 +1,185 @@
package cart
import (
"encoding/json"
"fmt"
"testing"
)
// TestNewCartIdUniqueness generates many ids and checks for collisions.
func TestNewCartIdUniqueness(t *testing.T) {
const n = 20000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
id, err := NewCartId()
if err != nil {
t.Fatalf("NewCartId error: %v", err)
}
s := id.String()
if _, exists := seen[s]; exists {
t.Fatalf("duplicate id encountered: %s", s)
}
seen[s] = struct{}{}
if s == "" {
t.Fatalf("empty string representation for id %d", id)
}
if len(s) > 11 {
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
}
if id == 0 {
// We force regeneration on zero, extremely unlikely but test guards intent.
t.Fatalf("zero id generated (should be regenerated)")
}
}
}
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
func TestParseCartIdRoundTrip(t *testing.T) {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok {
t.Fatalf("ParseCartId failed for valid text %q", txt)
}
if parsed != id {
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
}
}
// TestParseCartIdInvalid covers invalid inputs.
func TestParseCartIdInvalid(t *testing.T) {
invalid := []string{
"", // empty
" ", // space
"01234567890abc", // >11 chars
"!!!!", // invalid chars
"-underscore-", // invalid chars
"abc_def", // underscore invalid for base62
"0123456789ABCD", // 14 chars
}
for _, s := range invalid {
if _, ok := ParseCartId(s); ok {
t.Fatalf("expected parse failure for %q", s)
}
}
}
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
func TestMustParseCartIdPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid MustParseCartId input")
}
}()
_ = MustParseCartId("not*base62")
}
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
id := MustNewCartId()
data, err := json.Marshal(struct {
Cart CartId `json:"cart"`
}{Cart: id})
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var out struct {
Cart CartId `json:"cart"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.Cart != id {
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
}
}
// TestBase62LengthBound checks worst-case length (near max uint64).
func TestBase62LengthBound(t *testing.T) {
// Largest uint64
const maxU64 = ^uint64(0)
s := encodeBase62(maxU64)
if len(s) > 11 {
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
}
dec, ok := decodeBase62(s)
if !ok || dec != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
}
}
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
func TestZeroEncoding(t *testing.T) {
if s := encodeBase62(0); s != "0" {
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
}
v, ok := decodeBase62("0")
if !ok || v != 0 {
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
}
if _, ok := ParseCartId("0"); !ok {
t.Fatalf("ParseCartId(\"0\") should succeed")
}
}
// TestSequentialParse ensures sequentially generated ids parse correctly.
func TestSequentialParse(t *testing.T) {
for i := 0; i < 1000; i++ {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok || parsed != id {
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
}
}
}
// BenchmarkNewCartId measures generation performance.
func BenchmarkNewCartId(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := NewCartId(); err != nil {
b.Fatalf("NewCartId error: %v", err)
}
}
}
// BenchmarkEncodeBase62 measures encoding performance.
func BenchmarkEncodeBase62(b *testing.B) {
// Precompute sample values
samples := make([]uint64, 1024)
for i := range samples {
// Spread bits without crypto randomness overhead
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
}
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = encodeBase62(samples[i%len(samples)])
}
_ = sink
}
// BenchmarkDecodeBase62 measures decoding performance.
func BenchmarkDecodeBase62(b *testing.B) {
encoded := make([]string, 1024)
for i := range encoded {
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
}
b.ResetTimer()
var sum uint64
for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)])
if !ok {
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
}
sum ^= v
}
_ = sum
}
// ExampleCartIdString documents usage of CartId string form.
func ExampleCartId_string() {
id := MustNewCartId()
fmt.Println(len(id.String()) <= 11) // outputs true
// Output: true
}
+141
View File
@@ -0,0 +1,141 @@
package cart
import (
"context"
"errors"
"fmt"
"log"
"time"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// - Validates quantity > 0
// - If an item with same SKU exists -> increases quantity
// - Else creates a new CartItem with computed tax amounts
// - Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
var ErrPaymentInProgress = errors.New("payment in progress")
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
ctx := context.Background()
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// 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
}
if c.UseReservations(existing) {
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
log.Printf("failed to release item %d: %v", existing.Id, err)
}
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
if err != nil {
return err
}
existing.ReservationEndTime = endTime
}
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
}
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := float32(25.0)
if m.Tax > 0 {
taxRate = float32(int(m.Tax) / 100)
}
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
needsReservation := true
if m.ReservationEndTime != nil {
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
}
cartItem := &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: uint16(m.Quantity),
Sku: m.Sku,
Tax: int(taxRate * 100),
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerName: m.SellerName,
},
SellerId: m.SellerId,
Cgm: m.Cgm,
SaleStatus: m.SaleStatus,
ParentId: m.ParentId,
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
Stock: uint16(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
}
if needsReservation && c.UseReservations(cartItem) {
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
if endTime != nil {
m.ReservationEndTime = timestamppb.New(*endTime)
t := m.ReservationEndTime.AsTime()
cartItem.ReservationEndTime = &t
}
}
g.Items = append(g.Items, cartItem)
g.UpdateTotals()
return nil
}
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, taxRate)
}
+69
View File
@@ -0,0 +1,69 @@
package cart
import (
"slices"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "RemoveVoucher: nil payload",
Code: 1003,
StatusCode: 400,
}
}
if g.CheckoutStatus != nil {
return ErrPaymentInProgress
}
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
}) {
return &actor.MutationError{
Message: "voucher not applied",
Code: 1004,
StatusCode: 400,
}
}
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
})
g.UpdateTotals()
return nil
}
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "AddVoucher: nil payload",
Code: 1001,
StatusCode: 400,
}
}
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Code == m.Code
}) {
return &actor.MutationError{
Message: "voucher already applied",
Code: 1002,
StatusCode: 400,
}
}
g.lastVoucherId++
g.Vouchers = append(g.Vouchers, &Voucher{
Id: g.lastVoucherId,
Applied: false,
Description: m.Description,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
})
g.UpdateTotals()
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_change_quantity.go
//
// Registers the ChangeQuantity mutation.
//
// Behavior:
// - Locates an item by its cart-local line item Id (not source item_id).
// - If requested quantity <= 0 the line is removed.
// - Otherwise the line's Quantity field is updated.
// - Totals are recalculated (WithTotals).
//
// Error handling:
// - Returns an error if the item Id is not found.
// - Returns an error if payload is nil (defensive).
//
// Concurrency:
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
// the grain's implicit expectation that higher layers control access.
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
ctx := context.Background()
foundIndex := -1
for i, it := range g.Items {
if it.Id == uint32(m.Id) {
foundIndex = i
break
}
}
if foundIndex == -1 {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if m.Quantity <= 0 {
// Remove the item
itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
}
}
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
g.UpdateTotals()
return nil
}
item := g.Items[foundIndex]
if item == nil {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if c.UseReservations(item) {
if item.ReservationEndTime != nil {
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", item.Sku, item.StoreId)
}
}
endTime, err := c.ReserveItem(ctx, g.Id, item.Sku, item.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
item.ReservationEndTime = endTime
}
item.Quantity = uint16(m.Quantity)
g.UpdateTotals()
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
if g.CheckoutStatus != nil {
return fmt.Errorf("ClearCart: cart is in checkout")
}
// Clear items, vouchers, etc., but keep userId, etc.
g.Items = g.Items[:0]
g.Vouchers = g.Vouchers[:0]
g.Notifications = g.Notifications[:0]
g.OrderReference = ""
g.Processing = false
// g.InventoryReserved = false maybe should release inventory
g.UpdateTotals()
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = &Marking{
Type: req.Type,
Text: req.Marking,
}
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
+57
View File
@@ -0,0 +1,57 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_remove_item.go
//
// Registers the RemoveItem mutation.
//
// Behavior:
// - Removes the cart line whose local cart line Id == payload.Id
// - If no such line exists returns an error
// - Recalculates cart totals (WithTotals)
//
// Notes:
// - This removes only the line item; any deliveries referencing the removed
// item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids you can extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), only
// the first match would be removed—data integrity relies on unique line Ids.
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := uint32(m.Id)
index := -1
for i, it := range g.Items {
if it.Id == targetID {
index = i
break
}
}
if index == -1 {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
}
item := g.Items[index]
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
if err != nil {
log.Printf("unable to release item reservation")
}
}
g.Items = append(g.Items[:index], g.Items[index+1:]...)
g.UpdateTotals()
return nil
}
@@ -0,0 +1,18 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
+15
View File
@@ -0,0 +1,15 @@
package cart
import (
"errors"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
if req.UserId == "" {
return errors.New("user ID cannot be empty")
}
grain.userId = req.UserId
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
for i, item := range grain.Items {
if item.Id == req.ItemId {
grain.Items[i].SubscriptionDetailsId = req.DetailsId
grain.Items[i].OrderReference = req.OrderReference
grain.Items[i].IsSubscribed = true
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.ItemId)
}
@@ -0,0 +1,65 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
metaBytes := m.Data.GetValue()
// Create new subscription details when Id is nil.
if m.Id == nil {
// Validate JSON if provided.
var meta json.RawMessage
if metaBytes != nil {
if !json.Valid(metaBytes) {
return fmt.Errorf("subscription details invalid json")
}
meta = json.RawMessage(metaBytes)
}
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 with id %s not found", *m.Id)
}
changed := false
if m.OfferingCode != "" {
existing.OfferingCode = m.OfferingCode
changed = true
}
if m.SigningType != "" {
existing.SigningType = m.SigningType
changed = true
}
if metaBytes != nil {
if !json.Valid(metaBytes) {
return fmt.Errorf("subscription details invalid json")
}
existing.Meta = json.RawMessage(metaBytes)
changed = true
}
if changed {
existing.Version++
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package cart
import (
"encoding/json"
"strconv"
)
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
type Price struct {
IncVat int64 `json:"incVat"`
VatRates map[float32]int64 `json:"vat,omitempty"`
}
func NewPrice() *Price {
return &Price{
IncVat: 0,
VatRates: make(map[float32]int64),
}
}
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
tax := GetTaxAmount(incVat, int(taxRate*100))
return &Price{
IncVat: incVat,
VatRates: map[float32]int64{
taxRate: tax,
},
}
}
func (p *Price) ValueExVat() int64 {
exVat := p.IncVat
for _, amount := range p.VatRates {
exVat -= amount
}
return exVat
}
func (p *Price) TotalVat() int64 {
total := int64(0)
for _, amount := range p.VatRates {
total += amount
}
return total
}
func MultiplyPrice(p Price, qty int64) *Price {
ret := &Price{
IncVat: p.IncVat * qty,
VatRates: make(map[float32]int64),
}
for rate, amount := range p.VatRates {
ret.VatRates[rate] = amount * qty
}
return ret
}
func (p *Price) Multiply(qty int64) {
p.IncVat *= qty
for rate, amount := range p.VatRates {
p.VatRates[rate] = amount * qty
}
}
func (p Price) MarshalJSON() ([]byte, error) {
// Build a stable wire format without calling Price.MarshalJSON recursively
exVat := p.ValueExVat()
var vat map[string]int64
if len(p.VatRates) > 0 {
vat = make(map[string]int64, len(p.VatRates))
for rate, amount := range p.VatRates {
// Rely on default formatting that trims trailing zeros for whole numbers
// Using %g could output scientific notation for large numbers; float32 rates here are small.
key := trimFloat(rate)
vat[key] = amount
}
}
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
func (p *Price) UnmarshalJSON(data []byte) error {
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
p.IncVat = w.IncVat
if len(w.Vat) > 0 {
p.VatRates = make(map[float32]int64, len(w.Vat))
for rateStr, amount := range w.Vat {
rate, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
return err
}
p.VatRates[float32(rate)] = amount
}
} else {
p.VatRates = make(map[float32]int64)
}
return nil
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {
// Convert via FormatFloat then trim trailing zeros and dot.
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
return s
}
func (p *Price) Add(price Price) {
p.IncVat += price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] += amount
}
}
func (p *Price) Subtract(price Price) {
p.IncVat -= price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] -= amount
}
}
func SumPrices(prices ...Price) *Price {
if len(prices) == 0 {
return NewPrice()
}
aggregated := NewPrice()
for _, price := range prices {
aggregated.IncVat += price.IncVat
for rate, amount := range price.VatRates {
aggregated.VatRates[rate] += amount
}
}
if len(aggregated.VatRates) == 0 {
aggregated.VatRates = nil
}
return aggregated
}
+238
View File
@@ -0,0 +1,238 @@
package cart
import (
"encoding/json"
"testing"
)
func TestPriceMarshalJSON(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
// ExVat = 13700 - (2500+1200) = 10000
data, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
// Unmarshal into a generic struct to validate fields
var out struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.ExVat != 10000 {
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
}
if out.IncVat != 13700 {
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
}
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
t.Fatalf("unexpected vat map: %#v", out.Vat)
}
}
func TestNewPriceFromIncVat(t *testing.T) {
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() != 1000 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}
func TestSumPrices(t *testing.T) {
// We'll construct prices via raw struct since constructor expects tax math.
// IncVat already includes vat portions.
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
c := Price{IncVat: 0, VatRates: nil}
sum := SumPrices(a, b, c)
if sum.IncVat != 3990 { // 1250+2740
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
}
if len(sum.VatRates) != 2 {
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
}
if sum.VatRates[25] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
}
if sum.VatRates[12] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
}
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
}
}
func TestSumPricesEmpty(t *testing.T) {
sum := SumPrices()
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
t.Fatalf("expected zero price got %#v", sum)
}
}
func TestMultiplyPriceFunction(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 3)
if multiplied.IncVat != 1250*3 {
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
}
if multiplied.VatRates[25] != 250*3 {
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
}
if multiplied.ValueExVat() != (1250-250)*3 {
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
}
}
func TestPriceAddSubtract(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.IncVat != 1500 {
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
}
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
}
// Subtract b then a returns to zero
acc.Subtract(b)
acc.Subtract(a)
if acc.IncVat != 0 {
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
}
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
}
}
func TestPriceMultiplyMethod(t *testing.T) {
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
// Value before multiply
exBefore := p.ValueExVat()
p.Multiply(2)
if p.IncVat != 4000 {
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
}
if p.VatRates[25] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
}
if p.ValueExVat() != exBefore*2 {
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)
}
}
+195
View File
@@ -0,0 +1,195 @@
package checkout
import (
"encoding/json"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// CheckoutId is the same as CartId for simplicity
type CheckoutId = cart.CartId
type PickupPoint struct {
DeliveryId uint32 `json:"deliveryId,omitempty"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
Country *string `json:"country,omitempty"`
}
type CheckoutDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price cart.Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPoint `json:"pickupPoint,omitempty"`
}
type PaymentStatus string
type CheckoutPaymentStatus PaymentStatus
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusSuccess PaymentStatus = "success"
CheckoutPaymentStatusPending CheckoutPaymentStatus = "pending"
CheckoutPaymentStatusFailed CheckoutPaymentStatus = "failed"
CheckoutPaymentStatusSuccess CheckoutPaymentStatus = "success"
CheckoutPaymentStatusCancelled CheckoutPaymentStatus = "partial"
)
type Payment struct {
PaymentId string `json:"paymentId"`
Status PaymentStatus `json:"status"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider,omitempty"`
Method *string `json:"method,omitempty"`
SessionData *json.RawMessage `json:"sessionData,omitempty"`
Events []PaymentEvent `json:"events,omitempty"`
ProcessorReference *string `json:"processorReference,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type PaymentEvent struct {
Name string `json:"name"`
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
func (p *Payment) IsSettled() bool {
if p == nil {
return false
}
switch p.Status {
case PaymentStatusSuccess:
return true
default:
return false
}
}
type ContactDetails struct {
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"`
}
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
}
type CheckoutGrain struct {
mu sync.RWMutex
lastDeliveryId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time
Version uint32 `json:"version"`
Id CheckoutId `json:"id"`
CartId cart.CartId `json:"cartId"`
CartVersion uint64 `json:"cartVersion"`
CartState *cart.CartGrain `json:"cartState"` // snapshot of items
CartTotalPrice *cart.Price `json:"cartTotalPrice"`
OrderId *string `json:"orderId"`
Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"`
PaymentInProgress uint16 `json:"paymentInProgress"`
InventoryReserved bool `json:"inventoryReserved"`
Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
ContactDetails *ContactDetails `json:"contactDetails,omitempty"`
}
func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain {
r := &CheckoutGrain{
lastDeliveryId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
Id: CheckoutId(id),
CartId: cartId,
CartVersion: cartVersion,
Deliveries: []*CheckoutDelivery{},
Payments: []*Payment{},
}
if cartState != nil {
r.CartState = cartState
r.CartTotalPrice = cartState.TotalPrice
}
return r
}
func (c *CheckoutGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CheckoutGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CheckoutGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CheckoutGrain) GetCurrentState() (*CheckoutGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func (c *CheckoutGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CheckoutGrain) FindPayment(paymentId string) (*Payment, bool) {
if paymentId == "" {
return nil, false
}
for _, payment := range c.Payments {
if payment != nil && payment.PaymentId == paymentId {
return payment, true
}
}
return nil, false
}
func (c *CheckoutGrain) SettledPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
settled := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment != nil && payment.IsSettled() {
settled = append(settled, payment)
}
}
if len(settled) == 0 {
return nil
}
return settled
}
func (c *CheckoutGrain) OpenPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
pending := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment == nil {
continue
}
if !payment.IsSettled() {
pending = append(pending, payment)
}
}
if len(pending) == 0 {
return nil
}
return pending
}
+32
View File
@@ -0,0 +1,32 @@
package checkout
import (
"git.k6n.net/go-cart-actor/pkg/actor"
)
type CheckoutMutationContext struct {
// Add any services needed, e.g., for delivery calculations, but since inventory is pre-handled, maybe none
}
func NewCheckoutMutationContext() *CheckoutMutationContext {
return &CheckoutMutationContext{}
}
func NewCheckoutMutationRegistry(ctx *CheckoutMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(HandleInitializeCheckout),
actor.NewMutation(HandlePaymentStarted),
actor.NewMutation(HandlePaymentCompleted),
actor.NewMutation(HandlePaymentDeclined),
actor.NewMutation(HandlePaymentEvent),
actor.NewMutation(HandleConfirmationViewed),
actor.NewMutation(HandleOrderCreated),
actor.NewMutation(HandleInventoryReserved),
actor.NewMutation(HandleSetDelivery),
actor.NewMutation(HandleSetPickupPoint),
actor.NewMutation(HandleRemoveDelivery),
actor.NewMutation(HandleContactDetailsUpdated),
)
return reg
}
@@ -0,0 +1,26 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error {
if m == nil {
return fmt.Errorf("ConfirmationViewed: nil payload")
}
if g.Confirmation != nil {
g.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
g.Confirmation.ViewCount++
g.Confirmation.LastViewedAt = time.Now()
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More