From 61fefe26b413b290131e33e2fa7028022923e879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Mon, 17 Nov 2025 17:31:58 +0800 Subject: [PATCH] feat: add notification red bill --- ...25111700000001_notification_event.down.sql | 1 + ...2025111700000001_notification_event.up.sql | 15 + ...025111700000002_user_notification.down.sql | 1 + .../2025111700000002_user_notification.up.sql | 11 + ...5111700000003_notification_cursor.down.sql | 1 + ...025111700000003_notification_cursor.up.sql | 5 + go.mod | 3 + go.sum | 12 + pkg/library/cassandra/.golangci.yaml | 55 ++ pkg/library/cassandra/Makefile | 8 + pkg/library/cassandra/batch.go | 113 ++++ pkg/library/cassandra/batch_test.go | 44 ++ pkg/library/cassandra/cassandra_test.go | 204 ++++++ pkg/library/cassandra/client.go | 209 ++++++ pkg/library/cassandra/client_test.go | 210 ++++++ pkg/library/cassandra/const.go | 21 + pkg/library/cassandra/crud.go | 203 ++++++ pkg/library/cassandra/crud_test.go | 363 +++++++++++ pkg/library/cassandra/error.go | 122 ++++ pkg/library/cassandra/ez_transaction.go | 285 +++++++++ pkg/library/cassandra/ez_transaction_test.go | 231 +++++++ pkg/library/cassandra/go.mod | 74 +++ pkg/library/cassandra/go.sum | 207 ++++++ pkg/library/cassandra/lock.go | 138 ++++ pkg/library/cassandra/lock_test.go | 64 ++ pkg/library/cassandra/metadata.go | 69 ++ pkg/library/cassandra/metadata_test.go | 74 +++ pkg/library/cassandra/option.go | 144 +++++ pkg/library/cassandra/option_test.go | 158 +++++ pkg/library/cassandra/query_helper.go | 30 + pkg/library/cassandra/readme.md | 438 +++++++++++++ pkg/library/cassandra/table.go | 462 ++++++++++++++ pkg/library/cassandra/table_test.go | 324 ++++++++++ pkg/library/cassandra/utils.go | 65 ++ pkg/library/cassandra/utils_test.go | 166 +++++ .../domain/entity/notification_cursor.go | 18 + .../domain/entity/notification_event.go | 26 + .../domain/entity/user_notification.go | 22 + .../notification/notification_priority.go | 31 + .../notification/notification_status.go | 28 + .../domain/repository/notification.go | 82 +++ .../domain/usecase/notification.go | 114 ++++ pkg/notification/usecase/notification.go | 603 ++++++++++++++++++ 43 files changed, 5454 insertions(+) create mode 100644 generate/database/cassandra/2025111700000001_notification_event.down.sql create mode 100644 generate/database/cassandra/2025111700000001_notification_event.up.sql create mode 100644 generate/database/cassandra/2025111700000002_user_notification.down.sql create mode 100644 generate/database/cassandra/2025111700000002_user_notification.up.sql create mode 100644 generate/database/cassandra/2025111700000003_notification_cursor.down.sql create mode 100644 generate/database/cassandra/2025111700000003_notification_cursor.up.sql create mode 100644 pkg/library/cassandra/.golangci.yaml create mode 100644 pkg/library/cassandra/Makefile create mode 100644 pkg/library/cassandra/batch.go create mode 100644 pkg/library/cassandra/batch_test.go create mode 100644 pkg/library/cassandra/cassandra_test.go create mode 100644 pkg/library/cassandra/client.go create mode 100644 pkg/library/cassandra/client_test.go create mode 100644 pkg/library/cassandra/const.go create mode 100644 pkg/library/cassandra/crud.go create mode 100644 pkg/library/cassandra/crud_test.go create mode 100644 pkg/library/cassandra/error.go create mode 100644 pkg/library/cassandra/ez_transaction.go create mode 100644 pkg/library/cassandra/ez_transaction_test.go create mode 100644 pkg/library/cassandra/go.mod create mode 100644 pkg/library/cassandra/go.sum create mode 100644 pkg/library/cassandra/lock.go create mode 100644 pkg/library/cassandra/lock_test.go create mode 100644 pkg/library/cassandra/metadata.go create mode 100644 pkg/library/cassandra/metadata_test.go create mode 100644 pkg/library/cassandra/option.go create mode 100644 pkg/library/cassandra/option_test.go create mode 100644 pkg/library/cassandra/query_helper.go create mode 100644 pkg/library/cassandra/readme.md create mode 100644 pkg/library/cassandra/table.go create mode 100644 pkg/library/cassandra/table_test.go create mode 100644 pkg/library/cassandra/utils.go create mode 100644 pkg/library/cassandra/utils_test.go create mode 100644 pkg/notification/domain/entity/notification_cursor.go create mode 100644 pkg/notification/domain/entity/notification_event.go create mode 100644 pkg/notification/domain/entity/user_notification.go create mode 100644 pkg/notification/domain/notification/notification_priority.go create mode 100644 pkg/notification/domain/notification/notification_status.go create mode 100644 pkg/notification/domain/repository/notification.go create mode 100644 pkg/notification/domain/usecase/notification.go create mode 100644 pkg/notification/usecase/notification.go diff --git a/generate/database/cassandra/2025111700000001_notification_event.down.sql b/generate/database/cassandra/2025111700000001_notification_event.down.sql new file mode 100644 index 0000000..ada8d5a --- /dev/null +++ b/generate/database/cassandra/2025111700000001_notification_event.down.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS notification_event; diff --git a/generate/database/cassandra/2025111700000001_notification_event.up.sql b/generate/database/cassandra/2025111700000001_notification_event.up.sql new file mode 100644 index 0000000..0a2d87f --- /dev/null +++ b/generate/database/cassandra/2025111700000001_notification_event.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS notification_event ( + event_id uuid PRIMARY KEY, -- 事件 ID + + event_type text, -- POST_PUBLISHED / COMMENT_ADDED / MENTIONED ... + actor_uid text, -- 觸發者 UID(例如 A) + object_type text, -- POST / COMMENT / USER ... + object_id text, -- 對應物件 ID(post_id 等) + + title text, -- 顯示用標題 + body text, -- 顯示用內容 / 摘要 + payload text, -- JSON string(額外欄位,例如 {"postId": "..."}) + + priority smallint, -- 1=critical, 2=high, 3=normal, 4=low + created_at timestamp -- 事件時間(方便做 cross table 查詢) +) AND comment = 'notification_event'; \ No newline at end of file diff --git a/generate/database/cassandra/2025111700000002_user_notification.down.sql b/generate/database/cassandra/2025111700000002_user_notification.down.sql new file mode 100644 index 0000000..4d6ab9a --- /dev/null +++ b/generate/database/cassandra/2025111700000002_user_notification.down.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS user_notification; diff --git a/generate/database/cassandra/2025111700000002_user_notification.up.sql b/generate/database/cassandra/2025111700000002_user_notification.up.sql new file mode 100644 index 0000000..63b7bb1 --- /dev/null +++ b/generate/database/cassandra/2025111700000002_user_notification.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS user_notification ( + user_id text, -- 收通知的人 + bucket text, -- 分桶,例如 '2025-11' 或 '2025-11-17' + ts timeuuid, -- 通知時間,用 now() 產生,排序用 + + event_id uuid, -- 對應 notification_event.event_id + status text, -- 'UNREAD' / 'READ' / 'ARCHIVED' + read_at timestamp, -- 已讀時間(非必填) + + PRIMARY KEY ((user_id, bucket), ts) +) WITH CLUSTERING ORDER BY (ts DESC); diff --git a/generate/database/cassandra/2025111700000003_notification_cursor.down.sql b/generate/database/cassandra/2025111700000003_notification_cursor.down.sql new file mode 100644 index 0000000..6c5fe6c --- /dev/null +++ b/generate/database/cassandra/2025111700000003_notification_cursor.down.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS notification_cursor; diff --git a/generate/database/cassandra/2025111700000003_notification_cursor.up.sql b/generate/database/cassandra/2025111700000003_notification_cursor.up.sql new file mode 100644 index 0000000..f90d33f --- /dev/null +++ b/generate/database/cassandra/2025111700000003_notification_cursor.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS notification_cursor ( + user_id text PRIMARY KEY, + last_seen_ts timeuuid, -- 最後看到的通知 timeuuid + updated_at timestamp +); diff --git a/go.mod b/go.mod index fcd9141..a56af1a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.21 github.com/aws/aws-sdk-go-v2/service/ses v1.34.9 github.com/go-playground/validator/v10 v10.28.0 + github.com/gocql/gocql v1.7.0 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/matcornic/hermes/v2 v2.1.0 @@ -68,6 +69,7 @@ require ( github.com/grafana/pyroscope-go v1.2.7 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/huandu/xstrings v1.2.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect @@ -139,6 +141,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // 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 ) diff --git a/go.sum b/go.sum index 7fc6edc..1f11ea3 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,10 @@ github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 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= @@ -93,10 +97,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= +github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -115,6 +122,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= @@ -230,6 +239,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -369,6 +379,8 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AW gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/pkg/library/cassandra/.golangci.yaml b/pkg/library/cassandra/.golangci.yaml new file mode 100644 index 0000000..b57947c --- /dev/null +++ b/pkg/library/cassandra/.golangci.yaml @@ -0,0 +1,55 @@ +run: + timeout: 3m + issues-exit-code: 2 + tests: false # 不檢查測試檔案 + +linters: + enable: + - govet # 官方靜態分析,抓潛在 bug + - staticcheck # 最強 bug/反模式偵測 + - revive # golint 進化版,風格與註解規範 + - gofmt # 風格格式化檢查 + - goimports # import 排序 + - errcheck # error 忽略警告 + - ineffassign # 無效賦值 + - unused # 未使用變數 + - bodyclose # HTTP body close + - gosimple # 靜態分析簡化警告(staticcheck 也包含,可選) + - typecheck # 型別檢查 + - misspell # 拼字檢查 + - gocritic # bug-prone code + - gosec # 資安檢查 + - prealloc # slice/array 預分配 + - unparam # 未使用參數 + +issues: + exclude-rules: + - path: _test\.go + linters: + - funlen + - goconst + - cyclop + - gocognit + - lll + - wrapcheck + - contextcheck + +linters-settings: + revive: + severity: warning + rules: + - name: blank-imports + severity: error + gofmt: + simplify: true + lll: + line-length: 140 + +# 可自訂目錄忽略(視專案需求加上) +# skip-dirs: +# - vendor +# - third_party + +# 可以設定本機與 CI 上都一致 +# env: +# GOLANGCI_LINT_CACHE: ".golangci-lint-cache" diff --git a/pkg/library/cassandra/Makefile b/pkg/library/cassandra/Makefile new file mode 100644 index 0000000..eb3bf4e --- /dev/null +++ b/pkg/library/cassandra/Makefile @@ -0,0 +1,8 @@ +GOFMT ?= gofmt "-s" +GOFILES := $(shell find . -name "*.go") + +.PHONY: fmt +fmt: # 格式優化 + $(GOFMT) -w $(GOFILES) + goimports -w ./ + golangci-lint run \ No newline at end of file diff --git a/pkg/library/cassandra/batch.go b/pkg/library/cassandra/batch.go new file mode 100644 index 0000000..6069521 --- /dev/null +++ b/pkg/library/cassandra/batch.go @@ -0,0 +1,113 @@ +package cassandra + +import ( + "context" + "reflect" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3" + "github.com/scylladb/gocqlx/v3/qb" + "github.com/scylladb/gocqlx/v3/table" +) + +// TODO: 只保證同一個 PK 下有一致性,中間有失敗的話可能只有失敗不會寫入,其他成功的還是會成功。 +// 之後會朝兩個方向走 +// 1. 最終一致性:目前的設計是直接寫入副表,然後透過 background worker 讀取 sync_task 表,補寫副表資料。 +// 2. 研究 自己做 TX_ID 以及 STATUS 的方案 +// 這個是已知問題,一定要解決 + +// NewBatch 創建一個新的 Batch 操作 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) NewBatch(ctx context.Context, keyspace string) *Batch { + keyspace = getKeyspace(db, keyspace) + session := db.GetSession() + return &Batch{ + ctx: ctx, + keyspace: keyspace, + db: db, + batch: gocqlx.Batch{ + Batch: session.NewBatch(gocql.LoggedBatch).WithContext(ctx), + }, + } +} + +type Batch struct { + ctx context.Context + keyspace string + db *CassandraDB + batch gocqlx.Batch +} + +func (tx *Batch) Insert(doc any) error { + metadata, err := GenerateTableMetadata(doc, tx.keyspace) + if err != nil { + return err + } + tbl := table.New(metadata) + stmt, names := tbl.Insert() + return tx.batch.BindStruct(tx.db.GetSession().Query(stmt, names), doc) +} + +func (tx *Batch) Delete(doc any) error { + metadata, err := GenerateTableMetadata(doc, tx.keyspace) + if err != nil { + return err + } + tbl := table.New(metadata) + stmt, names := tbl.Delete() + return tx.batch.BindStruct(tx.db.GetSession().Query(stmt, names), doc) +} + +func (tx *Batch) Update(doc any) error { + metadata, err := GenerateTableMetadata(doc, tx.keyspace) + if err != nil { + return err + } + v := reflect.ValueOf(doc) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + typ := v.Type() + + setCols := make([]string, 0) + setVals := make([]any, 0) + whereCols := make([]string, 0) + whereVals := make([]any, 0) + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + val := v.Field(i) + if !val.IsValid() { + continue + } + if contains(metadata.PartKey, tag) || contains(metadata.SortKey, tag) { + whereCols = append(whereCols, tag) + whereVals = append(whereVals, val.Interface()) + } else if !isZero(val) { + setCols = append(setCols, tag) + setVals = append(setVals, val.Interface()) + } + } + + if len(setCols) == 0 { + return ErrNoFieldsToUpdate.WithTable(metadata.Name) + } + + builder := qb.Update(metadata.Name).Set(setCols...) + for _, col := range whereCols { + builder = builder.Where(qb.Eq(col)) + } + stmt, names := builder.ToCql() + setVals = append(setVals, whereVals...) + return tx.batch.Bind(tx.db.GetSession().Query(stmt, names), setVals...) +} + +func (tx *Batch) Commit() error { + session := tx.db.GetSession() + + return session.ExecuteBatch(&tx.batch) +} diff --git a/pkg/library/cassandra/batch_test.go b/pkg/library/cassandra/batch_test.go new file mode 100644 index 0000000..c9ff01b --- /dev/null +++ b/pkg/library/cassandra/batch_test.go @@ -0,0 +1,44 @@ +package cassandra + +import ( + "context" + "testing" + "time" + + "github.com/gocql/gocql" + "github.com/stretchr/testify/assert" +) + +func TestBatchTx_AllSuccess(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ks := generateRandomKeySpace(t) + now := time.Now() + id1 := gocql.TimeUUID() + id2 := gocql.TimeUUID() + + tx := cassandraDBTest.NewBatch(ctx, ks) + err := tx.Insert(&MonkeyEntity{ID: id1, Name: "Alice", UpdateAt: now, CreateAt: now}) + assert.NoError(t, err) + err = tx.Insert(&MonkeyEntity{ID: id2, Name: "Bob", UpdateAt: now, CreateAt: now}) + assert.NoError(t, err) + err = tx.Update(&MonkeyEntity{ID: id1, Name: "Alice", UpdateAt: now.Add(5 * time.Minute)}) + assert.NoError(t, err) + err = tx.Delete(&MonkeyEntity{ID: id2, Name: "Bob"}) + assert.NoError(t, err) + + err = tx.Commit() + assert.NoError(t, err) + + // Alice 應該還在,且被更新 + var alice MonkeyEntity + alice.ID, alice.Name = id1, "Alice" + err = cassandraDBTest.Get(ctx, &alice, ks) + assert.NoError(t, err) + assert.WithinDuration(t, now.Add(5*time.Minute), alice.UpdateAt, time.Second) + + // Bob 應該被刪除 + err = cassandraDBTest.Get(ctx, &MonkeyEntity{ID: id2, Name: "Bob"}, ks) + assert.Error(t, err) +} diff --git a/pkg/library/cassandra/cassandra_test.go b/pkg/library/cassandra/cassandra_test.go new file mode 100644 index 0000000..d0dfeeb --- /dev/null +++ b/pkg/library/cassandra/cassandra_test.go @@ -0,0 +1,204 @@ +package cassandra + +import ( + "context" + "fmt" + "log" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/gocql/gocql" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +type Container struct { + Ctx context.Context + Container testcontainers.Container + Host string + Port int +} + +var cassandraDBTest *CassandraDB +var keyspaceSequence atomic.Int64 + +func TestMain(m *testing.M) { + container, db := connCassandraForTest() + cassandraDBTest = db + + code := m.Run() + + cassandraDBTest.Close() + + if err := container.Container.Terminate(container.Ctx); err != nil { + log.Fatalf("Failed to terminate Cassandra container: %v", err) + } + + log.Println("[TEST] Container terminated") + + os.Exit(code) +} + +func initCassandraContainer(version string) (Container, error) { + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: fmt.Sprintf("cassandra:%s", version), + Env: map[string]string{ + "CASSANDRA_START_RPC": "true", + "CASSANDRA_NUM_TOKENS": "1", + "CASSANDRA_ENDPOINT_SNITCH": "GossipingPropertyFileSnitch", + "CASSANDRA_DC": "datacenter1", + "CASSANDRA_RACK": "rack1", + "MAX_HEAP_SIZE": "256M", + "HEAP_NEWSIZE": "100M", + }, + ExposedPorts: []string{"9042/tcp"}, + // 等待 Cassandra 啟動完成的指標字串,依據實際啟動 log 可調整 + WaitingFor: wait.ForLog("Created default superuser role 'cassandra'"). + WithStartupTimeout(2 * time.Minute), + } + cassandraContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return Container{}, err + } + + host, err := cassandraContainer.Host(ctx) + if err != nil { + return Container{}, err + } + + mappedPort, err := cassandraContainer.MappedPort(ctx, "9042") + if err != nil { + return Container{}, err + } + + return Container{ctx, cassandraContainer, host, mappedPort.Int()}, nil +} + +func connCassandraForTest() (Container, *CassandraDB) { + // 啟動 Cassandra container + dbContainer, err := initCassandraContainer("5.0.4") + if err != nil { + log.Fatalf("Failed to initialize Cassandra container: %v", err) + } + + db, err := NewCassandraDB( + []string{dbContainer.Host}, + WithPort(dbContainer.Port), + WithConsistency(gocql.One), + WithNumConns(5), + ) + if err != nil { + log.Fatalf("Failed to initialize Cassandra DB: %v", err) + } + + // 建立 keyspace 和 table + err = db.EnsureTable(` +CREATE KEYSPACE IF NOT EXISTS my_keyspace +WITH replication = { + 'class': 'SimpleStrategy', + 'replication_factor': 1 +};`) + if err != nil { + log.Fatalf("Failed to create keyspace: %v", err) + } + + err = db.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.monkey_entity ( + id UUID, + name TEXT, + update_at TIMESTAMP, + create_at TIMESTAMP, + PRIMARY KEY ((id), name) +);`) + if err != nil { + log.Fatalf("Failed to create table: %v", err) + } + + return dbContainer, db +} + +func generateRandomKeySpace(t *testing.T) string { + ks := fmt.Sprintf("my_keyspace_%d", keyspaceSequence.Add(1)) + err := cassandraDBTest.EnsureTable(fmt.Sprintf(` +CREATE KEYSPACE IF NOT EXISTS %s +WITH replication = { + 'class': 'SimpleStrategy', + 'replication_factor': 1 +};`, ks)) + if err != nil { + t.Fatalf("Failed to create keyspace: %v", err) + } + + err = cassandraDBTest.EnsureTable(fmt.Sprintf(` +CREATE TABLE IF NOT EXISTS %s.monkey_entity ( + id UUID, + name TEXT, + update_at TIMESTAMP, + create_at TIMESTAMP, + PRIMARY KEY ((id), name) + );`, ks)) + if err != nil { + log.Fatalf("Failed to create table: %v", err) + } + + return ks +} + +// Animal 為不實作 TableName 方法的範例 struct,則會以型別名稱轉換成 snake_case +type Animal struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Type string `db:"type"` +} + +func (m *Animal) TableName() string { + return "animal" +} + +// InvalidEntity 為無 partition key 的範例 struct,預期產生錯誤 +type InvalidEntity struct { + Field string `db:"field"` +} + +type MonkeyEntity struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Name string `db:"name" clustering_key:"true" sai:"true"` + UpdateAt time.Time `db:"update_at"` + CreateAt time.Time `db:"create_at"` +} + +func (m *MonkeyEntity) TableName() string { + return "monkey_entity" +} + +type CatEntity struct { + ID *gocql.UUID `db:"id" partition_key:"true"` + Name *string `db:"name" partition_key:"true"` + UpdateAt *time.Time `db:"update_at"` + CreateAt *time.Time `db:"create_at" clustering_key:"true"` +} + +func (m *CatEntity) TableName() string { + return "cat_entity" +} + +type Consistency struct { + ID gocql.UUID `db:"id" partition_key:"true"` + ConsistencyName string `db:"consistency_name"` // can editor + ConsistencyType string `db:"consistency_type"` + LastTaskID string `db:"last_task_id"` // ConsistencyTask ID + Target string `db:"target"` // file name can editor + Status string `db:"status"` + ConsistencyMap string `db:"consistency_map"` // JSON string + CreateAT int64 `db:"create_at"` + UpdateAT int64 `db:"update_at"` +} + +func (c *Consistency) TableName() string { + return "consistency" +} diff --git a/pkg/library/cassandra/client.go b/pkg/library/cassandra/client.go new file mode 100644 index 0000000..a679439 --- /dev/null +++ b/pkg/library/cassandra/client.go @@ -0,0 +1,209 @@ +package cassandra + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3" +) + +// cassandraConf 是初始化 CassandraDB 所需的內部設定(私有) +type cassandraConf struct { + Hosts []string // Cassandra 主機列表 + Port int // 連線埠 + Keyspace string // 預設使用的 Keyspace + Username string // 認證用戶名 + Password string // 認證密碼 + Consistency gocql.Consistency // 一致性級別 + ConnectTimeoutSec int // 連線逾時秒數 + NumConns int // 每個節點連線數 + MaxRetries int // 重試次數 + UseAuth bool // 是否使用帳號密碼驗證 + RetryMinInterval time.Duration // 重試間隔最小值 + RetryMaxInterval time.Duration // 重試間隔最大值 + ReconnectInitialInterval time.Duration // 重連初始間隔 + ReconnectMaxInterval time.Duration // 重連最大間隔 + CQLVersion string // 執行連線的CQL 版本號 +} + +// CassandraDB 是封裝了 Cassandra 資料庫 session 的結構 +type CassandraDB struct { + session gocqlx.Session + SaiSupported bool // 是否支援 sai + Version string // 資料庫版本 + defaultKeyspace string // 預設 keyspace +} + +// NewCassandraDB 初始化並建立 Cassandra 資料庫連線,使用預設設定並可透過Option修改 +func NewCassandraDB(hosts []string, opts ...Option) (*CassandraDB, error) { + config := &cassandraConf{ + Hosts: hosts, + Port: defaultPort, + Consistency: defaultConsistency, + ConnectTimeoutSec: defaultTimeoutSec, + NumConns: defaultNumConns, + MaxRetries: defaultMaxRetries, + RetryMinInterval: defaultRetryMinInterval, + RetryMaxInterval: defaultRetryMaxInterval, + ReconnectInitialInterval: defaultReconnectInitialInterval, + ReconnectMaxInterval: defaultReconnectMaxInterval, + CQLVersion: defaultCqlVersion, + } + + // 套用Option設定選項 + for _, opt := range opts { + opt(config) + } + + // 建立連線設定 + cluster := gocql.NewCluster(config.Hosts...) + cluster.Port = config.Port + cluster.Consistency = config.Consistency + cluster.Timeout = time.Duration(config.ConnectTimeoutSec) * time.Second + cluster.NumConns = config.NumConns + cluster.RetryPolicy = &gocql.ExponentialBackoffRetryPolicy{ + NumRetries: config.MaxRetries, + Min: config.RetryMinInterval, + Max: config.RetryMaxInterval, + } + + cluster.ReconnectionPolicy = &gocql.ExponentialReconnectionPolicy{ + MaxRetries: config.MaxRetries, + InitialInterval: config.ReconnectInitialInterval, + MaxInterval: config.ReconnectMaxInterval, + } + + // 若有提供 Keyspace 則指定 + if config.Keyspace != "" { + cluster.Keyspace = config.Keyspace + } + + // 若啟用驗證則設定帳號密碼 + if config.UseAuth { + cluster.Authenticator = gocql.PasswordAuthenticator{ + Username: config.Username, + Password: config.Password, + } + } + + // 建立 Session + s, err := gocqlx.WrapSession(cluster.CreateSession()) + if err != nil { + return nil, fmt.Errorf("failed to connect to Cassandra cluster (hosts: %v, port: %d): %w", config.Hosts, config.Port, err) + } + + db := &CassandraDB{ + session: s, + defaultKeyspace: config.Keyspace, + } + + version, err := db.getReleaseVersion() + if err != nil { + return nil, fmt.Errorf("failed to get DB version: %w", err) + } + + db.Version = version + db.SaiSupported = isSAISupported(version) + + return db, nil +} + +// Close 關閉 Cassandra 資料庫連線 +func (db *CassandraDB) Close() { + db.session.Close() +} + +// GetSession 返回目前使用的 Cassandra Session +func (db *CassandraDB) GetSession() gocqlx.Session { + return db.session +} + +// GetDefaultKeyspace 返回預設的 keyspace +func (db *CassandraDB) GetDefaultKeyspace() string { + return db.defaultKeyspace +} + +// WithKeyspace 返回一個帶有指定 keyspace 的查詢構建器 +// 如果 keyspace 為空,則使用預設 keyspace +func (db *CassandraDB) WithKeyspace(keyspace string) *KeyspaceDB { + if keyspace == "" { + keyspace = db.defaultKeyspace + } + return &KeyspaceDB{ + db: db, + keyspace: keyspace, + } +} + +// KeyspaceDB 是帶有 keyspace 的資料庫包裝器 +type KeyspaceDB struct { + db *CassandraDB + keyspace string +} + +// GetSession 返回 session +func (kdb *KeyspaceDB) GetSession() gocqlx.Session { + return kdb.db.GetSession() +} + +// GetKeyspace 返回 keyspace +func (kdb *KeyspaceDB) GetKeyspace() string { + return kdb.keyspace +} + +// EnsureTable 確認並建立資料表 +func (db *CassandraDB) EnsureTable(schema string) error { + return db.session.ExecStmt(schema) +} + +func (db *CassandraDB) InitVersionSupport() error { + version, err := db.getReleaseVersion() + if err != nil { + return err + } + db.Version = version + db.SaiSupported = isSAISupported(version) + return nil +} + +func (db *CassandraDB) getReleaseVersion() (string, error) { + var version string + stmt := "SELECT release_version FROM system.local" + err := db.GetSession().Query(stmt, []string{"release_version"}).Consistency(gocql.One).Scan(&version) + return version, err +} + +func isSAISupported(version string) bool { + // 只要 major >=5 就支援 + // 4.0.9+ 才有 SAI,但不穩,強烈建議 5.0+ + parts := strings.Split(version, ".") + if len(parts) < 2 { + return false + } + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + + if major >= 5 { + return true + } + + if major == 4 { + if minor > 0 { // 4.1.x、4.2.x 直接支援 + return true + } + if minor == 0 { + patch := 0 + if len(parts) >= 3 { + patch, _ = strconv.Atoi(parts[2]) + } + if patch >= 9 { + return true + } + } + } + + return false +} diff --git a/pkg/library/cassandra/client_test.go b/pkg/library/cassandra/client_test.go new file mode 100644 index 0000000..ad3eb28 --- /dev/null +++ b/pkg/library/cassandra/client_test.go @@ -0,0 +1,210 @@ +package cassandra + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSAISupported(t *testing.T) { + tests := []struct { + version string + expected bool + }{ + {"5.0.0", true}, // 5.x 支援 + {"5.1.2", true}, // 5.x 支援 + {"6.0.0", true}, // 6.x 理論上也支援 + {"4.0.8", false}, // 4.0.8 不支援 + {"4.0.9", true}, // 4.0.9 支援 + {"4.1.0", true}, // 4.1.0 支援 + {"4.2.2", true}, // 4.2.2 支援 + {"3.11.10", false}, // 3.x 不支援 + {"3.0.0", false}, + {"", false}, // 空字串,不支援 + {"unknown", false}, // 無效格式 + {"4", false}, // 缺 patch,不支援 + {"4.0", false}, // 缺 patch,不支援 + {"5", false}, // 缺 minor + {"5.0", true}, // 5.0 預設支援 + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + result := isSAISupported(tt.version) + assert.Equal(t, tt.expected, result, "version: %s", tt.version) + }) + } +} + +// TestCassandraDB_Integration_TableDriven 使用 table-driven 方式整合測試 +// func TestCassandraDB_Integration_TableDriven(t *testing.T) { +// // 啟動 Cassandra container +// dbContainer, err := initCassandraContainer("5.0.4") +// defer func() { +// _ = dbContainer.Container.Terminate(dbContainer.Ctx) +// fmt.Println("[TEST] Container terminated") +// }() + +// // 建立 CassandraDB 連線 +// hosts := []string{dbContainer.Host} +// db, err := NewCassandraDB( +// hosts, +// WithPort(dbContainer.Port), +// WithConsistency(gocql.One), +// WithNumConns(2), +// ) +// assert.NoError(t, err, "should success create CassandraDB") +// assert.NotNil(t, db, "db should not be nil") +// assert.NotNil(t, db.GetSession(), "get Session should not be nil") + +// err = db.EnsureTable("CREATE KEYSPACE my_keyspace\nWITH replication = {\n 'class': 'SimpleStrategy',\n 'replication_factor': 1\n};\n") +// assert.NoError(t, err, "should success ensure table") +// // 注意:由於 Close 會關閉 session,因此請把測試 Close 的子案例放在所有使用 session 的子案例之後 +// tests := []struct { +// name string +// action func() error +// wantErr bool +// }{ +// { +// name: "ok", +// action: func() error { +// // 建立一個合法的資料表 (使用 IF NOT EXISTS 避免重複建立錯誤) +// schema := "CREATE TABLE IF NOT EXISTS my_keyspace.test (id uuid PRIMARY KEY, name text)" +// return db.EnsureTable(schema) +// }, +// wantErr: false, +// }, +// { +// name: "failed to ensure table since wrong schema", +// action: func() error { +// // 傳入無效的 CQL 語法,預期應回傳錯誤 +// schema := "CREATE TABLE invalid schema" +// return db.EnsureTable(schema) +// }, +// wantErr: true, +// }, +// { +// name: "GetSession 返回有效 Session", +// action: func() error { +// if db.GetSession().Session == nil { +// return fmt.Errorf("session is nil") +// } + +// return nil +// }, +// wantErr: false, +// }, +// { +// name: "Close close Session", +// action: func() error { +// db.Close() +// // 無法直接驗證內部是否已關閉,但可避免再次使用 session 產生 panic +// return nil +// }, +// wantErr: false, +// }, +// } + +// // 依序執行各子案例 +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// err := tc.action() +// if (err != nil) != tc.wantErr { +// t.Errorf("%s havs error = %v, wantErr %v", tc.name, err, tc.wantErr) +// } +// }) +// } +// } + +// Mark: new multiple container lead to unit test too slow +// func TestCassandraDB_getReleaseVersion(t *testing.T) { +// t.Parallel() + +// type fields struct { +// Version string +// } + +// tests := []struct { +// name string +// fields fields +// want string +// wantError bool +// }{ +// { +// name: "3", +// fields: fields{Version: "3.11"}, +// want: "3.11.19", +// wantError: false, +// }, +// { +// name: "5", +// fields: fields{Version: "5.0.4"}, +// want: "5.0.4", +// wantError: false, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// container, err := initCassandraContainer(tt.fields.Version) +// defer func() { +// _ = container.Container.Terminate(container.Ctx) +// fmt.Println("[TEST] Container terminated") +// }() + +// if !tt.wantError { +// assert.NoError(t, err) +// // 建立 CassandraDB 連線 +// hosts := []string{container.Host} +// db, err := NewCassandraDB( +// hosts, +// WithPort(container.Port), +// WithConsistency(gocql.One), +// WithNumConns(2), +// ) +// assert.NoError(t, err) +// version, err := db.getReleaseVersion() +// assert.NoError(t, err) +// assert.Equal(t, version, tt.want) +// } +// }) +// } +// } + +func TestCassandraDB_getReleaseVersion(t *testing.T) { + t.Parallel() + + type fields struct { + Version string + } + + tests := []struct { + name string + fields fields + want string + wantError bool + }{ + // { + // name: "3", + // fields: fields{Version: "3.11"}, + // want: "3.11.19", + // wantError: false, + // }, + { + name: "5.0.4", + fields: fields{Version: "5.0.4"}, + want: "5.0.4", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.wantError { + version, err := cassandraDBTest.getReleaseVersion() + assert.NoError(t, err) + assert.Equal(t, version, tt.want) + } + }) + } +} diff --git a/pkg/library/cassandra/const.go b/pkg/library/cassandra/const.go new file mode 100644 index 0000000..534e43c --- /dev/null +++ b/pkg/library/cassandra/const.go @@ -0,0 +1,21 @@ +package cassandra + +import ( + "time" + + "github.com/gocql/gocql" +) + +// 預設設定常數 +const ( + defaultNumConns = 10 // 預設每個節點的連線數量 + defaultTimeoutSec = 10 // 預設連線逾時秒數 + defaultMaxRetries = 3 // 預設重試次數 + defaultPort = 9042 + defaultConsistency = gocql.Quorum + defaultRetryMinInterval = 1 * time.Second + defaultRetryMaxInterval = 30 * time.Second + defaultReconnectInitialInterval = 1 * time.Second + defaultReconnectMaxInterval = 60 * time.Second + defaultCqlVersion = "3.0.0" +) diff --git a/pkg/library/cassandra/crud.go b/pkg/library/cassandra/crud.go new file mode 100644 index 0000000..8f29723 --- /dev/null +++ b/pkg/library/cassandra/crud.go @@ -0,0 +1,203 @@ +package cassandra + +import ( + "context" + "reflect" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3/qb" + "github.com/scylladb/gocqlx/v3/table" +) + +var qh = &queryHelper{} + +// Insert 依據 document 自動產生 INSERT 語句並執行 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) Insert(ctx context.Context, document any, keyspace string) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(document, keyspace) + if err != nil { + return err + } + t := table.New(metadata) + q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(t.Insert()).BindStruct(document)) + return q.ExecRelease() +} + +// Get 根據 struct 的 Primary Key 查詢單筆資料(Get ByPK) +// - filter 為目標資料 struct,其欄位需對應表格的 Primary Key 欄位(Partition Key + Clustering Key) +// - Cassandra 中 Primary Key 是由 Partition Key 與 Clustering Key 組成的整體,作為唯一識別一筆資料的 key +// - Cassandra 並不保證 Partition Key 或 Clustering Key 單獨具有唯一性,只有整個 Primary Key 才是唯一 +// - Partition Key 的作用是將資料分布到不同節點(Node),Clustering Key 則是節點內排序資料用 +// - 如果僅提供 Partition Key,會查到分區內的多筆資料,但由於 .Get() 預設加 LIMIT 1,僅會取得其中一筆(排序第一) +// - 若想查詢特定欄位(如 name)但該欄位不是 Primary Key 組成部分,則無法使用 .Get() 查詢,也無法用該欄位直接篩選資料(會報錯) +// - 解法是:1. 改變 table 結構使欲查欄位成為 PK,或 2. 建立額外 table 以該欄位為 Partition Key,或 3. 使用 ALLOW FILTERING(不建議) + +// Get 根據 struct 的 Primary Key 查詢單筆資料(Get ByPK) +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) Get(ctx context.Context, dest any, keyspace string) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(dest, keyspace) + if err != nil { + return err + } + t := table.New(metadata) + q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(t.Get()).BindStruct(dest)) + err = q.GetRelease(dest) + + if err == gocql.ErrNotFound { + return ErrNotFound.WithTable(metadata.Name) + } else if err != nil { + return ErrInvalidInput.WithTable(metadata.Name).WithError(err) + } + + return nil +} + +// Delete 依據 document 的主鍵產生 DELETE 語句並執行 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) Delete(ctx context.Context, filter any, keyspace string) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(filter, keyspace) + if err != nil { + return err + } + t := table.New(metadata) + stmt, names := t.Delete() + q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).BindStruct(filter)) + + return q.ExecRelease() +} + +// Update 根據 document 欄位產生 UPDATE 語句並執行 +// - 只會更新非零值或非 nil 的欄位(零值欄位會被排除) +// - 主鍵欄位一定會保留,作為 WHERE 條件使用 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) Update(ctx context.Context, document any, keyspace string) error { + return db.UpdateSelective(ctx, document, keyspace, false) +} + +// UpdateSelective 根據 document 欄位產生 UPDATE 語句並執行 +// - includeZero: false 時只更新非零值欄位(等同於 Update),true 時更新所有欄位(包括零值) +// - 主鍵欄位一定會保留,作為 WHERE 條件使用 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) UpdateSelective(ctx context.Context, document any, keyspace string, includeZero bool) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(document, keyspace) + if err != nil { + return err + } + + v := reflect.ValueOf(document) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + typ := v.Type() + + // 收集更新欄位與其值(根據 includeZero 決定是否包含零值,保留主鍵) + setCols := make([]string, 0) + setVals := make([]any, 0) + whereCols := make([]string, 0) + whereVals := make([]any, 0) + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + + val := v.Field(i) + if !val.IsValid() { + continue + } + + if contains(metadata.PartKey, tag) || contains(metadata.SortKey, tag) { + whereCols = append(whereCols, tag) + whereVals = append(whereVals, val.Interface()) + continue + } + + if !includeZero && isZero(val) { + continue + } + + setCols = append(setCols, tag) + setVals = append(setVals, val.Interface()) + } + + if len(setCols) == 0 { + return ErrNoFieldsToUpdate.WithTable(metadata.Name) + } + + // Build UPDATE statement + builder := qb.Update(metadata.Name).Set(setCols...) + for _, col := range whereCols { + builder = builder.Where(qb.Eq(col)) + } + stmt, names := builder.ToCql() + + setVals = append(setVals, whereVals...) + q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).Bind(setVals...)) + + return q.ExecRelease() +} + +// UpdateAll 更新所有欄位(包括零值) +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) UpdateAll(ctx context.Context, document any, keyspace string) error { + return db.UpdateSelective(ctx, document, keyspace, true) +} + +// TODO: Cassandra 不支援 OFFSET 方式的分頁(例如查詢第 N 頁) +// 原因:Cassandra 是分散式資料庫,設計上不允許像傳統 SQL 那樣用 OFFSET 跳頁,會導致效能極差 +// ✅ 正確方式為使用 PagingState 做游標式(Cursor-based)分頁,一頁一頁往後翻 +// ✅ 如果需要快取第 N 頁位置,應在應用層儲存每一頁的 PagingState 以供跳轉 +// ❌ Cassandra 不適合直接實作全站排行榜或全表分頁查詢,除非搭配 ElasticSearch 或針對 Partition Key 分頁設計 +// 若未來有特定分區(如 user_id)條件,可考慮實作分區內的分頁邏輯以提高效能 + +// GetAll 取得指定 struct 類型在 Cassandra 中的所有資料 +// - filter:用來推斷 table 結構的範例物件(可為指標) +// - result:要寫入的 slice 指標,如 *[]MyStruct +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) GetAll(ctx context.Context, filter any, result any, keyspace string) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(filter, keyspace) + if err != nil { + return err + } + t := table.New(metadata) + + stmt, names := qb.Select(t.Name()).Columns(metadata.Columns...).ToCql() + q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names)) + + return q.SelectRelease(result) +} + +// QueryBuilder executes a query with optional conditions on Cassandra table +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) QueryBuilder( + ctx context.Context, + tableStruct any, + result any, + keyspace string, + opts ...QueryOption, +) error { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(tableStruct, keyspace) + if err != nil { + return err + } + tbl := table.New(metadata) + + builder := qb.Select(tbl.Name()).Columns(metadata.Columns...) + bindMap := qb.M{} + for _, opt := range opts { + opt(builder, bindMap) + } + + stmt, names := builder.ToCql() + query := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).BindMap(bindMap)) + + return query.SelectRelease(result) +} diff --git a/pkg/library/cassandra/crud_test.go b/pkg/library/cassandra/crud_test.go new file mode 100644 index 0000000..34d8e05 --- /dev/null +++ b/pkg/library/cassandra/crud_test.go @@ -0,0 +1,363 @@ +package cassandra + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3/qb" + "github.com/stretchr/testify/assert" +) + +func TestInsert(t *testing.T) { + t.Parallel() + ks := generateRandomKeySpace(t) + ctx := context.Background() + now := time.Now() + // 測試案例(可擴充) + tests := []struct { + name string + input MonkeyEntity + }{ + { + name: "insert George", + input: MonkeyEntity{ + ID: gocql.TimeUUID(), + Name: "George", + UpdateAt: now, + CreateAt: now, + }, + }, + { + name: "insert Bob", + input: MonkeyEntity{ + ID: gocql.TimeUUID(), + Name: "Bob", + UpdateAt: now, + CreateAt: now, + }, + }, + { + name: "insert Alice", + input: MonkeyEntity{ + ID: gocql.TimeUUID(), + Name: "Alice", + UpdateAt: now, + CreateAt: now, + }, + }, + } + + // 執行測試 + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := cassandraDBTest.Insert(ctx, &tc.input, ks) + assert.NoError(t, err) + + // 驗證寫入 + var name string + q := cassandraDBTest.GetSession().Query(fmt.Sprintf("SELECT name FROM %s.monkey_entity WHERE id = ?", ks), []string{"name"}) + err = q.Bind(tc.input.ID).GetRelease(&name) + assert.NoError(t, err) + assert.Equal(t, tc.input.Name, name) + }) + } +} + +func TestGet(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ks := generateRandomKeySpace(t) + now := time.Now() + monkey := MonkeyEntity{ + ID: gocql.TimeUUID(), + Name: "George", + UpdateAt: now, + CreateAt: now, + } + + // 插入一筆資料 + err := cassandraDBTest.Insert(ctx, &monkey, ks) + assert.NoError(t, err) + + tests := []struct { + name string + filter MonkeyEntity + expect string + }{ + { + name: "Get existing monkey", + filter: MonkeyEntity{ID: monkey.ID, Name: monkey.Name}, + expect: "George", + }, + { + name: "Get non-existent monkey", + filter: MonkeyEntity{ID: gocql.TimeUUID(), Name: "GG"}, + expect: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := tc.filter // 預設填入主鍵 + err := cassandraDBTest.Get(ctx, &result, ks) + + if tc.expect == "" { + assert.Error(t, err, "expected error for missing record") + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expect, result.Name) + } + }) + } +} + +func TestDelete(t *testing.T) { + t.Parallel() + + ks := generateRandomKeySpace(t) + ctx := context.Background() + now := time.Now() + monkey := MonkeyEntity{ + ID: gocql.TimeUUID(), + Name: "DeleteMe", + UpdateAt: now, + CreateAt: now, + } + + // 插入資料 + err := cassandraDBTest.Insert(ctx, &monkey, ks) + assert.NoError(t, err) + + // 先確認有插入成功 + verify := MonkeyEntity{ID: monkey.ID, Name: monkey.Name} + err = cassandraDBTest.Get(ctx, &verify, ks) + assert.NoError(t, err) + assert.Equal(t, "DeleteMe", verify.Name) + + // 執行刪除 + err = cassandraDBTest.Delete(ctx, &monkey, ks) + assert.NoError(t, err) + + // 再查,應該查不到 + result := MonkeyEntity{ID: monkey.ID, Name: monkey.Name} + err = cassandraDBTest.Get(ctx, &result, ks) + assert.Error(t, err, "expected error because record should be deleted") +} + +func TestUpdate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ks := generateRandomKeySpace(t) + now := time.Now() + id := gocql.TimeUUID() + + // Step 1: 插入初始資料 + monkey := MonkeyEntity{ + ID: id, + Name: "OldName", + UpdateAt: now, + CreateAt: now, + } + err := cassandraDBTest.Insert(ctx, &monkey, ks) + assert.NoError(t, err) + + // Step 2: 更新 UpdateAt 欄位(模擬只更新一欄) + updatedTime := now.Add(10 * time.Minute) + updateDoc := MonkeyEntity{ + ID: id, + Name: "OldName", // 主鍵 + UpdateAt: updatedTime, + // CreateAt 是零值,不會被更新 + } + err = cassandraDBTest.Update(ctx, &updateDoc, ks) + assert.NoError(t, err) + + // Step 3: 查詢回來驗證更新 + result := MonkeyEntity{ + ID: id, + Name: "OldName", + } + err = cassandraDBTest.Get(ctx, &result, ks) + assert.NoError(t, err) + assert.WithinDuration(t, updatedTime, result.UpdateAt, time.Second) + assert.WithinDuration(t, now, result.CreateAt, time.Second) // 未被更新 +} + +func insertSampleConsistency(t *testing.T, db *CassandraDB, ctx context.Context, keyspace string) *Consistency { + + err := db.EnsureTable(` + CREATE TABLE IF NOT EXISTS my_keyspace.consistency ( + id UUID, + consistency_name TEXT, + last_task_id TEXT, + target TEXT, + status TEXT, + consistency_type TEXT, + consistency_map TEXT, + create_at BIGINT, + update_at BIGINT, + PRIMARY KEY ((id)) + );`) + assert.NoError(t, err) + + c := &Consistency{ + ID: gocql.TimeUUID(), + ConsistencyName: "query-test", + LastTaskID: "task-1", + Target: "test.csv", + Status: "Running", + ConsistencyType: "simple", + ConsistencyMap: `{"example": "value"}`, + CreateAT: time.Now().UnixNano(), + UpdateAT: time.Now().UnixNano(), + } + + err = db.Insert(ctx, c, keyspace) + assert.NoError(t, err) + return c +} + +func TestQueryBuilder_WithWhere(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + saved := insertSampleConsistency(t, cassandraDBTest, ctx, "my_keyspace") + + t.Run("query by id", func(t *testing.T) { + var results []*Consistency + e := &Consistency{} + field := GetCqlTag(e, &e.ID) + err := cassandraDBTest.QueryBuilder( + ctx, + &Consistency{}, + &results, + "my_keyspace", + WithWhere( + []qb.Cmp{qb.Eq(field)}, + map[string]any{field: saved.ID.String()}, + ), + ) + + assert.NoError(t, err) + assert.NotEmpty(t, results) + + found := false + for _, r := range results { + if r.ID == saved.ID { + found = true + break + } + } + assert.True(t, found, "should find inserted consistency") + }) + + t.Run("query with unmatched id", func(t *testing.T) { + var results []*Consistency + + e := &Consistency{} + field := GetCqlTag(e, &e.ID) + err := cassandraDBTest.QueryBuilder( + ctx, + &Consistency{}, + &results, + "my_keyspace", + WithWhere( + []qb.Cmp{qb.Eq(field)}, + map[string]any{field: "NonExist"}, + ), + ) + + assert.Error(t, err) + assert.Empty(t, results) + }) + + t.Run("query by in", func(t *testing.T) { + var results []*Consistency + e := &Consistency{} + field := GetCqlTag(e, &e.ID) + err := cassandraDBTest.QueryBuilder( + ctx, + &Consistency{}, + &results, + "my_keyspace", + WithWhere( + []qb.Cmp{qb.In(field)}, + map[string]any{field: []gocql.UUID{saved.ID}}, + ), + ) + + assert.NoError(t, err) + assert.NotEmpty(t, results) + + found := false + for _, r := range results { + if r.ID == saved.ID { + found = true + break + } + } + assert.True(t, found, "should find inserted consistency") + }) + + t.Run("query by one is not in", func(t *testing.T) { + var results []*Consistency + e := &Consistency{} + field := GetCqlTag(e, &e.ID) + err := cassandraDBTest.QueryBuilder( + ctx, + &Consistency{}, + &results, + "my_keyspace", + WithWhere( + []qb.Cmp{qb.In(field)}, + map[string]any{field: []gocql.UUID{saved.ID, gocql.TimeUUID()}}, + ), + ) + + assert.NoError(t, err) + assert.NotEmpty(t, results) + + found := false + for _, r := range results { + if r.ID == saved.ID { + found = true + break + } + } + assert.True(t, found, "should find inserted consistency") + }) + + t.Run("query get all", func(t *testing.T) { + var results []*Consistency + e := &Consistency{} + err := cassandraDBTest.QueryBuilder( + ctx, + e, + &results, + "my_keyspace", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, results) + + found := false + for _, r := range results { + if r.ID == saved.ID { + found = true + break + } + } + assert.True(t, found, "should find inserted consistency") + }) + +} diff --git a/pkg/library/cassandra/error.go b/pkg/library/cassandra/error.go new file mode 100644 index 0000000..d7b672f --- /dev/null +++ b/pkg/library/cassandra/error.go @@ -0,0 +1,122 @@ +package cassandra + +import ( + "errors" + "fmt" +) + +// 定義統一的錯誤類型 +var ( + // ErrNotFound 表示記錄未找到 + ErrNotFound = &Error{ + Code: "NOT_FOUND", + Message: "record not found", + } + + // ErrAcquireLockFailed 表示獲取鎖失敗 + ErrAcquireLockFailed = &Error{ + Code: "LOCK_ACQUIRE_FAILED", + Message: "acquire lock failed", + } + + // ErrInvalidInput 表示輸入參數無效 + ErrInvalidInput = &Error{ + Code: "INVALID_INPUT", + Message: "invalid input parameter", + } + + // ErrNoPartitionKey 表示缺少 Partition Key + ErrNoPartitionKey = &Error{ + Code: "NO_PARTITION_KEY", + Message: "no partition key defined in struct", + } + + // ErrMissingTableName 表示缺少 TableName 方法 + ErrMissingTableName = &Error{ + Code: "MISSING_TABLE_NAME", + Message: "struct must implement TableName() method", + } + + // ErrNoFieldsToUpdate 表示沒有欄位需要更新 + ErrNoFieldsToUpdate = &Error{ + Code: "NO_FIELDS_TO_UPDATE", + Message: "no fields to update", + } + + // ErrMissingWhereCondition 表示缺少 WHERE 條件 + ErrMissingWhereCondition = &Error{ + Code: "MISSING_WHERE_CONDITION", + Message: "operation requires at least one WHERE condition for safety", + } + + // ErrMissingPartitionKey 表示 WHERE 條件中缺少 Partition Key + ErrMissingPartitionKey = &Error{ + Code: "MISSING_PARTITION_KEY", + Message: "operation requires all partition keys in WHERE clause", + } +) + +// Error 是統一的錯誤類型 +type Error struct { + Code string // 錯誤代碼 + Message string // 錯誤訊息 + Table string // 相關的表名(可選) + Err error // 底層錯誤(可選) +} + +// Error 實現 error 介面 +func (e *Error) Error() string { + if e.Table != "" { + if e.Err != nil { + return fmt.Sprintf("cassandra [%s] (table: %s): %s: %v", e.Code, e.Table, e.Message, e.Err) + } + return fmt.Sprintf("cassandra [%s] (table: %s): %s", e.Code, e.Table, e.Message) + } + if e.Err != nil { + return fmt.Sprintf("cassandra [%s]: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("cassandra [%s]: %s", e.Code, e.Message) +} + +// Unwrap 返回底層錯誤 +func (e *Error) Unwrap() error { + return e.Err +} + +// WithTable 為錯誤添加表名資訊 +func (e *Error) WithTable(table string) *Error { + return &Error{ + Code: e.Code, + Message: e.Message, + Table: table, + Err: e.Err, + } +} + +// WithError 為錯誤添加底層錯誤 +func (e *Error) WithError(err error) *Error { + return &Error{ + Code: e.Code, + Message: e.Message, + Table: e.Table, + Err: err, + } +} + +// NewError 創建新的錯誤 +func NewError(code, message string) *Error { + return &Error{ + Code: code, + Message: message, + } +} + +// IsNotFound 檢查錯誤是否為 NotFound +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) +} + +// IsLockFailed 檢查錯誤是否為獲取鎖失敗 +func IsLockFailed(err error) bool { + return errors.Is(err, ErrAcquireLockFailed) +} diff --git a/pkg/library/cassandra/ez_transaction.go b/pkg/library/cassandra/ez_transaction.go new file mode 100644 index 0000000..8f93857 --- /dev/null +++ b/pkg/library/cassandra/ez_transaction.go @@ -0,0 +1,285 @@ +package cassandra + +import ( + "context" + "fmt" + "reflect" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3" + "github.com/scylladb/gocqlx/v3/qb" + "github.com/scylladb/gocqlx/v3/table" +) + +/* +todo 目前尚未實作的部分,但因為目前使用上並沒有嚴格一致性,故目前簡易的版本可先行 + + 1. 讀寫一致性問題 + Cassandra 本身為最終一致性,如果在 Commit 期間網路有短暫中斷,可能造成部分操作成功、部分失敗的「半提交」狀態。 + Commit 之後,再次掃描 Steps,看是否所有 IsExec 都為 true,若有 false,則觸發額外的重試或警示機制。 + +2. 反射收集欄位的可靠度 + Update 方法透過反射與 isZero 來排除不更新欄位,但若結構體中出現自訂零值(如自訂型態有預設值),可能誤過濾掉真正要更新的欄位。 + 可能在資料模型層先明確標示「要更新的欄位列表」,或提供外部參數指明更新欄位,以減少反射過濾錯誤。 + +3. 交易邊界與隔離度 + 此實作並未提供交易隔離(Isolation),外部程式仍可能在交易尚未 Commit 時讀到中間狀態。 + 若對讀取一致性有嚴格要求,可考慮使用 Cassandra 的 Lightweight Transactions(LWT)搭配 IF NOT EXISTS / IF 條件,確保寫入前的前置檢查。 + +4. 錯誤重試與警示 + 當 Commit 中某個步驟失敗,直接返回錯誤,但沒有集中收集失敗資訊。 + 建議整合一個「監控與重試」機制,將失敗細節(step index、錯誤訊息)記錄到外部持久化系統,以便運維人員介入或自動重試。 + +5. 崩潰恢復 + 如果程式在 Commit 過程中程式本身當掉,記憶體中的 Steps 會丟失,無法回滾。 + 可以把 OperationLog 持久化到可靠的日誌表(Cassandra 或外部 DB),Commit 之前就先寫入,並在啟動時掃描未完成的交易回滾或重試。 +*/ + +type Action int64 + +const ( + ActionUnknown Action = iota + ActionInsert + ActionDelete + ActionUpdate +) + +// OperationLog 記錄操作日誌,用於補償回滾 +type OperationLog struct { + ID gocql.UUID // 操作ID,用來標識該操作 + Action Action // 操作類型(增、刪、改) + IsExec bool + Exec []*gocqlx.Queryx // 這一個步驟要執行的東西 + OldData any // 變更前的數據,僅對修改和刪除有效 + NewData any // 變更後的數據,僅對新增和修改有效 +} + +// CompensatingTransaction 補償式交易介面 +// 這是一個基於補償操作(Compensating Action)的交易模式,適用於最終一致性場景 +// 與傳統 ACID 交易不同,它不提供隔離性保證,但可以確保「要嘛全成功,要嘛全失敗」 +// 注意:這不是真正的原子性交易,而是透過記錄操作日誌並在失敗時執行補償操作來實現 +type CompensatingTransaction interface { + Insert(ctx context.Context, document any) error + Delete(ctx context.Context, filter any) error + Update(ctx context.Context, document any) error + Rollback() error + Commit() error +} + +// transaction 定義補償操作的結構 +type transaction struct { + ctx context.Context + keyspace string + db *CassandraDB + Steps []OperationLog // 用來記錄所有操作步驟的日誌 +} + +// NewCompensatingTransaction 創建一個新的補償式交易 +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func NewCompensatingTransaction(ctx context.Context, keyspace string, db *CassandraDB) CompensatingTransaction { + keyspace = getKeyspace(db, keyspace) + return &transaction{ + ctx: ctx, + keyspace: keyspace, + db: db, + Steps: []OperationLog{}, + } +} + +// NewEZTransaction 創建一個新的補償式交易(向後相容的別名) +// Deprecated: 使用 NewCompensatingTransaction 代替 +func NewEZTransaction(ctx context.Context, keyspace string, db *CassandraDB) CompensatingTransaction { + return NewCompensatingTransaction(ctx, keyspace, db) +} + +func (tx *transaction) Insert(ctx context.Context, document any) error { + metadata, err := GenerateTableMetadata(document, tx.keyspace) + if err != nil { + return err + } + t := table.New(metadata) + + q := qh.withContextAndTimestamp(ctx, tx.db.GetSession().Query(t.Insert()).BindStruct(document)) + logEntry := OperationLog{ + ID: gocql.TimeUUID(), + Action: ActionInsert, + Exec: []*gocqlx.Queryx{q}, + NewData: document, + } + tx.Steps = append(tx.Steps, logEntry) + + return nil +} + +func (tx *transaction) Delete(ctx context.Context, filter any) error { + metadata, err := GenerateTableMetadata(filter, tx.keyspace) + if err != nil { + return err + } + t := table.New(metadata) + + doc := filter + + get := tx.db.GetSession().Query(t.Get()).BindStruct(doc).WithContext(ctx) + q := qh.withContextAndTimestamp(ctx, tx.db.GetSession().Query(t.Delete()).BindStruct(filter)) + + logEntry := OperationLog{ + ID: gocql.TimeUUID(), + Action: ActionDelete, + Exec: []*gocqlx.Queryx{get, q}, // 有順序,要先拿取保留舊資料, + OldData: doc, // 保留結構體才有機會回復 + } + tx.Steps = append(tx.Steps, logEntry) + + return nil +} + +func (tx *transaction) Update(ctx context.Context, document any) error { + metadata, err := GenerateTableMetadata(document, tx.keyspace) + if err != nil { + return err + } + t := table.New(metadata) + + v := reflect.ValueOf(document) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + typ := v.Type() + + // 收集更新欄位與其值(排除零值,保留主鍵) + setCols := make([]string, 0) + setVals := make([]any, 0) + whereCols := make([]string, 0) + whereVals := make([]any, 0) + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + + val := v.Field(i) + if !val.IsValid() { + continue + } + + if contains(metadata.PartKey, tag) || contains(metadata.SortKey, tag) { + whereCols = append(whereCols, tag) + whereVals = append(whereVals, val.Interface()) + continue + } + + if isZero(val) { + continue + } + + setCols = append(setCols, tag) + setVals = append(setVals, val.Interface()) + } + + if len(setCols) == 0 { + return ErrNoFieldsToUpdate.WithTable(metadata.Name) + } + + // Build UPDATE statement + builder := qb.Update(metadata.Name).Set(setCols...) + for _, col := range whereCols { + builder = builder.Where(qb.Eq(col)) + } + stmt, names := builder.ToCql() + + setVals = append(setVals, whereVals...) + q := qh.withContextAndTimestamp(ctx, tx.db.GetSession().Query(stmt, names).Bind(setVals...)) + + doc := document + get := tx.db.GetSession().Query(t.Get()).BindStruct(doc).WithContext(ctx) + + logEntry := OperationLog{ + ID: gocql.TimeUUID(), + Action: ActionUpdate, + Exec: []*gocqlx.Queryx{get, q}, // 有順序,要先拿取保留舊資料,才可以 update + OldData: doc, // 保留結構體才有機會回復 + NewData: document, + } + tx.Steps = append(tx.Steps, logEntry) + + return nil +} + +func (tx *transaction) Rollback() error { + for _, item := range tx.Steps { + // 沒有做過的就不用回復了 + if !item.IsExec { + continue + } + + switch item.Action { + case ActionInsert: + err := tx.db.Delete(tx.ctx, item.NewData, tx.keyspace) + if err != nil { + // Rollback 失敗時繼續處理其他步驟,但最終會返回錯誤 + // 注意:這裡不記錄日誌,因為 library 包不應該直接記錄日誌 + // 調用者應該根據返回的錯誤進行日誌記錄 + continue + } + case ActionUpdate: + err := tx.db.Update(tx.ctx, item.OldData, tx.keyspace) + if err != nil { + // Rollback 失敗時繼續處理其他步驟,但最終會返回錯誤 + continue + } + case ActionDelete: + err := tx.db.Insert(tx.ctx, item.OldData, tx.keyspace) + if err != nil { + // Rollback 失敗時繼續處理其他步驟,但最終會返回錯誤 + continue + } + } + } + + return nil +} + +func (tx *transaction) Commit() error { + for i, step := range tx.Steps { + switch step.Action { + case ActionInsert: + // 單純插入,不用回滾額外做事,插入的資料已經放在 New Data 裡面了 + if err := step.Exec[0].ExecRelease(); err != nil { + return fmt.Errorf("failed to insert: %w", err) + } + // 標示為以執行,如果有錯誤要回復,指座椅執行的就好 + tx.Steps[i].IsExec = true + case ActionUpdate: + // 要先 get 之後再 Update + // 單純插入,不用回滾額外做事,插入的資料已經放在 New Data 裡面了 + if err := step.Exec[0].GetRelease(step.OldData); err != nil { + return fmt.Errorf("failed to get: %w", err) + } + + if err := step.Exec[1].ExecRelease(); err != nil { + return fmt.Errorf("failed to update: %w", err) + } + + // 標示為以執行,如果有錯誤要回復,指座椅執行的就好 + tx.Steps[i].IsExec = true + case ActionDelete: + // 要先 get 之後再 Update + // 單純插入,不用回滾額外做事,插入的資料已經放在 New Data 裡面了 + if err := step.Exec[0].GetRelease(step.OldData); err != nil { + return fmt.Errorf("failed to get: %w", err) + } + if err := step.Exec[1].ExecRelease(); err != nil { + return fmt.Errorf("failed to delete: %w", err) + } + // 標示為以執行,如果有錯誤要回復,指座椅執行的就好 + tx.Steps[i].IsExec = true + default: + return fmt.Errorf("unknown action: %v", step.Action) + } + } + + return nil +} diff --git a/pkg/library/cassandra/ez_transaction_test.go b/pkg/library/cassandra/ez_transaction_test.go new file mode 100644 index 0000000..d407a58 --- /dev/null +++ b/pkg/library/cassandra/ez_transaction_test.go @@ -0,0 +1,231 @@ +package cassandra + +import ( + "context" + "testing" + + "github.com/gocql/gocql" + "github.com/stretchr/testify/assert" +) + +type TE struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Name string `db:"name"` +} + +func (m *TE) TableName() string { + return "test_entity" +} + +func TestNewEZTransactionInsert(t *testing.T) { + ctx := context.Background() + + err := cassandraDBTest.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.test_entity ( + id UUID PRIMARY KEY, + name TEXT +);`) + assert.NoError(t, err) + + // 定義 table-driven 測試案例 + tests := []struct { + name string + doc TE + }{ + { + name: "insert_record_alice", + doc: TE{ + ID: gocql.TimeUUID(), + Name: "Alice", + }, + }, + { + name: "insert_record_bob", + doc: TE{ + ID: gocql.TimeUUID(), + Name: "Bob", + }, + }, + { + name: "insert_record_empty_name", + doc: TE{ + ID: gocql.TimeUUID(), + Name: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 每個子案例都使用新的 transaction + tx := NewEZTransaction(ctx, "my_keyspace", cassandraDBTest) + + // 1. 呼叫 Insert + err := tx.Insert(ctx, &tt.doc) + assert.NoError(t, err, "Insert() 應該不會錯誤") + + // 2. 呼叫 Commit,真正寫入 Cassandra + err = tx.Commit() + assert.NoError(t, err, "Commit() 應該不會錯誤") + + // 3. 從 Cassandra 查回資料,驗證 + var got TE + got.ID = tt.doc.ID + + err = cassandraDBTest.Get(ctx, &got, "my_keyspace") + assert.NoError(t, err) + // 驗證欄位值符合 + assert.Equal(t, tt.doc.ID, got.ID, "ID 應一致") + assert.Equal(t, tt.doc.Name, got.Name, "Name 應一致") + }) + } +} + +func TestNewEZTransactionDelete(t *testing.T) { + ctx := context.Background() + + err := cassandraDBTest.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.test_entity ( + id UUID PRIMARY KEY, + name TEXT +);`) + assert.NoError(t, err) + + // 定義 table-driven 測試案例 + tests := []struct { + name string + doc TE + }{ + { + name: "ok", + doc: TE{ + ID: gocql.TimeUUID(), + Name: "Alice", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 每個子案例都使用新的 transaction + tx := NewEZTransaction(ctx, "my_keyspace", cassandraDBTest) + + // 1. 呼叫 Delete + err := tx.Insert(ctx, &tt.doc) + assert.NoError(t, err, "Insert() 應該不會錯誤") + + // 2. 呼叫 Delete + err = tx.Delete(ctx, &tt.doc) + assert.NoError(t, err, "Delete() 應該不會錯誤") + + // 3. 呼叫 Commit,真正寫入 Cassandra + err = tx.Commit() + assert.NoError(t, err, "Commit() 應該不會錯誤") + // + // 4. 從 Cassandra 查回資料,驗證 + var got TE + got.ID = tt.doc.ID + + err = cassandraDBTest.Get(ctx, &got, "my_keyspace") + assert.Equal(t, err, gocql.ErrNotFound) + }) + } +} + +func TestNewEZTransactionUpdate(t *testing.T) { + ctx := context.Background() + + assert.NoError(t, cassandraDBTest.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.test_entity ( + id UUID PRIMARY KEY, + name TEXT +); +`)) + + // 2. 插入初始資料 + id := gocql.TimeUUID() + before := TE{ID: id, Name: "Before"} + assert.NoError(t, cassandraDBTest.Insert(ctx, &before, "my_keyspace")) + + // 定義多組更新案例 + tests := []struct { + name string + newName string + wantErr bool + }{ + {name: "update_to_Alice", newName: "Alice"}, + {name: "update_to_empty", newName: "", wantErr: true}, + {name: "update_to_Bob", newName: "Bob"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 為每個案例都重置為 Before + // 重新 insert 一次(覆蓋舊值) + assert.NoError(t, cassandraDBTest.Insert(ctx, &before, "my_keyspace")) + + // 3. 建立 transaction 並呼叫 Update + tx := NewEZTransaction(ctx, "my_keyspace", cassandraDBTest) + updateDoc := TE{ID: id, Name: tt.newName} + err := tx.Update(ctx, &updateDoc) + if tt.wantErr { + assert.Error(t, err, "Update() 應該會出錯") + return + } + + assert.NoError(t, err, "Update() 不應出錯") + + // 4. Commit 實際寫入 + err = tx.Commit() + assert.NoError(t, err, "Commit() 不應出錯") + + // 5. 查詢並驗證 + var got TE + got.ID = id + err = cassandraDBTest.Get(ctx, &got, "my_keyspace") + assert.NoError(t, err, "db.Get() 應成功") + + assert.Equal(t, id, got.ID, "ID 應一致") + assert.Equal(t, tt.newName, got.Name, "Name 應被更新為最新值") + }) + } +} + +func Test_Rollback(t *testing.T) { + ctx := context.Background() + + assert.NoError(t, cassandraDBTest.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.test_entity ( + id UUID PRIMARY KEY, + name TEXT +); +`)) + + // 3. 用 Transaction 插入一筆資料,並 Commit + id := gocql.TimeUUID() + doc := TE{ID: id, Name: "Alice"} + tx := NewEZTransaction(ctx, "my_keyspace", cassandraDBTest) + err := tx.Insert(ctx, &doc) + assert.NoError(t, err) + err = tx.Commit() + assert.NoError(t, err) + // 4. Query 確認資料已存在 + var got TE + got.ID = id + err = cassandraDBTest.Get(ctx, &got, "my_keyspace") + assert.NoError(t, err) + assert.Equal(t, got.Name, doc.Name) + + // 5. 呼叫 Rollback,應自動刪除剛剛那筆 + err = tx.Rollback() + assert.NoError(t, err) + + var afterGot TE + afterGot.ID = id + err = cassandraDBTest.Get(ctx, &afterGot, "my_keyspace") + assert.Error(t, err) + + // Output: + // after commit: Alice + // after rollback: not found +} diff --git a/pkg/library/cassandra/go.mod b/pkg/library/cassandra/go.mod new file mode 100644 index 0000000..6699504 --- /dev/null +++ b/pkg/library/cassandra/go.mod @@ -0,0 +1,74 @@ +module gitlab.supermicro.com/infra/infra-core/storage/cassandra + +go 1.24.2 + +require ( + github.com/gocql/gocql v1.7.0 + github.com/scylladb/gocqlx/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 + github.com/testcontainers/testcontainers-go v0.37.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.0.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/scylladb/go-reflectx v1.0.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/time v0.10.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/library/cassandra/go.sum b/pkg/library/cassandra/go.sum new file mode 100644 index 0000000..41c7016 --- /dev/null +++ b/pkg/library/cassandra/go.sum @@ -0,0 +1,207 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= +github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/scylladb/go-reflectx v1.0.1 h1:b917wZM7189pZdlND9PbIJ6NQxfDPfBvUaQ7cjj1iZQ= +github.com/scylladb/go-reflectx v1.0.1/go.mod h1:rWnOfDIRWBGN0miMLIcoPt/Dhi2doCMZqwMCJ3KupFc= +github.com/scylladb/gocqlx/v3 v3.0.1 h1:JBvOUBz62LQ2lbIgJqQbwVMiDftbtrJSi63KVxvRYOQ= +github.com/scylladb/gocqlx/v3 v3.0.1/go.mod h1:EjbSZM0VR2a57ZUxCRQ3v3CSoWIkH1WTMwxeDbFQorY= +github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= +github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +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/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +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/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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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/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/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= +google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +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= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/pkg/library/cassandra/lock.go b/pkg/library/cassandra/lock.go new file mode 100644 index 0000000..7f803dd --- /dev/null +++ b/pkg/library/cassandra/lock.go @@ -0,0 +1,138 @@ +package cassandra + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3/qb" +) + +const ( + defaultTTLSec = 30 + defaultRetry = 3 + baseDelay = 100 * time.Millisecond +) + +// 使用 error.go 中定義的統一錯誤 + +// LockOption 用來設定 TryLock 的 TTL 行為 +type LockOption func(*lockOptions) + +type lockOptions struct { + ttlSeconds int // TTL,單位秒;<=0 代表不 expire +} + +func WithLockTTL(d time.Duration) LockOption { + return func(o *lockOptions) { + o.ttlSeconds = int(d.Seconds()) + } +} + +// WithNoLockExpire 永不自動解鎖 +func WithNoLockExpire() LockOption { + return func(o *lockOptions) { + o.ttlSeconds = 0 + } +} + +// TryLock 嘗試在表上插入一筆唯一鍵(IF NOT EXISTS)作為鎖 +// 預設 30 秒 TTL,可透過 option 調整或取消 TTL +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) TryLock( + ctx context.Context, + document any, + keyspace string, + opts ...LockOption, +) error { + keyspace = getKeyspace(db, keyspace) + // 1. 解析 metadata + metadata, err := GenerateTableMetadata(document, keyspace) + if err != nil { + return err + } + + // 2. 組合 option + options := &lockOptions{ttlSeconds: defaultTTLSec} + for _, opt := range opts { + opt(options) + } + + // 3. 建 TTL 子句 + builder := qb.Insert(metadata.Name). + Unique(). // IF NOT EXISTS + Columns(metadata.Columns...) + + if options.ttlSeconds > 0 { + ttl := time.Duration(options.ttlSeconds) * time.Second + builder = builder.TTL(ttl) + } + stmt, names := builder.ToCql() + + // 4. 執行 CAS + q := db.GetSession().Query(stmt, names).BindStruct(document). + WithContext(ctx). + WithTimestamp(time.Now().UnixNano() / 1e3). + SerialConsistency(gocql.Serial) + + applied, err := q.ExecCASRelease() + if err != nil { + return err + } + + if !applied { + return ErrAcquireLockFailed.WithTable(metadata.Name) + } + return nil +} + +// UnLock 釋放鎖,其實就是 Delete +// keyspace 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) UnLock(ctx context.Context, filter any, keyspace string) error { + keyspace = getKeyspace(db, keyspace) + if filter == nil { + return errors.New("unlock: filter cannot be nil") + } + + metadata, err := GenerateTableMetadata(filter, keyspace) + if err != nil { + return fmt.Errorf("unlock: failed to generate metadata: %w", err) + } + + if len(metadata.Columns) == 0 { + return fmt.Errorf("unlock: missing primary key in struct (table: %s)", metadata.Name) + } + + var lastErr error + + for i := 0; i < defaultRetry; i++ { + builder := qb.Delete(metadata.Name).Existing() + + // 動態添加 WHERE 條件 + for _, key := range metadata.PartKey { + builder = builder.Where(qb.Eq(key)) + } + stmt, names := builder.ToCql() + q := db.GetSession().Query(stmt, names).BindStruct(filter). + WithContext(ctx). + WithTimestamp(time.Now().UnixNano() / 1e3). + SerialConsistency(gocql.Serial) + + applied, err := q.ExecCASRelease() + if err == nil && applied { + return nil + } + + if err != nil { + lastErr = fmt.Errorf("unlock: execution failed (table: %s, attempt: %d/%d): %w", metadata.Name, i+1, defaultRetry, err) + } else if !applied { + lastErr = fmt.Errorf("unlock: operation not applied - row not found or not visible yet (table: %s)", metadata.Name) + } + + time.Sleep(baseDelay * time.Duration(1< 0 { + b.Where(where...) + for k, v := range args { + bind[k] = v + } + } + } +} diff --git a/pkg/library/cassandra/option_test.go b/pkg/library/cassandra/option_test.go new file mode 100644 index 0000000..0240d09 --- /dev/null +++ b/pkg/library/cassandra/option_test.go @@ -0,0 +1,158 @@ +package cassandra + +import ( + "testing" + "time" + + "github.com/gocql/gocql" + "github.com/stretchr/testify/assert" +) + +func TestOptions(t *testing.T) { + tests := []struct { + name string + option Option + check func(conf *cassandraConf) + }{ + { + name: "WithPort", + option: WithPort(1234), + check: func(conf *cassandraConf) { + assert.Equal(t, 1234, conf.Port, "Port 設定錯誤") + }, + }, + { + name: "WithKeyspace", + option: WithKeyspace("my_keyspace"), + check: func(conf *cassandraConf) { + assert.Equal(t, "my_keyspace", conf.Keyspace, "Keyspace 設定錯誤") + }, + }, + { + name: "WithAuth", + option: WithAuth("user", "pass"), + check: func(conf *cassandraConf) { + assert.Equal(t, "user", conf.Username, "Username 設定錯誤") + assert.Equal(t, "pass", conf.Password, "Password 設定錯誤") + assert.True(t, conf.UseAuth, "UseAuth 應該為 true") + }, + }, + { + name: "WithConsistency", + option: WithConsistency(gocql.Quorum), + check: func(conf *cassandraConf) { + assert.Equal(t, gocql.Quorum, conf.Consistency, "Consistency 設定錯誤") + }, + }, + { + name: "WithConnectTimeoutSec", + option: WithConnectTimeoutSec(45), + check: func(conf *cassandraConf) { + assert.Equal(t, 45, conf.ConnectTimeoutSec, "ConnectTimeoutSec 設定錯誤") + }, + }, + { + name: "WithConnectTimeoutSec_default", + option: WithConnectTimeoutSec(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultTimeoutSec, conf.ConnectTimeoutSec, "ConnectTimeoutSec 設定錯誤") + }, + }, + { + name: "WithNumConns", + option: WithNumConns(10), + check: func(conf *cassandraConf) { + assert.Equal(t, 10, conf.NumConns, "NumConns 設定錯誤") + }, + }, + { + name: "WithNumConns_default", + option: WithNumConns(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultNumConns, conf.NumConns, "NumConns 設定錯誤") + }, + }, + { + name: "WithMaxRetries", + option: WithMaxRetries(5), + check: func(conf *cassandraConf) { + assert.Equal(t, 5, conf.MaxRetries, "MaxRetries 設定錯誤") + }, + }, + { + name: "WithMaxRetries_default", + option: WithMaxRetries(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultMaxRetries, conf.MaxRetries, "MaxRetries 設定錯誤") + }, + }, + { + name: "WithRetryMinInterval", + option: WithRetryMinInterval(2 * time.Second), + check: func(conf *cassandraConf) { + assert.Equal(t, 2*time.Second, conf.RetryMinInterval, "RetryMinInterval 設定錯誤") + }, + }, + { + name: "WithRetryMinInterval_default", + option: WithRetryMinInterval(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultRetryMinInterval, conf.RetryMinInterval, "RetryMinInterval 設定錯誤") + }, + }, + { + name: "WithRetryMaxInterval", + option: WithRetryMaxInterval(10 * time.Second), + check: func(conf *cassandraConf) { + assert.Equal(t, 10*time.Second, conf.RetryMaxInterval, "RetryMaxInterval 設定錯誤") + }, + }, + { + name: "WithRetryMaxInterval_default", + option: WithRetryMaxInterval(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultRetryMaxInterval, conf.RetryMaxInterval, "RetryMaxInterval 設定錯誤") + }, + }, + { + name: "WithReconnectInitialInterval", + option: WithReconnectInitialInterval(1 * time.Second), + check: func(conf *cassandraConf) { + assert.Equal(t, 1*time.Second, conf.ReconnectInitialInterval, "ReconnectInitialInterval 設定錯誤") + }, + }, + { + name: "WithReconnectInitialInterval_default", + option: WithReconnectInitialInterval(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultReconnectInitialInterval, conf.ReconnectInitialInterval, "ReconnectInitialInterval 設定錯誤") + }, + }, + { + name: "WithReconnectMaxInterval", + option: WithReconnectMaxInterval(10 * time.Second), + check: func(conf *cassandraConf) { + assert.Equal(t, 10*time.Second, conf.ReconnectMaxInterval, "ReconnectMaxInterval 設定錯誤") + }, + }, + { + name: "WithReconnectMaxInterval_default", + option: WithReconnectMaxInterval(0), + check: func(conf *cassandraConf) { + assert.Equal(t, defaultReconnectMaxInterval, conf.ReconnectMaxInterval, "ReconnectMaxInterval 設定錯誤") + }, + }, + } + + for _, tc := range tests { + tc := tc // 避免 closure 捕捉迴圈變數 + t.Run(tc.name, func(t *testing.T) { + // 為每個測試案例產生一個新的 cassandraConf 實例 + conf := &cassandraConf{} + // 套用 Option + tc.option(conf) + // 執行檢查 + tc.check(conf) + }) + } +} diff --git a/pkg/library/cassandra/query_helper.go b/pkg/library/cassandra/query_helper.go new file mode 100644 index 0000000..8c0e6b0 --- /dev/null +++ b/pkg/library/cassandra/query_helper.go @@ -0,0 +1,30 @@ +package cassandra + +import ( + "context" + "time" + + "github.com/scylladb/gocqlx/v3" +) + +// queryHelper 封裝查詢相關的輔助方法 +type queryHelper struct{} + +// withTimestamp 為查詢添加時間戳 +func (h *queryHelper) withTimestamp(q *gocqlx.Queryx) *gocqlx.Queryx { + return q.WithTimestamp(time.Now().UnixNano() / 1e3) +} + +// withContextAndTimestamp 為查詢添加 context 和時間戳 +func (h *queryHelper) withContextAndTimestamp(ctx context.Context, q *gocqlx.Queryx) *gocqlx.Queryx { + return q.WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3) +} + +// getKeyspace 獲取 keyspace,如果為空則使用預設值 +func getKeyspace(db *CassandraDB, keyspace string) string { + if keyspace == "" { + return db.defaultKeyspace + } + return keyspace +} + diff --git a/pkg/library/cassandra/readme.md b/pkg/library/cassandra/readme.md new file mode 100644 index 0000000..2255639 --- /dev/null +++ b/pkg/library/cassandra/readme.md @@ -0,0 +1,438 @@ +# Cassandra Database Client for Go with Advanced CRUD Operations and Transaction Support + +一套功能完備的 Go 語言 Apache Cassandra 客戶端,支援進階 CRUD 操作、Batch 交易、分散式鎖機制、SAI (Storage-Attached Indexing) 索引與 Fluent API 鏈式查詢介面,讓你用最簡潔的程式碼玩轉 Cassandra! + +## 特色 + +* Go struct 自動生成 Table Metadata +* 批次操作與原子性交易支援(含 rollback) +* 內建分散式鎖 (基於唯一索引) +* 支援 SAI 二級索引 +* 類 GORM 流暢式(Fluent API)查詢體驗 +* 單筆/多筆操作自動處理 +* 完善的連線管理與組態選項 + +## 專案結構 + +``` +. +├── batch.go # Batch 批次操作/交易 +├── client.go # Cassandra 連線管理主體 +├── crud.go # 基本 CRUD 操作 +├── ez_transaction.go # 支援 rollback 的交易系統 +├── lock.go # 分散式鎖實作 +├── metadata.go # 由 struct 產生 Table metadata +├── option.go # 組態與查詢選項 +├── table.go # Table 操作、查詢組合 +├── utils.go # 工具函式 +└── tests/ # 全面測試 +``` + +## 安裝方式 + +```bash +go get gitlab.supermicro.com/infra/infra-core/storage/cassandra +``` + +## 快速開始 + +### 1. 初始化 Client + +```go +import "gitlab.supermicro.com/infra/infra-core/storage/cassandra" + +// 基本初始化(使用預設 keyspace) +client, err := cassandra.NewCassandraDB( + []string{"localhost"}, + cassandra.WithPort(9042), + cassandra.WithKeyspace("my_keyspace"), + cassandra.WithAuth("username", "password"), // 可選 +) +if err != nil { + log.Fatal(err) +} +defer client.Close() + +// 使用預設 keyspace 時,後續操作可以省略 keyspace 參數 +// 如果傳入空字串 "",會自動使用初始化時設定的預設 keyspace +``` + +### 2. 定義資料模型 + +```go +type User struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Name string `db:"name" clustering_key:"true" sai:"true"` + Email string `db:"email"` + CreatedAt time.Time `db:"created_at"` +} + +func (u *User) TableName() string { + return "users" +} +``` + +### 3. 基本 CRUD 操作 + +```go +// 新增(keyspace 為空時使用預設 keyspace) +user := &User{ + ID: gocql.TimeUUID(), + Name: "John Doe", + Email: "john@example.com", + CreatedAt: time.Now(), +} +err = client.Insert(ctx, user, "") // 使用預設 keyspace +// 或明確指定 keyspace +err = client.Insert(ctx, user, "my_keyspace") + +// 查詢 +result := &User{ID: user.ID} +err = client.Get(ctx, result, "") +if cassandra.IsNotFound(err) { + // 處理記錄不存在的情況 + log.Println("User not found") +} + +// 更新(只更新非零值欄位) +result.Email = "newemail@example.com" +err = client.Update(ctx, result, "") + +// 更新所有欄位(包括零值) +result.Email = "" +err = client.UpdateAll(ctx, result, "") + +// 選擇性更新(可控制是否包含零值) +err = client.UpdateSelective(ctx, result, "", false) // false = 排除零值 + +// 刪除 +err = client.Delete(ctx, result, "") +``` + +### 4. 進階:Batch 與補償式交易操作 + +```go +// Batch 操作(原子性批次操作) +// Batch 是 Cassandra 原生的批次操作,保證原子性 +batch := client.NewBatch(ctx, "") // 使用預設 keyspace +batch.Insert(user1) +batch.Insert(user2) +batch.Update(user3) +err := batch.Commit() + +// 補償式交易(Compensating Transaction) +// 注意:這不是真正的 ACID 交易,而是基於補償操作的模式 +// 適用於最終一致性場景,可以確保「要嘛全成功,要嘛全失敗」 +tx := cassandra.NewCompensatingTransaction(ctx, "", client) +// 或使用向後相容的別名 +// tx := cassandra.NewEZTransaction(ctx, "", client) + +tx.Insert(user1) +tx.Update(user2) +if err := tx.Commit(); err != nil { + // 如果 Commit 失敗,執行 Rollback 進行補償操作 + if rollbackErr := tx.Rollback(); rollbackErr != nil { + log.Printf("Rollback failed: %v", rollbackErr) + } + return err +} +``` + +**Batch vs CompensatingTransaction 的區別:** + +- **Batch**: Cassandra 原生的原子性批次操作,所有操作要嘛全部成功,要嘛全部失敗。但無法跨表操作,且不支援條件操作。 +- **CompensatingTransaction**: 基於補償操作的交易模式,可以跨表操作,支援複雜的業務邏輯。透過記錄操作日誌,在失敗時執行補償操作來實現「要嘛全成功,要嘛全失敗」的語義。 + +### 5. 錯誤處理 + +```go +import "gitlab.supermicro.com/infra/infra-core/storage/cassandra" + +// 統一的錯誤處理 +result := &User{ID: userID} +err := client.Get(ctx, result, "") +if err != nil { + // 檢查特定錯誤類型 + if cassandra.IsNotFound(err) { + // 處理記錄不存在 + log.Println("User not found") + } else if cassandra.IsLockFailed(err) { + // 處理獲取鎖失敗 + log.Println("Failed to acquire lock") + } else { + // 處理其他錯誤 + log.Printf("Error: %v", err) + } +} + +// 錯誤類型包含詳細資訊 +var cassandraErr *cassandra.Error +if errors.As(err, &cassandraErr) { + log.Printf("Error Code: %s", cassandraErr.Code) + log.Printf("Error Message: %s", cassandraErr.Message) + log.Printf("Table: %s", cassandraErr.Table) + if cassandraErr.Err != nil { + log.Printf("Underlying Error: %v", cassandraErr.Err) + } +} +``` + +### 6. IN 操作 + +```go +// 使用 QueryBuilder 進行 IN 查詢 +where := []qb.Cmp{qb.In("id")} +args := map[string]any{"id": uuids} + +var result []User +err := client.QueryBuilder( + ctx, + &User{}, + &result, + "", // 使用預設 keyspace + cassandra.WithWhere(where, args), +) +``` +--- + +## Fluent API 鏈式查詢 (GORM 風格) + +支援類 GORM 直覺式鏈式呼叫查詢方式,快速進行 CRUD、條件過濾、排序、分頁、單筆查詢、更新、刪除等操作: + +```go +type TestUser struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Name string `db:"name" sai:"true"` + Age int64 `db:"age"` +} +func (TestUser) TableName() string { return "test_user" } + +// 新增單筆 +user := TestUser{ID: gocql.TimeUUID(), Name: "Alice", Age: 20} +err := db.Model(ctx, TestUser{}, keyspace).InsertOne(user) + +// 批量新增 +users := []TestUser{{...}, {...}} +err := db.Model(ctx, TestUser{}, keyspace).InsertMany(users) + +// 查詢所有 +var got []TestUser +err := db.Model(ctx, TestUser{}, keyspace).GetAll(&got) + +// 查詢某些欄位 +var got []TestUser +err := db.Model(ctx, TestUser{}, ""). // 使用預設 keyspace + Select("name").GetAll(&got) + +// 條件查詢 + 排序 + 分頁 +var result []TestUser +err := db.Model(ctx, TestUser{}, ""). + Where(qb.Eq("name"), map[string]any{"name": "Alice"}). + OrderBy("age", qb.DESC). + Limit(10). + Scan(&result) + + +// IN 操作 +var result []TestUser +err := db.Model(ctx, TestUser{}, ""). + Where(qb.In("name"), map[string]any{"name": []string{"Alice", "Bob"}}). + Scan(&result) + +// 單筆查詢 +var user TestUser +err := db.Model(ctx, TestUser{}, ""). + Where(qb.Eq("id"), map[string]any{"id": userID}). + Take(&user) + +// 更新欄位(必須提供 partition_key 或 sai indexed 欄位在 WHERE 中) +err := db.Model(ctx, TestUser{}, ""). + Where(qb.Eq("id"), map[string]any{"id": userID}). + Set("age", 30). + Update() + +// 刪除(必須提供所有 partition keys) +err := db.Model(ctx, TestUser{}, ""). + Where(qb.Eq("id"), map[string]any{"id": userID}). + Delete() + +// 計數 +count, err := db.Model(ctx, TestUser{}, ""). + Where(qb.Eq("name"), map[string]any{"name": "Alice"}). + Count() +``` + +### 常用查詢語法總結 + +| 操作 | 用法範例 | +| ---- | --------------------------------------------- | +| 條件查詢 | .Where(qb.Eq("欄位"), map\[string]any{"欄位": 值}) | +| 指定欄位 | .Select("id", "name") | +| 排序 | .OrderBy("age", qb.DESC) | +| 分頁 | .Limit(10) | +| 查單筆 | .Take(\&result) | +| 更新欄位 | .Set("age", 25).Update() | +| 刪除 | .Delete() | +| 計數 | .Count() | + +--- + +## 完整 API 參考 + +### 初始化選項 + +```go +// 連線選項 +cassandra.WithPort(port int) +cassandra.WithKeyspace(keyspace string) +cassandra.WithAuth(username, password string) +cassandra.WithConsistency(consistency gocql.Consistency) +cassandra.WithConnectTimeoutSec(timeout int) +cassandra.WithNumConns(numConns int) +cassandra.WithMaxRetries(maxRetries int) +cassandra.WithRetryMinInterval(duration time.Duration) +cassandra.WithRetryMaxInterval(duration time.Duration) +cassandra.WithReconnectInitialInterval(duration time.Duration) +cassandra.WithReconnectMaxInterval(duration time.Duration) +cassandra.WithCQLVersion(version string) +``` + +### 基本 CRUD 方法 + +```go +// 插入 +func (db *CassandraDB) Insert(ctx context.Context, document any, keyspace string) error + +// 查詢(根據 Primary Key) +func (db *CassandraDB) Get(ctx context.Context, dest any, keyspace string) error + +// 更新(只更新非零值欄位) +func (db *CassandraDB) Update(ctx context.Context, document any, keyspace string) error + +// 選擇性更新(可控制是否包含零值) +func (db *CassandraDB) UpdateSelective(ctx context.Context, document any, keyspace string, includeZero bool) error + +// 更新所有欄位(包括零值) +func (db *CassandraDB) UpdateAll(ctx context.Context, document any, keyspace string) error + +// 刪除 +func (db *CassandraDB) Delete(ctx context.Context, filter any, keyspace string) error + +// 查詢所有 +func (db *CassandraDB) GetAll(ctx context.Context, filter any, result any, keyspace string) error + +// 查詢構建器 +func (db *CassandraDB) QueryBuilder(ctx context.Context, tableStruct any, result any, keyspace string, opts ...QueryOption) error +``` + +### Fluent API 方法 + +```go +// 創建查詢構建器 +func (db *CassandraDB) Model(ctx context.Context, document any, keyspace string) *Query + +// Query 方法 +func (q *Query) Where(cmp qb.Cmp, args map[string]any) *Query +func (q *Query) Select(cols ...string) *Query +func (q *Query) OrderBy(column string, order qb.Order) *Query +func (q *Query) Limit(limit uint) *Query +func (q *Query) Set(col string, val any) *Query +func (q *Query) Scan(dest any) error +func (q *Query) Take(dest any) error +func (q *Query) GetAll(dest any) error +func (q *Query) Count() (int64, error) +func (q *Query) InsertOne(data any) error +func (q *Query) InsertMany(documents any) error +func (q *Query) Update() error +func (q *Query) Delete() error +``` + +### Batch 操作 + +```go +// 創建 Batch +func (db *CassandraDB) NewBatch(ctx context.Context, keyspace string) *Batch + +// Batch 方法 +func (tx *Batch) Insert(doc any) error +func (tx *Batch) Delete(doc any) error +func (tx *Batch) Update(doc any) error +func (tx *Batch) Commit() error +``` + +### 補償式交易 + +```go +// 創建補償式交易 +func NewCompensatingTransaction(ctx context.Context, keyspace string, db *CassandraDB) CompensatingTransaction + +// 向後相容的別名(已棄用) +func NewEZTransaction(ctx context.Context, keyspace string, db *CassandraDB) CompensatingTransaction + +// Transaction 方法 +func (tx CompensatingTransaction) Insert(ctx context.Context, document any) error +func (tx CompensatingTransaction) Delete(ctx context.Context, filter any) error +func (tx CompensatingTransaction) Update(ctx context.Context, document any) error +func (tx CompensatingTransaction) Commit() error +func (tx CompensatingTransaction) Rollback() error +``` + +### 分散式鎖 + +```go +// 嘗試獲取鎖 +func (db *CassandraDB) TryLock(ctx context.Context, document any, keyspace string, opts ...LockOption) error + +// 釋放鎖 +func (db *CassandraDB) UnLock(ctx context.Context, filter any, keyspace string) error + +// 鎖選項 +func WithLockTTL(d time.Duration) LockOption +func WithNoLockExpire() LockOption +``` + +### 錯誤處理 + +```go +// 錯誤類型 +type Error struct { + Code string + Message string + Table string + Err error +} + +// 預定義錯誤 +var ErrNotFound +var ErrAcquireLockFailed +var ErrInvalidInput +var ErrNoPartitionKey +var ErrMissingTableName +var ErrNoFieldsToUpdate +var ErrMissingWhereCondition +var ErrMissingPartitionKey + +// 錯誤檢查函數 +func IsNotFound(err error) bool +func IsLockFailed(err error) bool +``` + +## 注意事項 + +1. **Keyspace 處理**: 如果方法參數中的 `keyspace` 為空字串 `""`,會自動使用初始化時設定的預設 keyspace。 + +2. **WHERE 條件限制**: Cassandra 的 WHERE 條件只能使用: + - Partition Key 欄位 + - 有 SAI 索引的欄位 + - Clustering Key 欄位(在 Partition Key 之後) + +3. **Update 方法**: + - `Update()`: 只更新非零值欄位 + - `UpdateAll()`: 更新所有欄位(包括零值) + - `UpdateSelective()`: 可控制是否包含零值 + +4. **補償式交易**: 這不是真正的 ACID 交易,而是基於補償操作的模式,適用於最終一致性場景。 + +5. **錯誤處理**: 建議使用 `IsNotFound()` 和 `IsLockFailed()` 等輔助函數來檢查特定錯誤類型。 + +--- \ No newline at end of file diff --git a/pkg/library/cassandra/table.go b/pkg/library/cassandra/table.go new file mode 100644 index 0000000..19e4d84 --- /dev/null +++ b/pkg/library/cassandra/table.go @@ -0,0 +1,462 @@ +package cassandra + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx/v3/qb" + "github.com/scylladb/gocqlx/v3/table" +) + +func (db *CassandraDB) AutoCreateSAIIndexes(doc any, keyspace string) error { + metadata, err := GenerateTableMetadata(doc, keyspace) + if err != nil { + return err + } + t := reflect.TypeOf(doc) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Tag.Get("sai") == "true" { + col := f.Tag.Get("db") + if col == "" { + col = toSnakeCase(f.Name) + } + stmt := fmt.Sprintf("CREATE INDEX IF NOT EXISTS ON %s (%s) USING 'sai';", metadata.Name, col) + if err := db.GetSession().ExecStmt(stmt); err != nil { + return fmt.Errorf("failed to create SAI index on table %s, column %s: %w", metadata.Name, col, err) + } + } + } + return nil +} + +type Query struct { + db *CassandraDB + ctx context.Context + table string + keyspace string + columns []string + cmps []qb.Cmp + bindMap map[string]any + orders []orderBy + limit uint + document any + sets []setField // 欲更新欄位及其值 + errs []error +} + +type orderBy struct { + Column string + Order qb.Order +} + +type setField struct { + Col string + Val any +} + +// Model 創建一個新的查詢構建器 +// document: 用於推斷表結構的範例物件(必須實現 TableName() 方法) +// keyspace: 如果為空,則使用初始化時設定的預設 keyspace +func (db *CassandraDB) Model(ctx context.Context, document any, keyspace string) *Query { + keyspace = getKeyspace(db, keyspace) + metadata, err := GenerateTableMetadata(document, keyspace) + if err != nil { + // 如果 metadata 生成失敗,創建一個帶錯誤的 Query + return &Query{ + db: db, + ctx: ctx, + keyspace: keyspace, + document: document, + errs: []error{err}, + } + } + + return &Query{ + db: db, + ctx: ctx, + table: metadata.Name, + keyspace: keyspace, + columns: make([]string, 0), + cmps: make([]qb.Cmp, 0), + bindMap: make(map[string]any), + orders: make([]orderBy, 0), + limit: 0, + document: document, // document 用於生成 metadata 和驗證 SAI 欄位 + errs: make([]error, 0), + } +} + +// Where 添加 WHERE 條件 +// 只允許 partition key 或有 sai index 的欄位進行 where 查詢 +// cmp: 查詢條件(如 qb.Eq("id")) +// args: 參數映射(如 map[string]any{"id": uuid}) +func (q *Query) Where(cmp qb.Cmp, args map[string]any) *Query { + // 如果之前有錯誤,直接返回 + if len(q.errs) > 0 { + return q + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + q.errs = append(q.errs, err) + return q + } + + for k := range args { + // 允許 partition_key 或 sai 欄位 + isPartition := contains(metadata.PartKey, k) + isSAI := IsSAIField(q.document, k) + if !isPartition && !isSAI { + q.errs = append(q.errs, NewError( + "INVALID_WHERE_FIELD", + fmt.Sprintf("where condition on field %s requires partition_key or sai index", k), + ).WithTable(q.table)) + } + } + + q.cmps = append(q.cmps, cmp) + for k, v := range args { + q.bindMap[k] = v + } + + return q +} + +func (q *Query) Select(cols ...string) *Query { + q.columns = append(q.columns, cols...) + + return q +} + +func (q *Query) OrderBy(column string, order qb.Order) *Query { + q.orders = append(q.orders, orderBy{Column: column, Order: order}) + + return q +} + +func (q *Query) Limit(limit uint) *Query { + q.limit = limit + + return q +} + +func (q *Query) Set(col string, val any) *Query { + q.sets = append(q.sets, setField{Col: col, Val: val}) + + return q +} + +// Scan 執行查詢並將結果掃描到 dest +// dest 必須是指標類型:*Struct 用於單筆查詢,*[]Struct 用於多筆查詢 +func (q *Query) Scan(dest any) error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return err + } + + builder := qb.Select(q.table) + if len(q.columns) > 0 { + builder = builder.Columns(q.columns...) + } else { + // 如果沒有指定欄位,使用所有欄位 + builder = builder.Columns(metadata.Columns...) + } + if len(q.cmps) > 0 { + builder = builder.Where(q.cmps...) + } + if len(q.orders) > 0 { + for _, o := range q.orders { + builder = builder.OrderBy(o.Column, o.Order) + } + } + if q.limit > 0 { + builder = builder.Limit(q.limit) + } + + stmt, names := builder.ToCql() + query := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(stmt, names)) + if q.bindMap == nil { + q.bindMap = qb.M{} + } + query = query.BindMap(q.bindMap) + + // 型態判斷自動選用單筆/多筆查詢 + destType := reflect.TypeOf(dest) + if destType.Kind() != reflect.Ptr { + return ErrInvalidInput.WithTable(q.table).WithError(fmt.Errorf("destination must be a pointer, got %T", dest)) + } + elemType := destType.Elem() + switch elemType.Kind() { + case reflect.Slice: + return query.SelectRelease(dest) + case reflect.Struct: + err := query.GetRelease(dest) + if err == gocql.ErrNotFound { + return ErrNotFound.WithTable(q.table) + } + return err + default: + return ErrInvalidInput.WithTable(q.table).WithError(fmt.Errorf("destination must be pointer to struct or slice, got %T", dest)) + } +} + +func (q *Query) Take(dest any) error { + q.limit = 1 + + return q.Scan(dest) +} + +// Delete 執行刪除操作 +// 要求:必須提供所有 partition keys 在 WHERE 條件中 +func (q *Query) Delete() error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return err + } + + // 檢查是否提供所有 partition keys + missingKeys := make([]string, 0) + for _, pk := range metadata.PartKey { + if _, ok := q.bindMap[pk]; !ok { + missingKeys = append(missingKeys, pk) + } + } + if len(missingKeys) > 0 { + return ErrMissingPartitionKey.WithTable(q.table).WithError( + fmt.Errorf("missing partition keys: %v", missingKeys), + ) + } + if len(q.cmps) == 0 { + return ErrMissingWhereCondition.WithTable(q.table) + } + + // 組 Delete 語句 + builder := qb.Delete(q.table) + builder = builder.Where(q.cmps...) + stmt, names := builder.ToCql() + query := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(stmt, names)) + if q.bindMap == nil { + q.bindMap = qb.M{} + } + query = query.BindMap(q.bindMap) + + return query.ExecRelease() +} + +// Update 執行更新操作 +// 要求:必須提供至少一個 partition_key 或 sai indexed 欄位在 WHERE 條件中,且至少有一個 Set 欄位 +func (q *Query) Update() error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + if q.document == nil { + return ErrInvalidInput.WithTable(q.table).WithError( + fmt.Errorf("update requires document model to check partition keys"), + ) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return err + } + + // 先收集所有可被當作主查詢條件的欄位 + allowed := make(map[string]struct{}) + + // 收集 partition_key + for _, pk := range metadata.PartKey { + allowed[pk] = struct{}{} + } + + // 收集所有 sai 欄位 + for _, f := range reflect.VisibleFields(reflect.TypeOf(q.document)) { + if f.Tag.Get("sai") == "true" { + col := f.Tag.Get("db") + if col == "" { + col = toSnakeCase(f.Name) + } + allowed[col] = struct{}{} + } + } + + // 檢查 bindMap 有沒有 hit 到 + hasCondition := false + for k := range q.bindMap { + if _, ok := allowed[k]; ok { + hasCondition = true + break + } + } + if !hasCondition { + return ErrMissingPartitionKey.WithTable(q.table).WithError( + fmt.Errorf("requires at least one partition_key or sai indexed field in WHERE clause"), + ) + } + + // 至少要有一個 set 欄位 + if len(q.sets) == 0 { + return ErrNoFieldsToUpdate.WithTable(q.table) + } + // 至少一個 where + if len(q.cmps) == 0 { + return ErrMissingWhereCondition.WithTable(q.table) + } + + // 組合 set 欄位 + setCols := make([]string, 0, len(q.sets)) + setVals := make([]any, 0, len(q.sets)) + for _, s := range q.sets { + setCols = append(setCols, s.Col) + setVals = append(setVals, s.Val) + } + + // 組合 CQL + builder := qb.Update(q.table).Set(setCols...) + builder = builder.Where(q.cmps...) + stmt, names := builder.ToCql() + + // setVals 要先,剩下的 where bind 順序依照 names + bindVals := append([]any{}, setVals...) + for _, name := range names[len(setCols):] { + if v, ok := q.bindMap[name]; ok { + bindVals = append(bindVals, v) + } + } + + query := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(stmt, names)) + if len(bindVals) > 0 { + query = query.Bind(bindVals...) + } + return query.ExecRelease() +} + +// InsertOne 插入單筆資料 +func (q *Query) InsertOne(data any) error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return err + } + tbl := table.New(metadata) + qry := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(tbl.Insert())) + + switch reflect.TypeOf(data).Kind() { + case reflect.Map: + qry = qry.BindMap(data.(map[string]any)) + default: + qry = qry.BindStruct(data) + } + return qry.ExecRelease() +} + +func (q *Query) InsertMany(documents any) error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + v := reflect.ValueOf(documents) + if v.Kind() != reflect.Slice { + return fmt.Errorf("insert many: input must be a slice, got %T", documents) + } + if v.Len() == 0 { + return nil + } + + for i := 0; i < v.Len(); i++ { + item := v.Index(i).Interface() + if err := q.InsertOne(item); err != nil { + return fmt.Errorf("insert many: failed at index %d (table: %s): %w", i, q.table, err) + } + } + return nil +} + +// GetAll 查詢所有資料(不帶條件) +func (q *Query) GetAll(dest any) error { + if len(q.errs) > 0 { + return errors.Join(q.errs...) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return err + } + t := table.New(metadata) + + stmt, names := qb.Select(t.Name()).Columns(metadata.Columns...).ToCql() + exec := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(stmt, names)) + + return exec.SelectRelease(dest) +} + +// Count 計算符合條件的記錄數 +func (q *Query) Count() (int64, error) { + if len(q.errs) > 0 { + return 0, errors.Join(q.errs...) + } + + metadata, err := GenerateTableMetadata(q.document, q.keyspace) + if err != nil { + return 0, err + } + + t := table.New(metadata) + builder := qb.Select(t.Name()).Columns("COUNT(*)") + if len(q.cmps) > 0 { + builder = builder.Where(q.cmps...) + } + + stmt, names := builder.ToCql() + query := qh.withContextAndTimestamp(q.ctx, q.db.GetSession().Query(stmt, names)) + if q.bindMap == nil { + q.bindMap = qb.M{} + } + query = query.BindMap(q.bindMap) + + var count int64 + if err := query.GetRelease(&count); err != nil { + if err == gocql.ErrNotFound { + return 0, nil // COUNT 查詢不會返回 ErrNotFound,但為了安全起見 + } + return 0, err + } + return count, nil +} + +func IsSAIField(model any, fieldName string) bool { + t := reflect.TypeOf(model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("sai") + col := f.Tag.Get("db") + if col == "" { + col = toSnakeCase(f.Name) + } + if (col == fieldName || f.Name == fieldName) && tag == "true" { + return true + } + } + return false +} diff --git a/pkg/library/cassandra/table_test.go b/pkg/library/cassandra/table_test.go new file mode 100644 index 0000000..41af004 --- /dev/null +++ b/pkg/library/cassandra/table_test.go @@ -0,0 +1,324 @@ +package cassandra + +import ( + "context" + "testing" + "time" + + "github.com/gocql/gocql" + + "github.com/scylladb/gocqlx/v3/qb" + "github.com/stretchr/testify/assert" +) + +func TestQueryBuilder(t *testing.T) { + ctx := context.Background() + db := &CassandraDB{} // 可以用 mock DB + + type args struct { + cmp qb.Cmp + whereArg map[string]any + selects []string + orderCol string + order qb.Order + limit uint + setCol string + setVal any + } + + tests := []struct { + name string + args args + wantPanic bool + wantColumns []string + wantOrderCol string + wantOrder qb.Order + wantLimit uint + wantSetCol string + wantSetVal any + }{ + { + name: "where by partition key", + args: args{ + cmp: qb.Eq("id"), + whereArg: map[string]any{"id": "abc"}, + selects: []string{"id", "name"}, + orderCol: "id", + order: qb.ASC, + limit: 1, + setCol: "name", + setVal: "Daniel", + }, + wantPanic: false, + wantColumns: []string{"id", "name"}, + wantOrderCol: "id", + wantOrder: qb.ASC, + wantLimit: 1, + wantSetCol: "name", + wantSetVal: "Daniel", + }, + { + name: "where by sai index", + args: args{ + cmp: qb.Eq("name"), + whereArg: map[string]any{"name": "daniel"}, + selects: []string{"id", "name"}, + orderCol: "name", + order: qb.DESC, + limit: 2, + setCol: "name", + setVal: "Jacky", + }, + wantPanic: false, + wantColumns: []string{"id", "name"}, + wantOrderCol: "name", + wantOrder: qb.DESC, + wantLimit: 2, + wantSetCol: "name", + wantSetVal: "Jacky", + }, + { + name: "where by non-partition-non-sai", + args: args{ + cmp: qb.Eq("age"), + whereArg: map[string]any{"age": 18}, + selects: []string{"id", "name"}, + orderCol: "age", + order: qb.ASC, + limit: 3, + setCol: "age", + setVal: 20, + }, + wantPanic: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + q := db.Model(ctx, &MonkeyEntity{}, "my_keyspace"). + Where(tc.args.cmp, tc.args.whereArg). + Select(tc.args.selects...). + OrderBy(tc.args.orderCol, tc.args.order). + Limit(tc.args.limit). + Set(tc.args.setCol, tc.args.setVal) + + if tc.wantPanic { + assert.Error(t, q.Update()) + } else { + assert.Equal(t, tc.wantColumns, q.columns) + if len(q.orders) > 0 { + assert.Equal(t, tc.wantOrderCol, q.orders[0].Column) + assert.Equal(t, tc.wantOrder, q.orders[0].Order) + } + assert.Equal(t, tc.wantLimit, q.limit) + if len(q.sets) > 0 { + assert.Equal(t, tc.wantSetCol, q.sets[0].Col) + assert.Equal(t, tc.wantSetVal, q.sets[0].Val) + } + } + }) + } +} + +func TestQuery_Select(t *testing.T) { + tests := []struct { + name string + selectCalls [][]string + wantColumns []string + }{ + { + name: "select one col", + selectCalls: [][]string{{"id"}}, + wantColumns: []string{"id"}, + }, + { + name: "select multi col in one call", + selectCalls: [][]string{{"id", "name"}}, + wantColumns: []string{"id", "name"}, + }, + { + name: "multiple select calls append columns", + selectCalls: [][]string{{"id"}, {"name"}, {"age"}}, + wantColumns: []string{"id", "name", "age"}, + }, + { + name: "multiple select calls with overlap", + selectCalls: [][]string{{"id"}, {"id", "name"}, {"name", "age"}}, + wantColumns: []string{"id", "id", "name", "name", "age"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + q := &Query{columns: make([]string, 0)} + for _, call := range tc.selectCalls { + q = q.Select(call...) + } + assert.Equal(t, tc.wantColumns, q.columns) + }) + } +} + +func TestQuery_Count(t *testing.T) { + // 準備測試用資料 + ctx := context.Background() + + ks := generateRandomKeySpace(t) + cassandraDBTest.AutoCreateSAIIndexes(&MonkeyEntity{}, ks) + now := time.Now().UTC() + // 批量插入資料 + docs := []MonkeyEntity{ + {ID: gocql.TimeUUID(), Name: "Alice", CreateAt: now, UpdateAt: now}, + {ID: gocql.TimeUUID(), Name: "Bob", CreateAt: now, UpdateAt: now}, + {ID: gocql.TimeUUID(), Name: "Alice", CreateAt: now, UpdateAt: now}, + } + for _, doc := range docs { + assert.NoError(t, cassandraDBTest.Insert(ctx, &doc, ks)) + } + + tests := []struct { + name string + filterName string + wantCount int64 + }{ + {"CountAll", "", 3}, + {"CountAlice", "Alice", 2}, + {"CountBob", "Bob", 1}, + {"CountNobody", "Charlie", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := cassandraDBTest.Model(ctx, &MonkeyEntity{}, ks) + if tt.filterName != "" { + q = q.Where(qb.Eq("name"), qb.M{"name": tt.filterName}) + } + count, err := q.Count() + assert.NoError(t, err) + assert.Equal(t, tt.wantCount, count) + }) + } +} + +type TestUser struct { + ID gocql.UUID `db:"id" partition_key:"true"` + Name string `db:"name" sai:"true"` + Age int64 `db:"age"` +} + +func (TestUser) TableName() string { return "test_user" } + +func TestQueryBasicFlow(t *testing.T) { + // 啟動 Cassandra container + ctx := context.Background() + + keyspace := "my_keyspace" + err := cassandraDBTest.EnsureTable(` +CREATE TABLE IF NOT EXISTS my_keyspace.test_user ( + id UUID, + name TEXT, + age BIGINT, + PRIMARY KEY (id) +);`) + assert.NoError(t, err) + err = cassandraDBTest.AutoCreateSAIIndexes(&TestUser{}, keyspace) + assert.NoError(t, err) + // 測試資料 + u1 := TestUser{ID: gocql.TimeUUID(), Name: "Alice", Age: 20} + u2 := TestUser{ID: gocql.TimeUUID(), Name: "Bob", Age: 22} + u3 := TestUser{ID: gocql.TimeUUID(), Name: "Carol", Age: 23} + + // InsertOne/InsertMany + t.Run("InsertOne", func(t *testing.T) { + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + assert.NoError(t, q.InsertOne(u1)) + }) + + t.Run("InsertMany", func(t *testing.T) { + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + assert.NoError(t, q.InsertMany([]TestUser{u2, u3})) + }) + + // GetAll + t.Run("GetAll", func(t *testing.T) { + var got []TestUser + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + assert.NoError(t, q.GetAll(&got)) + assert.GreaterOrEqual(t, len(got), 3) + }) + + // Count + t.Run("Count All", func(t *testing.T) { + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + count, err := q.Count() + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(3)) + }) + + // Delete + t.Run("Delete Carol", func(t *testing.T) { + q2 := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + q2.Where(qb.Eq("id"), map[string]any{"id": u3.ID}) + assert.NoError(t, q2.Delete()) + // 驗證已刪除 + var user TestUser + err := cassandraDBTest.Model(ctx, TestUser{}, keyspace). + Where(qb.Eq("id"), map[string]any{"id": u3.ID}).Scan(&user) + assert.Error(t, err) + + q3 := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + count, err := q3.Count() + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(2)) + }) + + // Scan + t.Run("Scan Find Alice", func(t *testing.T) { + var user []TestUser + err := cassandraDBTest.Model(ctx, TestUser{}, keyspace). + Where(qb.Eq("name"), map[string]any{"name": "Alice"}).Scan(&user) + + assert.NoError(t, err) + assert.Equal(t, u1.Name, user[0].Name) + }) + // + // Take (僅取一筆) + t.Run("Take Get Bob", func(t *testing.T) { + var user TestUser + q2 := cassandraDBTest.Model(ctx, TestUser{}, keyspace). + Where(qb.Eq("name"), map[string]any{"name": "Bob"}) + assert.NoError(t, q2.Take(&user)) + assert.Equal(t, u2.Name, user.Name) + }) + // Update + t.Run("Update Age of Alice", func(t *testing.T) { + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + assert.NoError(t, q.InsertMany([]TestUser{u1, u2, u3})) + + err = cassandraDBTest.Model(ctx, + TestUser{}, keyspace). + Where(qb.Eq("id"), map[string]any{"id": u1.ID}). + Set("age", 30). + Update() + + assert.NoError(t, err) + // 驗證 + var user TestUser + assert.NoError(t, cassandraDBTest.Model(ctx, TestUser{}, keyspace). + Where(qb.Eq("id"), map[string]any{"id": u1.ID}).Take(&user)) + assert.Equal(t, int64(30), user.Age) + }) + + // In 這個 case 不通過,原因是 sai key 也不一定可以確認 cassandra 分區 + t.Run("In", func(t *testing.T) { + q := cassandraDBTest.Model(ctx, TestUser{}, keyspace) + assert.NoError(t, q.InsertMany([]TestUser{u1, u2, u3})) + + var user []TestUser + err = cassandraDBTest.Model(ctx, + TestUser{}, keyspace). + Where(qb.In("name"), map[string]any{"name": []string{u1.Name, u2.Name}}). + Scan(&user) + assert.Error(t, err) + }) +} diff --git a/pkg/library/cassandra/utils.go b/pkg/library/cassandra/utils.go new file mode 100644 index 0000000..9344011 --- /dev/null +++ b/pkg/library/cassandra/utils.go @@ -0,0 +1,65 @@ +package cassandra + +import ( + "reflect" + "unicode" +) + +// GetCqlTag 取得指定欄位的 cql tag +// model 必須為 struct 指標,fieldPtr 為該 struct 欄位的指標 +func GetCqlTag(model interface{}, fieldPtr interface{}) string { + v := reflect.ValueOf(model) + // 確保 model 為 struct 指標 + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return "" + } + + s := v.Elem() + // 遍歷所有欄位,找出地址與傳入 fieldPtr 相符的欄位 + for i := 0; i < s.NumField(); i++ { + field := s.Type().Field(i) + fieldVal := s.Field(i) + // 如果能取地址且地址與 fieldPtr 相等,則取得 tag + if fieldVal.CanAddr() && fieldVal.Addr().Interface() == fieldPtr { + return field.Tag.Get("db") + } + } + + return "" +} + +// toSnakeCase 將 CamelCase 字串轉換為 snake_case +func toSnakeCase(s string) string { + var result []rune + for i, r := range s { + if unicode.IsUpper(r) { + if i > 0 { + result = append(result, '_') + } + result = append(result, unicode.ToLower(r)) + } else { + result = append(result, r) + } + } + return string(result) +} + +// 判斷欄位是否為零值或 nil +func isZero(v reflect.Value) bool { + switch v.Kind() { + case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice: + return v.IsNil() + default: + return reflect.DeepEqual(v.Interface(), reflect.Zero(v.Type()).Interface()) + } +} + +// 判斷字串是否存在於 slice 中 +func contains(list []string, target string) bool { + for _, item := range list { + if item == target { + return true + } + } + return false +} diff --git a/pkg/library/cassandra/utils_test.go b/pkg/library/cassandra/utils_test.go new file mode 100644 index 0000000..5556da9 --- /dev/null +++ b/pkg/library/cassandra/utils_test.go @@ -0,0 +1,166 @@ +package cassandra + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/gocql/gocql" +) + +func TestGetCqlTag(t *testing.T) { + monkey := &MonkeyEntity{ + // 為了測試用,欄位內容可以不給值 + ID: gocql.TimeUUID(), + Name: "TestMonkey", + UpdateAt: time.Now(), + CreateAt: time.Now(), + } + + tests := []struct { + name string + model interface{} + fieldPtr interface{} + expected string + expectPanic bool + }{ + { + name: "取得 Name 的 cql tag", + model: monkey, + fieldPtr: &monkey.Name, + expected: "name", + }, + { + name: "取得 ID 的 cql tag", + model: monkey, + fieldPtr: &monkey.ID, + expected: "id", + }, + { + name: "取得 UpdateAt 的 cql tag", + model: monkey, + fieldPtr: &monkey.UpdateAt, + expected: "update_at", + }, + { + name: "取得 CreateAt 的 cql tag", + model: monkey, + fieldPtr: &monkey.CreateAt, + expected: "create_at", + }, + { + name: "找不到對應欄位,回傳空字串", + model: monkey, + fieldPtr: new(int), // 傳入與 MonkeyEntity 無關的欄位指標 + expected: "", + }, + { + name: "非指向 struct 的 model,應該 panic", + model: MonkeyEntity{}, // 非指針 + fieldPtr: &monkey.Name, + expected: "", + }, + } + + for _, tt := range tests { + tt := tt // 捕捉迴圈變數 + t.Run(tt.name, func(t *testing.T) { + // 如果預期會 panic,則用 recover 進行驗證 + if tt.expectPanic { + defer func() { + if r := recover(); r == nil { + t.Errorf("預期測試案例 %q 發生 panic,但實際並未 panic", tt.name) + } + }() + _ = GetCqlTag(tt.model, tt.fieldPtr) + } else { + result := GetCqlTag(tt.model, tt.fieldPtr) + if result != tt.expected { + t.Errorf("測試案例 %q: 預期 %q, 但得到 %q", tt.name, tt.expected, result) + } + } + }) + } +} + +// -------------------- 測試函式 -------------------- + +// TestToSnakeCase 測試 toSnakeCase 函式 +func TestToSnakeCase(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"CamelCase", "camel_case"}, + {"snake_case", "snake_case"}, + {"HttpServer", "http_server"}, + {"A", "a"}, + {"Already_Snake", "already__snake"}, // 依照實作,"Already_Snake" 轉換後會產生 double underscore + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + result := toSnakeCase(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestIsZero(t *testing.T) { + type testCase struct { + name string + input any + expected bool + } + + tests := []testCase{ + {"zero int", 0, true}, + {"non-zero int", 42, false}, + {"zero string", "", true}, + {"non-zero string", "hello", false}, + {"zero bool", false, true}, + {"non-zero bool", true, false}, + {"nil slice", []string(nil), true}, + {"empty slice", []string{}, false}, + {"nil pointer", (*int)(nil), true}, + {"non-nil pointer", new(int), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := reflect.ValueOf(tc.input) + actual := isZero(v) + if actual != tc.expected { + t.Errorf("isZero(%v) = %v; want %v", tc.input, actual, tc.expected) + } + }) + } +} + +func TestContains(t *testing.T) { + type testCase struct { + name string + list []string + target string + expected bool + } + + tests := []testCase{ + {"contains first", []string{"a", "b", "c"}, "a", true}, + {"contains middle", []string{"a", "b", "c"}, "b", true}, + {"contains last", []string{"a", "b", "c"}, "c", true}, + {"not contains", []string{"a", "b", "c"}, "d", false}, + {"empty list", []string{}, "a", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := contains(tc.list, tc.target) + if actual != tc.expected { + t.Errorf("contains(%v, %q) = %v; want %v", tc.list, tc.target, actual, tc.expected) + } + }) + } +} diff --git a/pkg/notification/domain/entity/notification_cursor.go b/pkg/notification/domain/entity/notification_cursor.go new file mode 100644 index 0000000..bd8d302 --- /dev/null +++ b/pkg/notification/domain/entity/notification_cursor.go @@ -0,0 +1,18 @@ +package entity + +import ( + "time" + + "github.com/gocql/gocql" +) + +// NotificationCursor tracks the last seen notification for a user. +type NotificationCursor struct { + UID string `db:"user_id" partition_key:"true"` + LastSeenTS gocql.UUID `db:"last_seen_ts"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (uc *NotificationCursor) TableName() string { + return "notification_cursor" +} diff --git a/pkg/notification/domain/entity/notification_event.go b/pkg/notification/domain/entity/notification_event.go new file mode 100644 index 0000000..0519553 --- /dev/null +++ b/pkg/notification/domain/entity/notification_event.go @@ -0,0 +1,26 @@ +package entity + +import ( + "backend/pkg/notification/domain/notification" + "time" + + "github.com/gocql/gocql" +) + +// NotificationEvent represents an event that triggers a notification. +type NotificationEvent struct { + EventID gocql.UUID `db:"event_id" partition_key:"true"` // 事件 ID + EventType string `db:"event_type"` // POST_PUBLISHED / COMMENT_ADDED / MENTIONED ... + ActorUID string `db:"actor_uid"` // 觸發者 UID + ObjectType string `db:"object_type"` // POST / COMMENT / USER ... + ObjectID string `db:"object_id"` // 對應物件 ID(post_id 等) + Title string `db:"title"` // 顯示用標題 + Body string `db:"body"` // 顯示用內容 / 摘要 + Payload string `db:"payload"` // JSON string(額外欄位,例如 {"postId": "..."}) + Priority notification.NotifyPriority `db:"priority"` // 1=critical, 2=high, 3=normal, 4=low + CreatedAt time.Time `db:"created_at"` // 事件時間(方便做 cross table 查詢) +} + +func (ue *NotificationEvent) TableName() string { + return "notification_event" +} diff --git a/pkg/notification/domain/entity/user_notification.go b/pkg/notification/domain/entity/user_notification.go new file mode 100644 index 0000000..c059c4f --- /dev/null +++ b/pkg/notification/domain/entity/user_notification.go @@ -0,0 +1,22 @@ +package entity + +import ( + "backend/pkg/notification/domain/notification" + "time" + + "github.com/gocql/gocql" +) + +// UserNotification represents a notification for a specific user. +type UserNotification struct { + UserID string `db:"user_id" partition_key:"true"` // 收通知的人 + Bucket string `db:"bucket" partition_key:"true"` // 分桶,例如 '2025-11' 或 '2025-11-17' + TS gocql.UUID `db:"ts" clustering_key:"true"` // 通知時間,用 now() 產生,排序用(UTC0) + EventID gocql.UUID `db:"event_id"` // 對應 notification_event.event_id + Status notification.NotifyStatus `db:"status"` // UNREAD / READ / ARCHIVED + ReadAt time.Time `db:"read_at"` // 已讀時間(非必填) +} + +func (un *UserNotification) TableName() string { + return "user_notification" +} diff --git a/pkg/notification/domain/notification/notification_priority.go b/pkg/notification/domain/notification/notification_priority.go new file mode 100644 index 0000000..2931976 --- /dev/null +++ b/pkg/notification/domain/notification/notification_priority.go @@ -0,0 +1,31 @@ +package notification + +type NotifyPriority int8 + +func (n NotifyPriority) ToString() string { + status, ok := priorityMap[n] + if !ok { + return "unknown" + } + + return status +} + +const ( + Critical NotifyPriority = 1 + High NotifyPriority = 2 + Normal NotifyPriority = 3 + Low NotifyPriority = 4 + + CriticalStr = "critical" + HighStr = "high" + NormalStr = "normal" + LowStr = "low" +) + +var priorityMap = map[NotifyPriority]string{ + Critical: CriticalStr, + High: HighStr, + Normal: NormalStr, + Low: LowStr, +} diff --git a/pkg/notification/domain/notification/notification_status.go b/pkg/notification/domain/notification/notification_status.go new file mode 100644 index 0000000..8e132f7 --- /dev/null +++ b/pkg/notification/domain/notification/notification_status.go @@ -0,0 +1,28 @@ +package notification + +type NotifyStatus int8 + +func (n NotifyStatus) ToString() string { + status, ok := statusMap[n] + if !ok { + return "unknown" + } + + return status +} + +const ( + UNREAD NotifyStatus = 1 + READ NotifyStatus = 2 + ARCHIVED NotifyStatus = 3 + + UNREADStr = "UNREAD" + READStr = "READ" + ARCHIVEDStr = "ARCHIVED" +) + +var statusMap = map[NotifyStatus]string{ + UNREAD: UNREADStr, + READ: READStr, + ARCHIVED: ARCHIVEDStr, +} diff --git a/pkg/notification/domain/repository/notification.go b/pkg/notification/domain/repository/notification.go new file mode 100644 index 0000000..5d661f0 --- /dev/null +++ b/pkg/notification/domain/repository/notification.go @@ -0,0 +1,82 @@ +package repository + +import ( + "backend/pkg/notification/domain/entity" + "context" + + "github.com/gocql/gocql" +) + +type NotificationRepository interface { + NotificationEventRepository + UserNotificationRepository + NotificationCursorRepository +} + +// ---- 1. Event ---- +// 專心管「事件本體」,fan-out 前先寫這張。 +// 通常由上游 domain event consumer 呼叫 Create。 + +type QueryNotificationEventParam struct { + ObjectID *string + ObjectType *string + Limit *int +} + +type NotificationEventRepository interface { + // Create 建立一筆新的 NotificationEvent。 + Create(ctx context.Context, e *entity.NotificationEvent) error + // GetByID 依 EventID 取得事件。 + GetByID(ctx context.Context, id string) (*entity.NotificationEvent, error) + // ListByObject 依 object_type + object_id 查詢相關事件(選用,debug / 後台用)。 + ListByObject(ctx context.Context, param QueryNotificationEventParam) ([]*entity.NotificationEvent, error) +} + +// ---- 2. 使用者通知:user_notification ---- +// 管使用者的小鈴鐺 row,fan-out 之後用這個寫入。 + +// ListLatestOptions 查列表用的參數 +type ListLatestOptions struct { + UserID string + Buckets []string // e.g. []string{"202511", "202510"} + Limit int // 建議在 service 層限制最大值,例如 <= 100 +} + +type UserNotificationRepository interface { + // CreateUserNotification 建立單一通知(針對某一個 user)。 + // 由呼叫端決定 bucket 與 TTL 秒數。 + CreateUserNotification(ctx context.Context, n *entity.UserNotification, ttlSeconds int) error + + // BulkCreate 批次建立多筆通知(fan-out worker 使用)。 + // 一般期望要嘛全部成功要嘛全部失敗。 + BulkCreate(ctx context.Context, list []*entity.UserNotification, ttlSeconds int) error + + // ListLatest 取得某 user 最新的通知列表(小鈴鐺拉下來用)。 + ListLatest(ctx context.Context, opt ListLatestOptions) ([]*entity.UserNotification, error) + + // MarkRead 將單一通知設為已讀。 + // 用 (user_id, bucket, ts) 精準定位那一筆資料。 + MarkRead(ctx context.Context, userID, bucket string, ts gocql.UUID) error + + // MarkAllRead 將指定 buckets 範圍內的通知設為已讀。 + // 常見用法:最近幾個 bucket(例如最近 30 天)全部標為已讀。 + // Cassandra 不適合全表掃描,實作時可分批 select 再 update。 + MarkAllRead(ctx context.Context, userID string, buckets []string) error + + // CountUnreadApprox 回傳未讀數(允許是近似值)。 + // 實作方式可以是: + // - 掃少量 buckets 中 status='UNREAD' 的 row,然後在應用端計算 + // - 或讀取外部 counter(Redis / 另一張 counter table) + CountUnreadApprox(ctx context.Context, userID string, buckets []string) (int64, error) +} + +// ---- 3. NotificationCursorRepository ---- +// 管 last_seen 光標,用來減少大量「每一筆更新已讀」的成本。 + +type NotificationCursorRepository interface { + // GetCursor 取得某 user 的光標,如果不存在可以回傳 (nil, nil)。 + GetCursor(ctx context.Context, userID string) (*entity.NotificationCursor, error) + // UpsertCursor 新增或更新光標。 + // 一般在使用者打開通知列表、或捲到最上面時更新。 + UpsertCursor(ctx context.Context, cursor *entity.NotificationCursor) error +} diff --git a/pkg/notification/domain/usecase/notification.go b/pkg/notification/domain/usecase/notification.go new file mode 100644 index 0000000..ef7dca0 --- /dev/null +++ b/pkg/notification/domain/usecase/notification.go @@ -0,0 +1,114 @@ +package usecase + +// Import necessary packages +import ( + "context" +) + +type NotificationUseCase interface { + EventUseCase + UserNotificationUseCase + CursorUseCase +} +type NotificationEvent struct { + EventType string // POST_PUBLISHED / COMMENT_ADDED / MENTIONED ... + ActorUID string // 觸發者 UID + ObjectType string // POST / COMMENT / USER ... + ObjectID string // 對應物件 ID(post_id 等) + Title string // 顯示用標題 + Body string // 顯示用內容 / 摘要 + Payload string // JSON string(額外欄位,例如 {"postId": "..."}) + Priority string // critical, high, normal, low +} + +type NotificationEventResp struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + ActorUID string `json:"actor_uid"` + ObjectType string `json:"object_type"` + ObjectID string `json:"object_id"` + Title string `json:"title"` + Body string `json:"body"` + Payload string `json:"payload"` + Priority string `json:"priority"` + CreatedAt string `json:"created_at"` +} + +type QueryNotificationEventParam struct { + ObjectID *string + ObjectType *string + Limit *int +} + +type EventUseCase interface { + // CreateEvent creates a new notification event. + CreateEvent(ctx context.Context, e *NotificationEvent) error + + // GetEventByID retrieves an event by its ID. + GetEventByID(ctx context.Context, id string) (*NotificationEventResp, error) + + // ListEventsByObject lists events related to a specific object. + ListEventsByObject(ctx context.Context, param QueryNotificationEventParam) ([]*NotificationEventResp, error) +} + +type UserNotification struct { + UserID string `json:"user_id"` // 收通知的人 + EventID string `json:"event_id"` // 對應 notification_event.event_id + TTL int `json:"ttl"` +} + +type ListLatestOptions struct { + UserID string + Buckets []string // e.g. []string{"202511", "202510"} + Limit int // 建議在 service 層限制最大值,例如 <= 100 +} + +type UserNotificationResponse struct { + UserID string `json:"user_id"` // 收通知的人 + Bucket string `json:"bucket"` // 分桶,例如 '2025-11' 或 '2025-11-17' + TS string `json:"ts"` // 通知時間,用 now() 產生,排序用(UTC0) + EventID string `json:"event_id"` // 對應 notification_event.event_id + Status string `json:"status"` // UNREAD / READ / ARCHIVED + ReadAt *string `json:"read_at,omitempty"` // 已讀時間(非必填) +} + +// UserNotificationUseCase handles user-specific notification operations. +type UserNotificationUseCase interface { + // CreateUserNotification creates a notification for a single user. + CreateUserNotification(ctx context.Context, n *UserNotification) error + + // BulkCreateNotifications creates multiple notifications in batch. + BulkCreateNotifications(ctx context.Context, list []*UserNotification) error + + // ListLatestNotifications lists the latest notifications for a user. + ListLatestNotifications(ctx context.Context, opt ListLatestOptions) ([]*UserNotificationResponse, error) + + // MarkAsRead marks a single notification as read. + MarkAsRead(ctx context.Context, userID, bucket string, ts string) error + + // MarkAllAsRead marks all notifications in specified buckets as read. + MarkAllAsRead(ctx context.Context, userID string, buckets []string) error + + // CountUnread approximates the count of unread notifications. + CountUnread(ctx context.Context, userID string, buckets []string) (int64, error) +} + +type NotificationCursor struct { + UID string + LastSeenTS string + UpdatedAt string +} + +type UpdateNotificationCursorParam struct { + UID string + LastSeenTS string +} + +// CursorUseCase handles notification cursor operations for efficient reading. +type CursorUseCase interface { + // GetCursor retrieves the notification cursor for a user. + GetCursor(ctx context.Context, userID string) (*NotificationCursor, error) + + // UpdateCursor updates or inserts the cursor for a user. + UpdateCursor(ctx context.Context, cursor *UpdateNotificationCursorParam) error +} diff --git a/pkg/notification/usecase/notification.go b/pkg/notification/usecase/notification.go new file mode 100644 index 0000000..dcf2950 --- /dev/null +++ b/pkg/notification/usecase/notification.go @@ -0,0 +1,603 @@ +package usecase + +import ( + "backend/pkg/notification/domain/entity" + "backend/pkg/notification/domain/notification" + "backend/pkg/notification/domain/repository" + "backend/pkg/notification/domain/usecase" + "context" + "errors" + "fmt" + "time" + + errs "backend/pkg/library/errors" + + "github.com/gocql/gocql" +) + +// NotificationUseCaseParam 通知服務參數配置 +type NotificationUseCaseParam struct { + Repo repository.NotificationRepository + Logger errs.Logger +} + +// NotificationUseCase 通知服務實現 +type NotificationUseCase struct { + param NotificationUseCaseParam +} + +// MustNotificationUseCase 創建通知服務實例 +func MustNotificationUseCase(param NotificationUseCaseParam) usecase.NotificationUseCase { + return &NotificationUseCase{ + param: param, + } +} + +// ==================== EventUseCase 實現 ==================== + +// CreateEvent 創建新的通知事件 +func (uc *NotificationUseCase) CreateEvent(ctx context.Context, e *usecase.NotificationEvent) error { + // 驗證輸入 + if err := uc.validateNotificationEvent(e); err != nil { + return err + } + + // 轉換 priority + priority, err := uc.parsePriority(e.Priority) + if err != nil { + return errs.InputInvalidRangeError(fmt.Sprintf("invalid priority: %s", e.Priority)).Wrap(err) + } + + // 創建 entity + event := &entity.NotificationEvent{ + EventID: gocql.TimeUUID(), + EventType: e.EventType, + ActorUID: e.ActorUID, + ObjectType: e.ObjectType, + ObjectID: e.ObjectID, + Title: e.Title, + Body: e.Body, + Payload: e.Payload, + Priority: priority, + CreatedAt: time.Now().UTC(), + } + + // 保存到資料庫 + if err := uc.param.Repo.Create(ctx, event); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "event_type", Val: e.EventType}, + {Key: "actor_uid", Val: e.ActorUID}, + {Key: "func", Val: "NotificationRepository.Create"}, + {Key: "error", Val: err.Error()}, + }, + "failed to create notification event", + ).Wrap(err) + } + + return nil +} + +// GetEventByID 根據 ID 獲取事件 +func (uc *NotificationUseCase) GetEventByID(ctx context.Context, id string) (*usecase.NotificationEventResp, error) { + // 驗證 UUID 格式 + if _, err := gocql.ParseUUID(id); err != nil { + return nil, errs.InputInvalidRangeError(fmt.Sprintf("invalid event ID format: %s", id)).Wrap(err) + } + + // 從資料庫獲取 + event, err := uc.param.Repo.GetByID(ctx, id) + if err != nil { + return nil, errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "event_id", Val: id}, + {Key: "func", Val: "NotificationRepository.GetByID"}, + {Key: "error", Val: err.Error()}, + }, + "failed to get notification event by ID", + ).Wrap(err) + } + + // 轉換為響應格式 + return uc.entityToEventResp(event), nil +} + +// ListEventsByObject 根據物件查詢事件列表 +func (uc *NotificationUseCase) ListEventsByObject(ctx context.Context, param usecase.QueryNotificationEventParam) ([]*usecase.NotificationEventResp, error) { + // 驗證參數 + if param.ObjectID == nil || param.ObjectType == nil || param.Limit == nil { + return nil, errs.InputInvalidRangeError("object_id and object_type are required") + } + + // 構建查詢參數 + repoParam := repository.QueryNotificationEventParam{ + ObjectID: param.ObjectID, + ObjectType: param.ObjectType, + Limit: param.Limit, + } + + // 從資料庫查詢 + events, err := uc.param.Repo.ListByObject(ctx, repoParam) + if err != nil { + return nil, errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "object_id", Val: *param.ObjectID}, + {Key: "object_type", Val: *param.ObjectType}, + {Key: "func", Val: "NotificationRepository.ListByObject"}, + {Key: "error", Val: err.Error()}, + }, + "failed to list notification events by object", + ).Wrap(err) + } + + // 轉換為響應格式 + result := make([]*usecase.NotificationEventResp, 0, len(events)) + for _, event := range events { + result = append(result, uc.entityToEvent(event)) + } + + return result, nil +} + +// ==================== UserNotificationUseCase 實現 ==================== + +// CreateUserNotification 為單個用戶創建通知 +func (uc *NotificationUseCase) CreateUserNotification(ctx context.Context, n *usecase.UserNotification) error { + // 驗證輸入 + if err := uc.validateUserNotification(n); err != nil { + return err + } + + // 生成 bucket + bucket := uc.generateBucket(time.Now().UTC()) + + // 解析 EventID + eventID, err := gocql.ParseUUID(n.EventID) + if err != nil { + return errs.InputInvalidRangeError(fmt.Sprintf("invalid event ID format: %s", n.EventID)).Wrap(err) + } + + // 創建 entity + userNotif := &entity.UserNotification{ + UserID: n.UserID, + Bucket: bucket, + TS: gocql.TimeUUID(), + EventID: eventID, + Status: notification.UNREAD, + ReadAt: time.Time{}, + } + + // 計算 TTL(如果未提供,使用默認值) + ttlSeconds := n.TTL + if ttlSeconds == 0 { + ttlSeconds = uc.calculateDefaultTTL() + } + + // 保存到資料庫 + if err := uc.param.Repo.CreateUserNotification(ctx, userNotif, ttlSeconds); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: n.UserID}, + {Key: "event_id", Val: n.EventID}, + {Key: "func", Val: "NotificationRepository.CreateUserNotification"}, + {Key: "error", Val: err.Error()}, + }, + "failed to create user notification", + ).Wrap(err) + } + + return nil +} + +// BulkCreateNotifications 批量創建通知 +func (uc *NotificationUseCase) BulkCreateNotifications(ctx context.Context, list []*usecase.UserNotification) error { + if len(list) == 0 { + return errs.InputInvalidRangeError("notification list cannot be empty") + } + + // 生成 bucket + bucket := uc.generateBucket(time.Now().UTC()) + + // 轉換為 entity 列表 + entities := make([]*entity.UserNotification, 0, len(list)) + for _, n := range list { + // 驗證輸入 + if err := uc.validateUserNotification(n); err != nil { + return err + } + + // 解析 EventID + eventID, err := gocql.ParseUUID(n.EventID) + if err != nil { + return errs.InputInvalidRangeError(fmt.Sprintf("invalid event ID format: %s", n.EventID)).Wrap(err) + } + + // 計算 TTL + ttlSeconds := n.TTL + if ttlSeconds == 0 { + ttlSeconds = uc.calculateDefaultTTL() + } + + e := &entity.UserNotification{ + UserID: n.UserID, + Bucket: bucket, + TS: gocql.TimeUUID(), + EventID: eventID, + Status: notification.UNREAD, + ReadAt: time.Time{}, + } + + entities = append(entities, e) + } + + // 使用第一個通知的 TTL(假設批量通知使用相同的 TTL) + ttlSeconds := list[0].TTL + if ttlSeconds == 0 { + ttlSeconds = uc.calculateDefaultTTL() + } + + // 批量保存 + if err := uc.param.Repo.BulkCreate(ctx, entities, ttlSeconds); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "count", Val: len(list)}, + {Key: "func", Val: "NotificationRepository.BulkCreate"}, + {Key: "error", Val: err.Error()}, + }, + "failed to bulk create user notifications", + ).Wrap(err) + } + + return nil +} + +// ListLatestNotifications 獲取用戶最新的通知列表 +func (uc *NotificationUseCase) ListLatestNotifications(ctx context.Context, opt usecase.ListLatestOptions) ([]*usecase.UserNotificationResponse, error) { + // 驗證參數 + if opt.UserID == "" { + return nil, errs.InputInvalidRangeError("user_id is required") + } + + // 限制 Limit 最大值 + if opt.Limit <= 0 { + opt.Limit = 20 // 默認值 + } + + // 如果未提供 buckets,生成默認的 buckets(最近 3 個月) + if len(opt.Buckets) == 0 { + opt.Buckets = uc.generateDefaultBuckets() + } + + // 構建查詢參數 + repoOpt := repository.ListLatestOptions{ + UserID: opt.UserID, + Buckets: opt.Buckets, + Limit: opt.Limit, + } + + // 從資料庫查詢 + notifications, err := uc.param.Repo.ListLatest(ctx, repoOpt) + if err != nil { + return nil, errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: opt.UserID}, + {Key: "buckets", Val: opt.Buckets}, + {Key: "func", Val: "NotificationRepository.ListLatest"}, + {Key: "error", Val: err.Error()}, + }, + "failed to list latest notifications", + ).Wrap(err) + } + + // 轉換為響應格式 + result := make([]*usecase.UserNotificationResponse, 0, len(notifications)) + for _, n := range notifications { + result = append(result, uc.entityToUserNotificationResp(n)) + } + + return result, nil +} + +// MarkAsRead 標記單個通知為已讀 +func (uc *NotificationUseCase) MarkAsRead(ctx context.Context, userID, bucket string, ts string) error { + // 驗證參數 + if userID == "" || bucket == "" || ts == "" { + return errs.InputInvalidRangeError("user_id, bucket, and ts are required") + } + + // 解析 TimeUUID + timeUUID, err := gocql.ParseUUID(ts) + if err != nil { + return errs.InputInvalidRangeError(fmt.Sprintf("invalid ts format: %s", ts)).Wrap(err) + } + + // 更新資料庫 + if err := uc.param.Repo.MarkRead(ctx, userID, bucket, timeUUID); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: userID}, + {Key: "bucket", Val: bucket}, + {Key: "ts", Val: ts}, + {Key: "func", Val: "NotificationRepository.MarkRead"}, + {Key: "error", Val: err.Error()}, + }, + "failed to mark notification as read", + ).Wrap(err) + } + + return nil +} + +// MarkAllAsRead 標記指定 buckets 範圍內的所有通知為已讀 +func (uc *NotificationUseCase) MarkAllAsRead(ctx context.Context, userID string, buckets []string) error { + // 驗證參數 + if userID == "" { + return errs.InputInvalidRangeError("user_id is required") + } + + // 如果未提供 buckets,使用默認的 buckets + if len(buckets) == 0 { + buckets = uc.generateDefaultBuckets() + } + + // 更新資料庫 + if err := uc.param.Repo.MarkAllRead(ctx, userID, buckets); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: userID}, + {Key: "buckets", Val: buckets}, + {Key: "func", Val: "NotificationRepository.MarkAllRead"}, + {Key: "error", Val: err.Error()}, + }, + "failed to mark all notifications as read", + ).Wrap(err) + } + + return nil +} + +// CountUnread 計算未讀通知數量(近似值) +func (uc *NotificationUseCase) CountUnread(ctx context.Context, userID string, buckets []string) (int64, error) { + // 驗證參數 + if userID == "" { + return 0, errs.InputInvalidRangeError("user_id is required") + } + + // 如果未提供 buckets,使用默認的 buckets + if len(buckets) == 0 { + buckets = uc.generateDefaultBuckets() + } + + // 從資料庫查詢 + count, err := uc.param.Repo.CountUnreadApprox(ctx, userID, buckets) + if err != nil { + return 0, errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: userID}, + {Key: "buckets", Val: buckets}, + {Key: "func", Val: "NotificationRepository.CountUnreadApprox"}, + {Key: "error", Val: err.Error()}, + }, + "failed to count unread notifications", + ).Wrap(err) + } + + return count, nil +} + +// ==================== CursorUseCase 實現 ==================== + +// GetCursor 獲取用戶的通知光標 +func (uc *NotificationUseCase) GetCursor(ctx context.Context, userID string) (*usecase.NotificationCursor, error) { + // 驗證參數 + if userID == "" { + return nil, errs.InputInvalidRangeError("user_id is required") + } + + // 從資料庫查詢 + cursor, err := uc.param.Repo.GetCursor(ctx, userID) + if err != nil { + return nil, errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "user_id", Val: userID}, + {Key: "func", Val: "NotificationRepository.GetCursor"}, + {Key: "error", Val: err.Error()}, + }, + "failed to get notification cursor", + ).Wrap(err) + } + + // 如果不存在,返回 nil + if cursor == nil { + return nil, nil + } + + // 轉換為響應格式 + return uc.entityToCursor(cursor), nil +} + +// UpdateCursor 更新或插入通知光標 +func (uc *NotificationUseCase) UpdateCursor(ctx context.Context, param *usecase.UpdateNotificationCursorParam) error { + // 驗證參數 + if param == nil { + return errs.InputInvalidRangeError("cursor param is required") + } + if param.UID == "" { + return errs.InputInvalidRangeError("uid is required") + } + if param.LastSeenTS == "" { + return errs.InputInvalidRangeError("last_seen_ts is required") + } + + // 解析 TimeUUID + lastSeenTS, err := gocql.ParseUUID(param.LastSeenTS) + if err != nil { + return errs.InputInvalidRangeError(fmt.Sprintf("invalid last_seen_ts format: %s", param.LastSeenTS)).Wrap(err) + } + + // 創建 entity + cursor := &entity.NotificationCursor{ + UID: param.UID, + LastSeenTS: lastSeenTS, + UpdatedAt: time.Now(), + } + + // 更新資料庫 + if err := uc.param.Repo.UpsertCursor(ctx, cursor); err != nil { + return errs.DBErrorErrorL( + uc.param.Logger, + []errs.LogField{ + {Key: "uid", Val: param.UID}, + {Key: "last_seen_ts", Val: param.LastSeenTS}, + {Key: "func", Val: "NotificationRepository.UpsertCursor"}, + {Key: "error", Val: err.Error()}, + }, + "failed to update notification cursor", + ).Wrap(err) + } + + return nil +} + +// ==================== 輔助函數 ==================== + +// validateNotificationEvent 驗證通知事件 +func (uc *NotificationUseCase) validateNotificationEvent(e *usecase.NotificationEvent) error { + if e == nil { + return errs.InputInvalidRangeError("notification event is required") + } + if e.EventType == "" { + return errs.InputInvalidRangeError("event_type is required") + } + if e.ActorUID == "" { + return errs.InputInvalidRangeError("actor_uid is required") + } + if e.ObjectType == "" { + return errs.InputInvalidRangeError("object_type is required") + } + if e.ObjectID == "" { + return errs.InputInvalidRangeError("object_id is required") + } + return nil +} + +// validateUserNotification 驗證用戶通知 +func (uc *NotificationUseCase) validateUserNotification(n *usecase.UserNotification) error { + if n == nil { + return errs.InputInvalidRangeError("user notification is required") + } + if n.UserID == "" { + return errs.InputInvalidRangeError("user_id is required") + } + if n.EventID == "" { + return errs.InputInvalidRangeError("event_id is required") + } + return nil +} + +// parsePriority 解析優先級字符串 +func (uc *NotificationUseCase) parsePriority(priorityStr string) (notification.NotifyPriority, error) { + switch priorityStr { + case "critical": + return notification.Critical, nil + case "high": + return notification.High, nil + case "normal": + return notification.Normal, nil + case "low": + return notification.Low, nil + default: + return notification.Normal, errors.New("invalid priority value") + } +} + +// generateBucket 生成 bucket 字符串(格式:YYYYMM) +func (uc *NotificationUseCase) generateBucket(t time.Time) string { + return t.Format("200601") +} + +// generateDefaultBuckets 生成默認的 buckets(最近 3 個月) +func (uc *NotificationUseCase) generateDefaultBuckets() []string { + now := time.Now() + buckets := make([]string, 0, 3) + for i := 0; i < 3; i++ { + month := now.AddDate(0, -i, 0) + buckets = append(buckets, month.Format("200601")) + } + return buckets +} + +// calculateDefaultTTL 計算默認 TTL(90 天) +func (uc *NotificationUseCase) calculateDefaultTTL() int { + return 90 * 24 * 60 * 60 // 90 天,單位:秒 +} + +// entityToEventResp 將 entity 轉換為 EventResp +func (uc *NotificationUseCase) entityToEventResp(e *entity.NotificationEvent) *usecase.NotificationEventResp { + return &usecase.NotificationEventResp{ + EventID: e.EventID.String(), + EventType: e.EventType, + ActorUID: e.ActorUID, + ObjectType: e.ObjectType, + ObjectID: e.ObjectID, + Title: e.Title, + Body: e.Body, + Payload: e.Payload, + Priority: e.Priority.ToString(), + CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339), + } +} + +// entityToEvent 將 entity 轉換為 Event +func (uc *NotificationUseCase) entityToEvent(e *entity.NotificationEvent) *usecase.NotificationEventResp { + return &usecase.NotificationEventResp{ + EventID: e.EventID.String(), + EventType: e.EventType, + ActorUID: e.ActorUID, + ObjectType: e.ObjectType, + ObjectID: e.ObjectID, + Title: e.Title, + Body: e.Body, + Payload: e.Payload, + Priority: e.Priority.ToString(), + CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339), + } +} + +// entityToUserNotificationResp 將 entity 轉換為 UserNotificationResponse +func (uc *NotificationUseCase) entityToUserNotificationResp(n *entity.UserNotification) *usecase.UserNotificationResponse { + resp := &usecase.UserNotificationResponse{ + UserID: n.UserID, + Bucket: n.Bucket, + TS: n.TS.String(), + EventID: n.EventID.String(), + Status: n.Status.ToString(), + } + + // 如果 ReadAt 不是零值,設置為字符串 + if !n.ReadAt.IsZero() { + readAtStr := n.ReadAt.UTC().Format(time.RFC3339) + resp.ReadAt = &readAtStr + } + + return resp +} + +// entityToCursor 將 entity 轉換為 Cursor +func (uc *NotificationUseCase) entityToCursor(c *entity.NotificationCursor) *usecase.NotificationCursor { + return &usecase.NotificationCursor{ + UID: c.UID, + LastSeenTS: c.LastSeenTS.String(), + UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339), + } +}