Sync-Kern:
- internal/sync/hlc.go: Hybrid Logical Clock (wall_ms<<16|counter)
Tick/Now/After, global mutex, strikt monoton + kausal korrekt
- internal/sync/hlc_test.go: Unit-Tests (monoton, kausal, keine Duplikate)
Store-Schicht:
- internal/store/opstore.go: AppendOps idempotent via UNIQUE(client_id,
client_seq) ON CONFLICT DO NOTHING; LWW-Projektion (list_create/
rename/delete, item_add/update/remove) in derselben Transaktion;
PullOps mit Cursor (seq > since, 500er Pages)
- internal/store/liststore.go: CreateList / GetLists / GetList
- internal/store/itemstore.go: GetItems (nicht-gelöschte Items)
- internal/store/suggeststore.go: Search (pg_trgm + LIKE-fallback, 10)
HTTP-Handler:
- internal/httpapi/lists.go: GET/POST /api/lists, GET /api/lists/{id}
- internal/httpapi/ops.go: POST /api/lists/{id}/ops (Push),
GET /api/lists/{id}/ops (Pull ?since=)
- internal/httpapi/suggest.go: GET /api/suggestions?q=
- internal/httpapi/api.go: alle Routen verdrahtet (RequireAuth)
Deployment:
- deploy/Caddyfile.behind-proxy: auto_https off, trusted_proxies
- deploy/Caddyfile: X-Forwarded-Proto hinzugefügt, Kommentar aktualisiert
- deploy/docker-compose.yml: CADDY_HTTP_PORT + CADDY_HTTPS_PORT
- deploy/.env.example: Caddy-Port-Variablen dokumentiert
go build ./... && go vet ./... && go test ./... ✅
HLC-Tests: monoton, kausal, keine Duplikate ✅
AGENTS.md: Phase C vollständig ✅
120 lines
3.3 KiB
Go
120 lines
3.3 KiB
Go
// Package sync provides synchronisation primitives for the mitbringsl backend.
|
|
// The Hybrid Logical Clock (HLC) combines a physical wall-clock with a logical
|
|
// counter so that every event gets a strictly monotonically increasing int64
|
|
// timestamp that is compatible with client-generated HLC values.
|
|
//
|
|
// Encoding: the top 48 bits hold wall-clock milliseconds since the Unix epoch;
|
|
// the bottom 16 bits hold a counter that disambiguates events within the same
|
|
// millisecond. This lets us store the full HLC value in a single BIGINT column
|
|
// while still being orderable and human-readable.
|
|
package sync
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// counterBits is the number of bits reserved for the intra-ms counter.
|
|
counterBits = 16
|
|
// maxCounter is the maximum value the intra-ms counter can hold.
|
|
maxCounter = (1 << counterBits) - 1
|
|
)
|
|
|
|
// clock is the process-global HLC state. All exported functions are safe for
|
|
// concurrent use via the embedded mutex.
|
|
var clock struct {
|
|
mu sync.Mutex
|
|
last int64
|
|
}
|
|
|
|
// Now returns the current HLC value without advancing the state.
|
|
// Useful for initialisation checks; prefer Tick for actual event timestamps.
|
|
func Now() int64 {
|
|
clock.mu.Lock()
|
|
defer clock.mu.Unlock()
|
|
return tick(clock.last)
|
|
}
|
|
|
|
// Tick advances the global HLC past the given external value (e.g. a
|
|
// client-supplied hlc_ts) and returns the new value. It is safe to call with
|
|
// 0 when there is no external value to incorporate.
|
|
//
|
|
// Guarantees:
|
|
// - result > last (strictly monotonic)
|
|
// - result >= externalHLC (causal ordering preserved)
|
|
func Tick(externalHLC int64) int64 {
|
|
clock.mu.Lock()
|
|
defer clock.mu.Unlock()
|
|
|
|
wall := wallMS()
|
|
prev := clock.last
|
|
|
|
// Determine the maximum physical time we're aware of.
|
|
maxWall := max3(wall, wallOf(prev), wallOf(externalHLC))
|
|
|
|
var counter int64
|
|
switch {
|
|
case maxWall == wallOf(prev) && maxWall == wallOf(externalHLC):
|
|
// Both are in the same ms bucket: take the larger counter + 1.
|
|
c := counterOf(prev)
|
|
if ec := counterOf(externalHLC); ec > c {
|
|
c = ec
|
|
}
|
|
counter = c + 1
|
|
case maxWall == wallOf(prev):
|
|
counter = counterOf(prev) + 1
|
|
case maxWall == wallOf(externalHLC):
|
|
counter = counterOf(externalHLC) + 1
|
|
default:
|
|
// Wall time advanced; reset counter.
|
|
counter = 0
|
|
}
|
|
|
|
if counter > maxCounter {
|
|
// Counter overflow: steal a millisecond from the future.
|
|
maxWall++
|
|
counter = 0
|
|
}
|
|
|
|
next := (maxWall << counterBits) | counter
|
|
clock.last = next
|
|
return next
|
|
}
|
|
|
|
// After reports whether a causally follows b (i.e. a > b as integers).
|
|
func After(a, b int64) bool { return a > b }
|
|
|
|
// wallMS returns the current Unix epoch time in milliseconds.
|
|
func wallMS() int64 { return time.Now().UnixMilli() }
|
|
|
|
// wallOf extracts the wall-clock ms from an HLC value.
|
|
func wallOf(hlc int64) int64 { return hlc >> counterBits }
|
|
|
|
// counterOf extracts the logical counter from an HLC value.
|
|
func counterOf(hlc int64) int64 { return hlc & maxCounter }
|
|
|
|
// tick is the internal (lock-held) Tick implementation against wall time only.
|
|
func tick(last int64) int64 {
|
|
wall := wallMS()
|
|
maxWall := wallOf(last)
|
|
if wall > maxWall {
|
|
return wall << counterBits
|
|
}
|
|
counter := counterOf(last) + 1
|
|
if counter > maxCounter {
|
|
maxWall++
|
|
counter = 0
|
|
}
|
|
return (maxWall << counterBits) | counter
|
|
}
|
|
|
|
func max3(a, b, c int64) int64 {
|
|
if b > a {
|
|
a = b
|
|
}
|
|
if c > a {
|
|
a = c
|
|
}
|
|
return a
|
|
}
|