backend/pkg/library/cassandra/lock.go

139 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<<i)) // 100ms → 200ms → 400ms
}
return fmt.Errorf("unlock: failed after %d retries (table: %s): %w", defaultRetry, metadata.Name, lastErr)
}