cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// Package clipboard 实现单条覆写式的剪贴板同步:DB 立即写入(GET 永远反映
|
||||
// 最新真相),SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后
|
||||
// 一次广播出去,避免下游设备被中间值抖动。
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/hub"
|
||||
)
|
||||
|
||||
// originFutureToleranceMs caps how far ahead of server time a client's origin_ts
|
||||
// may be. Beyond it we assume a runaway clock and clamp to server-now, so one
|
||||
// mis-set device can't set an origin_ts no honest device could beat for a while.
|
||||
const originFutureToleranceMs = 5 * 60 * 1000
|
||||
|
||||
const (
|
||||
// ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text
|
||||
// fallback 在客户端完成,后端只接 text/plain。
|
||||
ContentTypeText = "text/plain"
|
||||
|
||||
// EventType 是 SSE 推送给在线设备的事件名(brief §5 风格的 namespace:verb)。
|
||||
EventType = "clipboard:update"
|
||||
)
|
||||
|
||||
// Errors that the HTTP layer needs to distinguish.
|
||||
var (
|
||||
ErrUnsupportedType = errors.New("unsupported content_type")
|
||||
ErrTooLarge = errors.New("content exceeds limit")
|
||||
)
|
||||
|
||||
// Hub is the subset of *hub.Hub that the service needs. Declared as interface
|
||||
// so unit tests can swap in a fake.
|
||||
type Hub interface {
|
||||
Broadcast(userID string, ev hub.Event)
|
||||
}
|
||||
|
||||
// Querier 是 sqlc 生成的 *db.Queries 的子集,便于测试桩替换。
|
||||
type Querier interface {
|
||||
UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error)
|
||||
GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error)
|
||||
ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error)
|
||||
}
|
||||
|
||||
// Service 持有内存里的 pending 表,每个 userID 对应一个最新待广播的 generation。
|
||||
type Service struct {
|
||||
q Querier
|
||||
hub Hub
|
||||
max int
|
||||
window time.Duration
|
||||
ttl time.Duration // 0 = 内容不过期
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]uint64 // userID → 当前最新 generation
|
||||
nextGen uint64
|
||||
}
|
||||
|
||||
// New constructs a Service. window 是稳定窗口(PUT 收到后等待无新写入的时长,
|
||||
// 到期才广播);maxBytes 是单次写入上限(UTF-8 字节);ttl 是云端内容存活时长
|
||||
// (0 = 不过期)——见 Get 的惰性过期与 RunSweeper 的主动清除。
|
||||
func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 65536
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 3 * time.Second
|
||||
}
|
||||
return &Service{
|
||||
q: q,
|
||||
hub: h,
|
||||
max: maxBytes,
|
||||
window: window,
|
||||
ttl: ttl,
|
||||
pending: map[string]uint64{},
|
||||
}
|
||||
}
|
||||
|
||||
// MaxBytes 暴露上限给 HTTP 层做请求体校验。
|
||||
func (s *Service) MaxBytes() int { return s.max }
|
||||
|
||||
// State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心——
|
||||
// 每用户严格单调递增;OriginTs 是冲突收敛的 LWW 键(源设备复制时刻 ms);客户端
|
||||
// 据 Version 判变更、据 OriginTs 决定回写与否。
|
||||
type State struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"`
|
||||
SourceDevice string `json:"source_device"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
OriginTs int64 `json:"origin_ts"`
|
||||
}
|
||||
|
||||
// Get 读取当前持久化的剪贴板状态;err == sql.ErrNoRows 时表示用户尚未写入。
|
||||
// 内容超过 TTL 即视为已过期,返回空状态(与「尚未写入」等价、ETag 0),绝不
|
||||
// 回吐过期内容;实际的 DB 清除交给 RunSweeper。
|
||||
func (s *Service) Get(ctx context.Context, userID string) (*State, error) {
|
||||
row, err := s.q.GetClipboard(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := rowToState(row)
|
||||
if s.expired(st.Content, st.UpdatedAt) {
|
||||
return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。
|
||||
func (s *Service) expired(content string, updatedAt int64) bool {
|
||||
if s.ttl <= 0 || content == "" {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds())
|
||||
}
|
||||
|
||||
// SweepExpired 清除所有超过 TTL 的剪贴板内容(content / source_device 置空),
|
||||
// 返回清除的行数。ttl<=0 时为 no-op。
|
||||
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
|
||||
if s.ttl <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().Unix() - int64(s.ttl.Seconds())
|
||||
return s.q.ClearExpiredClipboards(ctx, cutoff)
|
||||
}
|
||||
|
||||
// SweepInterval 是 sweeper 唤醒周期。
|
||||
const SweepInterval = 30 * time.Second
|
||||
|
||||
// RunSweeper 阻塞直到 ctx 取消,周期性清除过期剪贴板内容。ttl<=0 时
|
||||
// SweepExpired 为 no-op;调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。
|
||||
func RunSweeper(ctx context.Context, svc *Service) {
|
||||
t := time.NewTicker(SweepInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
n, err := svc.SweepExpired(ctx)
|
||||
if err != nil {
|
||||
slog.Error("clipboard sweeper: clear expired failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("clipboard sweeper: cleared expired content", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Put 接收一次写入:校验 → 立即 UPSERT → bump generation → 定时器到期才广播。
|
||||
// 同 userID 在窗口期内的多次 Put 只让最后一次广播。
|
||||
// Put writes a clipboard update tagged with originTs (the source device's copy
|
||||
// time, ms). It is accepted only when originTs strictly exceeds the stored one
|
||||
// (LWW); a delayed / out-of-order older copy is rejected with accepted=false and
|
||||
// the current winning State returned (so the caller can reconcile). Only an
|
||||
// accepted write bumps the version and schedules a broadcast.
|
||||
func (s *Service) Put(
|
||||
ctx context.Context,
|
||||
userID, contentType, content, sourceDevice string,
|
||||
originTs int64,
|
||||
) (st *State, accepted bool, err error) {
|
||||
if contentType != ContentTypeText {
|
||||
return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType)
|
||||
}
|
||||
if len(content) > s.max {
|
||||
return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs {
|
||||
originTs = nowMs // clamp a runaway-future clock to server-now
|
||||
}
|
||||
|
||||
version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{
|
||||
UserID: userID,
|
||||
ContentType: contentType,
|
||||
Content: &content,
|
||||
SourceDevice: &sourceDevice,
|
||||
UpdatedAt: now.Unix(),
|
||||
OriginTs: originTs,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// LWW rejected: a newer origin_ts already won. Return the current winner
|
||||
// (no broadcast) so the caller pulls / reconciles instead of clobbering.
|
||||
cur, gerr := s.Get(ctx, userID)
|
||||
if gerr != nil {
|
||||
return nil, false, gerr
|
||||
}
|
||||
return cur, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("upsert clipboard: %w", err)
|
||||
}
|
||||
|
||||
st = &State{
|
||||
Content: content,
|
||||
ContentType: contentType,
|
||||
SourceDevice: sourceDevice,
|
||||
UpdatedAt: now.Unix(),
|
||||
Version: version,
|
||||
OriginTs: originTs,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.nextGen++
|
||||
gen := s.nextGen
|
||||
s.pending[userID] = gen
|
||||
s.mu.Unlock()
|
||||
|
||||
time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) })
|
||||
return st, true, nil
|
||||
}
|
||||
|
||||
// maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时(即
|
||||
// 期间没有更新写入)才真正广播;否则视为被新写入覆盖,直接丢弃。
|
||||
func (s *Service) maybeCommit(userID string, gen uint64) {
|
||||
s.mu.Lock()
|
||||
cur, ok := s.pending[userID]
|
||||
if !ok || cur != gen {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(s.pending, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
// 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容(中间被
|
||||
// 后续覆盖的可能性已被前面的 generation 检查排除)。
|
||||
row, err := s.q.GetClipboard(context.Background(), userID)
|
||||
if err != nil {
|
||||
slog.Error("clipboard commit: get state failed", "err", err, "user", userID)
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast(userID, hub.Event{
|
||||
Type: EventType,
|
||||
Data: rowToState(row),
|
||||
})
|
||||
}
|
||||
|
||||
func rowToState(row db.ClipboardState) *State {
|
||||
st := &State{
|
||||
ContentType: row.ContentType,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Version: row.Version,
|
||||
OriginTs: row.OriginTs,
|
||||
}
|
||||
if row.Content != nil {
|
||||
st.Content = *row.Content
|
||||
}
|
||||
if row.SourceDevice != nil {
|
||||
st.SourceDevice = *row.SourceDevice
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/hub"
|
||||
)
|
||||
|
||||
// fakeQuerier 在内存里模拟 clipboard_state 表,按 user_id 单条覆写。
|
||||
type fakeQuerier struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]db.ClipboardState
|
||||
gets atomic.Int32
|
||||
puts atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeQuerier() *fakeQuerier {
|
||||
return &fakeQuerier{rows: map[string]db.ClipboardState{}}
|
||||
}
|
||||
|
||||
func (f *fakeQuerier) UpsertClipboard(_ context.Context, arg db.UpsertClipboardParams) (int64, error) {
|
||||
f.puts.Add(1)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
prev, exists := f.rows[arg.UserID]
|
||||
if exists && arg.OriginTs <= prev.OriginTs {
|
||||
return 0, sql.ErrNoRows // conditional WHERE failed → stale, no change (RETURNING no row)
|
||||
}
|
||||
version := prev.Version + 1 // absent → 0 → new version 1; else prev+1
|
||||
f.rows[arg.UserID] = db.ClipboardState{
|
||||
UserID: arg.UserID,
|
||||
ContentType: arg.ContentType,
|
||||
Content: arg.Content,
|
||||
SourceDevice: arg.SourceDevice,
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
Version: version,
|
||||
OriginTs: arg.OriginTs,
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (f *fakeQuerier) GetClipboard(_ context.Context, userID string) (db.ClipboardState, error) {
|
||||
f.gets.Add(1)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
row, ok := f.rows[userID]
|
||||
if !ok {
|
||||
return db.ClipboardState{}, errNoRow
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ClearExpiredClipboards 模拟 SQL:把 content 非空且 updated_at < cutoff 的行
|
||||
// 内容置 nil,返回清除行数。
|
||||
func (f *fakeQuerier) ClearExpiredClipboards(_ context.Context, cutoff int64) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var n int64
|
||||
for k, row := range f.rows {
|
||||
if row.Content != nil && row.UpdatedAt < cutoff {
|
||||
row.Content = nil
|
||||
row.SourceDevice = nil
|
||||
f.rows[k] = row
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var errNoRow = errors.New("no row")
|
||||
|
||||
// fakeHub 计数 Broadcast 调用并把 events 排队让测试断言。
|
||||
type fakeHub struct {
|
||||
mu sync.Mutex
|
||||
events []hub.Event
|
||||
}
|
||||
|
||||
func (h *fakeHub) Broadcast(_ string, ev hub.Event) {
|
||||
h.mu.Lock()
|
||||
h.events = append(h.events, ev)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *fakeHub) snapshot() []hub.Event {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]hub.Event, len(h.events))
|
||||
copy(out, h.events)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPutWritesDBImmediately(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
h := &fakeHub{}
|
||||
s := New(q, h, 1024, 50*time.Millisecond, 0)
|
||||
|
||||
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "hello", "tab-1", 1); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if got := q.puts.Load(); got != 1 {
|
||||
t.Errorf("expected 1 DB upsert, got %d", got)
|
||||
}
|
||||
got, err := s.Get(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Content != "hello" {
|
||||
t.Errorf("content: got %q, want %q", got.Content, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutTrailingPutOnlyBroadcastsLast(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
h := &fakeHub{}
|
||||
s := New(q, h, 1024, 60*time.Millisecond, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
for i, text := range []string{"a", "b", "c"} {
|
||||
if _, _, err := s.Put(ctx, "alice", "text/plain", text, "tab-1", int64(i+1)); err != nil {
|
||||
t.Fatalf("Put %q: %v", text, err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond) // 每次写都在同窗口内
|
||||
}
|
||||
|
||||
// 等待最后一次 Put 之后的窗口结束 + 余量
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
evs := h.snapshot()
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(evs))
|
||||
}
|
||||
st, ok := evs[0].Data.(*State)
|
||||
if !ok {
|
||||
t.Fatalf("event data type: %T", evs[0].Data)
|
||||
}
|
||||
if st.Content != "c" {
|
||||
t.Errorf("broadcast content: got %q, want %q", st.Content, "c")
|
||||
}
|
||||
if evs[0].Type != EventType {
|
||||
t.Errorf("event type: got %q, want %q", evs[0].Type, EventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutDifferentUsersDontInterfere(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
h := &fakeHub{}
|
||||
s := New(q, h, 1024, 40*time.Millisecond, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, err := s.Put(ctx, "alice", "text/plain", "alice-data", "tab-1", 1); err != nil {
|
||||
t.Fatalf("alice: %v", err)
|
||||
}
|
||||
if _, _, err := s.Put(ctx, "bob", "text/plain", "bob-data", "tab-1", 1); err != nil {
|
||||
t.Fatalf("bob: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
evs := h.snapshot()
|
||||
if len(evs) != 2 {
|
||||
t.Fatalf("expected 2 broadcasts (one per user), got %d", len(evs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejectsUnsupportedType(t *testing.T) {
|
||||
s := New(newFakeQuerier(), &fakeHub{}, 1024, 10*time.Millisecond, 0)
|
||||
_, _, err := s.Put(context.Background(), "alice", "text/html", "<b>hi</b>", "tab-1", 1)
|
||||
if !errors.Is(err, ErrUnsupportedType) {
|
||||
t.Errorf("expected ErrUnsupportedType, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejectsTooLarge(t *testing.T) {
|
||||
s := New(newFakeQuerier(), &fakeHub{}, 8, 10*time.Millisecond, 0)
|
||||
_, _, err := s.Put(context.Background(), "alice", "text/plain", "123456789", "tab-1", 1)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Errorf("expected ErrTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutEmptyContentAccepted(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
h := &fakeHub{}
|
||||
s := New(q, h, 1024, 30*time.Millisecond, 0)
|
||||
|
||||
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "", "tab-1", 1); err != nil {
|
||||
t.Errorf("empty content should be accepted (clear semantics): %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
evs := h.snapshot()
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(evs))
|
||||
}
|
||||
st := evs[0].Data.(*State)
|
||||
if st.Content != "" {
|
||||
t.Errorf("expected empty content, got %q", st.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutWindowExtendsOnNewWrite(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
h := &fakeHub{}
|
||||
window := 80 * time.Millisecond
|
||||
s := New(q, h, 1024, window, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
t0 := time.Now()
|
||||
_, _, _ = s.Put(ctx, "alice", "text/plain", "first", "tab-1", 1)
|
||||
time.Sleep(window / 2)
|
||||
_, _, _ = s.Put(ctx, "alice", "text/plain", "second", "tab-1", 2)
|
||||
|
||||
// 第一窗到期点:任何广播都不该出现(被第二个 Put 重置)
|
||||
time.Sleep(window/2 + 10*time.Millisecond)
|
||||
if got := len(h.snapshot()); got != 0 {
|
||||
t.Errorf("at t=%v, expected 0 broadcasts (window not yet elapsed for second), got %d",
|
||||
time.Since(t0), got)
|
||||
}
|
||||
|
||||
// 第二窗到期之后 broadcast 应该出现,且内容是 "second"
|
||||
time.Sleep(window/2 + 30*time.Millisecond)
|
||||
evs := h.snapshot()
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("expected 1 broadcast after second window, got %d", len(evs))
|
||||
}
|
||||
if got := evs[0].Data.(*State).Content; got != "second" {
|
||||
t.Errorf("broadcast content: got %q, want %q", got, "second")
|
||||
}
|
||||
}
|
||||
|
||||
// --- TTL(短存活)---
|
||||
|
||||
func seedRow(q *fakeQuerier, userID, content string, ageSec int64) {
|
||||
c := content
|
||||
src := "tab-1"
|
||||
q.rows[userID] = db.ClipboardState{
|
||||
UserID: userID,
|
||||
ContentType: "text/plain",
|
||||
Content: &c,
|
||||
SourceDevice: &src,
|
||||
UpdatedAt: time.Now().Unix() - ageSec,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpiredReturnsEmpty(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
seedRow(q, "alice", "secret", 100) // 100s 前写入
|
||||
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
|
||||
|
||||
got, err := s.Get(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Content != "" {
|
||||
t.Errorf("过期内容不应回吐,got %q", got.Content)
|
||||
}
|
||||
if got.UpdatedAt != 0 {
|
||||
t.Errorf("过期状态 UpdatedAt 应为 0(等同未写入),got %d", got.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshWithinTTL(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
seedRow(q, "alice", "recent", 5) // 5s 前
|
||||
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
|
||||
|
||||
got, err := s.Get(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Content != "recent" {
|
||||
t.Errorf("TTL 内的新内容应返回,got %q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTTLDisabledNeverExpires(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
seedRow(q, "alice", "old", 100000)
|
||||
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) // ttl=0 → 禁用
|
||||
|
||||
got, _ := s.Get(context.Background(), "alice")
|
||||
if got.Content != "old" {
|
||||
t.Errorf("ttl=0 永不过期,got %q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepExpiredClearsOldOnly(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
seedRow(q, "alice", "old-secret", 100) // 过期
|
||||
seedRow(q, "bob", "recent", 5) // 未过期
|
||||
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
|
||||
|
||||
n, err := s.SweepExpired(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("SweepExpired: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("应清除 1 行(alice),got %d", n)
|
||||
}
|
||||
if c := q.rows["alice"].Content; c != nil {
|
||||
t.Errorf("alice 的内容应被清空(nil),got %v", *c)
|
||||
}
|
||||
if c := q.rows["bob"].Content; c == nil || *c != "recent" {
|
||||
t.Errorf("bob 的新内容应存活,got %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepExpiredNoopWhenDisabled(t *testing.T) {
|
||||
q := newFakeQuerier()
|
||||
seedRow(q, "alice", "x", 100000)
|
||||
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0)
|
||||
|
||||
n, err := s.SweepExpired(context.Background())
|
||||
if err != nil || n != 0 {
|
||||
t.Errorf("ttl=0 时 SweepExpired 应为 noop,got n=%d err=%v", n, err)
|
||||
}
|
||||
if q.rows["alice"].Content == nil {
|
||||
t.Error("ttl=0 不应清除任何内容")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutBumpsVersionMonotonically(t *testing.T) {
|
||||
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
|
||||
st1, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "a", "devA", 1)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("put 1: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if st1.Version != 1 {
|
||||
t.Errorf("first put version: got %d, want 1", st1.Version)
|
||||
}
|
||||
st2, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "b", "devA", 2)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("put 2: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if st2.Version != 2 {
|
||||
t.Errorf("second put version: got %d, want 2", st2.Version)
|
||||
}
|
||||
got, err := svc.Get(context.Background(), "u")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Version != 2 || got.SourceDevice != "devA" {
|
||||
t.Errorf("get: version=%d source=%q, want 2/devA", got.Version, got.SourceDevice)
|
||||
}
|
||||
}
|
||||
|
||||
// LWW: a delayed/out-of-order write with an older-or-equal origin_ts must be
|
||||
// rejected (accepted=false) and leave the current winner intact.
|
||||
func TestPutRejectsStaleOrigin(t *testing.T) {
|
||||
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
st, ok, err := svc.Put(ctx, "u", ContentTypeText, "new", "devA", 200)
|
||||
if err != nil || !ok || st.Version != 1 {
|
||||
t.Fatalf("first put: ok=%v ver=%v err=%v", ok, st.Version, err)
|
||||
}
|
||||
// Older copy arriving late → rejected, current winner returned.
|
||||
cur, ok, err := svc.Put(ctx, "u", ContentTypeText, "stale", "devB", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale put err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("older origin_ts must be rejected")
|
||||
}
|
||||
if cur.Content != "new" || cur.Version != 1 {
|
||||
t.Errorf("rejected put must return current winner; got content=%q ver=%d", cur.Content, cur.Version)
|
||||
}
|
||||
// Equal origin_ts is not strictly greater → rejected (idempotent duplicate).
|
||||
if _, ok, _ := svc.Put(ctx, "u", ContentTypeText, "dup", "devA", 200); ok {
|
||||
t.Error("equal origin_ts must be rejected (idempotent)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user