change ids
All checks were successful
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s

This commit is contained in:
matst80
2025-10-10 21:50:18 +00:00
parent b0e6c8eca8
commit e48a2590bd
13 changed files with 312 additions and 510 deletions

View File

@@ -9,37 +9,8 @@ import (
messages "git.tornberg.me/go-cart-actor/proto" messages "git.tornberg.me/go-cart-actor/proto"
) )
type CartId [16]byte // Legacy padded [16]byte CartId and its helper methods removed.
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
func (id CartId) String() string {
n := 0
for n < len(id) && id[n] != 0 {
n++
}
return string(id[:n])
}
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
func ToCartId(s string) CartId {
var id CartId
copy(id[:], []byte(s))
return id
}
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
func (id *CartId) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
copy(id[:], []byte(str))
return nil
}
type StockStatus int type StockStatus int

View File

@@ -2,63 +2,43 @@ package main
import ( import (
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/json"
"errors"
"fmt" "fmt"
"strings"
) )
// cart_id.go // cart_id.go
// //
// Compact CartID implementation using 64 bits of cryptographic randomness, // Breaking change:
// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64). // Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
// //
// Motivation: // Rationale:
// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId // - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
// * O(1) hashing (raw uint64) for consistent hashing ring integration // - Provides compact, URL/cookie-friendly identifiers.
// * Extremely low collision probability (birthday bound negligible at scale) // - O(1) hashing and minimal memory footprint.
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
// //
// Backward Compatibility Strategy (Phased): // Public API:
// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId. // type CartId uint64
// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()). // func NewCartId() (CartId, error)
// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header. // func MustNewCartId() CartId
// func ParseCartId(string) (CartId, bool)
// func MustParseCartId(string) CartId
// (CartId).String() string
// (CartId).MarshalJSON() / UnmarshalJSON()
// //
// NOTE: // NOTE:
// The legacy type `CartId [16]byte` is still present elsewhere; helper // All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
// UpgradeLegacyCartId bridges that representation to the new form without // CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
// breaking deterministic mapping for existing carts.
//
// Security / Predictability:
// Uses crypto/rand for generation. If ever required, you can layer an
// HMAC-based derivation for additional secrecy. Current approach already
// provides 64 bits of entropy (brute force infeasible for practical risk).
//
// Future Extensions:
// * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits.
// * Add metrics counters for: generated_new, parsed_existing, legacy_fallback.
// * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed).
//
// Public Surface Summary:
// NewCartID() (CartID, error)
// ParseCartID(string) (CartID, bool)
// FallbackFromString(string) CartID
// UpgradeLegacyCartId(CartId) CartID
// CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error)
//
// Encoding Details:
// encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change
// alphabet order once IDs are in circulation, or previously issued IDs
// will change meaning.
//
// Zero Values:
// The zero value CartID{} has raw=0, txt="0". Treat it as valid but
// usually you will call NewCartID instead.
// //
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type CartId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Precomputed reverse lookup table for decode (255 = invalid). // Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte var base62Rev [256]byte
func init() { func init() {
@@ -70,136 +50,90 @@ func init() {
} }
} }
// CartID is the compact representation of a cart identifier. // String returns the canonical base62 encoding of the 64-bit id.
// raw: 64-bit entropy (also used directly for consistent hashing). func (id CartId) String() string {
// txt: cached base62 textual form. return encodeBase62(uint64(id))
type CartID struct {
raw uint64
txt string
} }
// String returns the canonical base62 encoded ID. // MarshalJSON encodes the cart id as a JSON string.
func (c CartID) String() string { func (id CartId) MarshalJSON() ([]byte, error) {
if c.txt == "" { // lazily encode if constructed manually return json.Marshal(id.String())
c.txt = encodeBase62(c.raw)
}
return c.txt
} }
// Raw returns the 64-bit numeric value (useful for hashing / ring lookup). // UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (c CartID) Raw() uint64 { func (id *CartId) UnmarshalJSON(data []byte) error {
return c.raw var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseCartId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
} }
// IsZero reports whether this CartID is the zero value. // NewCartId generates a new cryptographically random non-zero 64-bit id.
func (c CartID) IsZero() bool { func NewCartId() (CartId, error) {
return c.raw == 0
}
// NewCartID generates a new cryptographically random 64-bit ID.
func NewCartID() (CartID, error) {
var b [8]byte var b [8]byte
if _, err := rand.Read(b[:]); err != nil { if _, err := rand.Read(b[:]); err != nil {
return CartID{}, fmt.Errorf("NewCartID: %w", err) return 0, fmt.Errorf("NewCartId: %w", err)
} }
u := binary.BigEndian.Uint64(b[:]) u := (uint64(b[0]) << 56) |
// Reject zero if you want to avoid ever producing "0" (optional). (uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 { if u == 0 {
// Extremely unlikely; recurse once. // Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewCartID() return NewCartId()
} }
return CartID{raw: u, txt: encodeBase62(u)}, nil return CartId(u), nil
} }
// MustNewCartID panics on failure (suitable for tests / initialization). // MustNewCartId panics if generation fails.
func MustNewCartID() CartID { func MustNewCartId() CartId {
id, err := NewCartID() id, err := NewCartId()
if err != nil { if err != nil {
panic(err) panic(err)
} }
return id return id
} }
// ParseCartID attempts to parse a base62 canonical ID. // ParseCartId parses a base62 string into a CartId.
// Returns (id, true) if fully valid; (zero, false) otherwise. // Returns (0,false) for invalid input.
func ParseCartID(s string) (CartID, bool) { func ParseCartId(s string) (CartId, bool) {
if len(s) == 0 { // Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
return CartID{}, false // Provide a slightly looser upper bound (<=16) only if you anticipate future
} // extensions; here we stay strict.
// Basic length sanity; allow a bit of headroom for future timestamp variant. if len(s) == 0 || len(s) > 11 {
if len(s) > 16 { return 0, false
return CartID{}, false
} }
u, ok := decodeBase62(s) u, ok := decodeBase62(s)
if !ok { if !ok {
return CartID{}, false return 0, false
} }
return CartID{raw: u, txt: s}, true return CartId(u), true
} }
// FallbackFromString produces a deterministic CartID from arbitrary input // MustParseCartId panics on invalid base62 input.
// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map func MustParseCartId(s string) CartId {
// consistently into the new scheme (collision probability still low). id, ok := ParseCartId(s)
func FallbackFromString(s string) CartID { if !ok {
const ( panic(fmt.Sprintf("invalid cart id: %q", s))
offset64 = 1469598103934665603
prime64 = 1099511628211
)
h := uint64(offset64)
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
} }
return CartID{raw: h, txt: encodeBase62(h)} return id
} }
// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID // encodeBase62 converts a uint64 to base62 (shortest form).
// by hashing its trimmed string form. Keeps stable mapping across restarts.
func UpgradeLegacyCartId(old CartId) CartID {
return FallbackFromString(old.String())
}
// CanonicalizeIncoming normalizes user-provided ID strings.
// Behavior:
//
// Empty string -> generate new ID (wasGenerated = true)
// Valid base62 -> parse and return (wasGenerated = false)
// Anything else -> fallback deterministic hash (wasGenerated = false)
//
// Errors only occur if crypto/rand fails during generation.
func CanonicalizeIncoming(s string) (CartID, bool, error) {
if s == "" {
id, err := NewCartID()
return id, true, err
}
if cid, ok := ParseCartID(s); ok {
return cid, false, nil
}
// Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID.
if len(s) == 16 && !isAllBase62(s) {
return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil
}
return FallbackFromString(s), false, nil
}
// isAllBase62 returns true if every byte is in the base62 alphabet.
func isAllBase62(s string) bool {
for i := 0; i < len(s); i++ {
if base62Rev[s[i]] == 0xFF {
return false
}
}
return true
}
// encodeBase62 turns a uint64 into base62 text.
// Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits.
func encodeBase62(u uint64) string { func encodeBase62(u uint64) string {
if u == 0 { if u == 0 {
return "0" return "0"
} }
// 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19.
// 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice.
var buf [11]byte var buf [11]byte
i := len(buf) i := len(buf)
for u > 0 { for u > 0 {
@@ -210,8 +144,7 @@ func encodeBase62(u uint64) string {
return string(buf[i:]) return string(buf[i:])
} }
// decodeBase62 converts a base62 string to uint64. // decodeBase62 converts base62 text to uint64.
// Returns (value, false) if any invalid character appears.
func decodeBase62(s string) (uint64, bool) { func decodeBase62(s string) (uint64, bool) {
var v uint64 var v uint64
for i := 0; i < len(s); i++ { for i := 0; i < len(s); i++ {
@@ -224,104 +157,3 @@ func decodeBase62(s string) (uint64, bool) {
} }
return v, true return v, true
} }
// ErrInvalidCartID can be returned by higher-level validation layers if you decide
// to reject fallback-derived IDs (currently unused here).
var ErrInvalidCartID = errors.New("invalid cart id")
// ---------------------------------------------------------------------------
// Legacy / Compatibility Conversion Helpers
// ---------------------------------------------------------------------------
// CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId
// ([16]byte) by copying the textual form (truncated or zero-padded).
// NOTE: If the base62 string is longer than 16 (should not happen with current
// 64-bit space), it will be truncated.
func CartIDToLegacy(c CartID) CartId {
var id CartId
txt := c.String()
copy(id[:], []byte(txt))
return id
}
// LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its
// trimmed string form (deterministic). This preserves stable mapping without
// depending on original randomness.
func LegacyToCartID(old CartId) CartID {
return UpgradeLegacyCartId(old)
}
// CartIDToKey returns the numeric key representation (uint64) for map indexing.
func CartIDToKey(c CartID) uint64 {
return c.Raw()
}
// LegacyToCartKey converts a legacy CartId to the numeric key via deterministic
// fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.)
func LegacyToCartKey(old CartId) uint64 {
return LegacyToCartID(old).Raw()
}
// ---------------------- Optional Helper Utilities ----------------------------
// CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID.
func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) {
if cid, ok := ParseCartID(s); ok {
return cid, true, nil
}
id, err := NewCartID()
return id, false, err
}
// MustParseCartID panics if s is not a valid base62 ID (useful in tests).
func MustParseCartID(s string) CartID {
if cid, ok := ParseCartID(s); ok {
return cid
}
panic(fmt.Sprintf("invalid CartID: %s", s))
}
// DebugString returns a verbose description (for logging / diagnostics).
func (c CartID) DebugString() string {
return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String())
}
// Equal compares two CartIDs by raw value.
func (c CartID) Equal(other CartID) bool {
return c.raw == other.raw
}
// CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their
// textual form, avoiding the previous behavior where fallback hashing replaced
// the original string with a base62-encoded hash (which broke deterministic
// key derivation across mixed call paths).
//
// Behavior:
// - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true)
// - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true)
// - otherwise -> treat as legacy: raw = hash(s), txt = original s
//
// Returns:
//
// cid - CartID (txt preserved for legacy inputs)
// generatedNew - true only when a brand new ID was created due to empty input
// wasBase62 - true if the input was already canonical base62 (or generated)
// err - only set if crypto/rand fails when generating a new ID
func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) {
if s == "" {
id, e := NewCartID()
if e != nil {
return CartID{}, false, false, e
}
return id, true, true, nil
}
if parsed, ok := ParseCartID(s); ok {
return parsed, false, true, nil
}
// Legacy path: keep original text so downstream legacy-to-key hashing
// (which uses the visible string) yields consistent keys across code paths.
hashCID := FallbackFromString(s)
// Preserve original textual form
hashCID.txt = s
return hashCID, false, false, nil
}

View File

@@ -1,167 +1,155 @@
package main package main
import ( import (
"crypto/rand" "encoding/json"
"encoding/binary" "fmt"
mrand "math/rand"
"testing" "testing"
) )
// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse. // TestNewCartIdUniqueness generates many ids and checks for collisions.
func TestEncodeDecodeBase62RoundTrip(t *testing.T) { func TestNewCartIdUniqueness(t *testing.T) {
mrand.Seed(42) const n = 20000
for i := 0; i < 1000; i++ {
// Random 64-bit value
v := mrand.Uint64()
s := encodeBase62(v)
dec, ok := decodeBase62(s)
if !ok {
t.Fatalf("decodeBase62 failed for %d encoded=%s", v, s)
}
if dec != v {
t.Fatalf("round trip mismatch: have %d got %d (encoded=%s)", v, dec, s)
}
}
// Explicit zero test
if s := encodeBase62(0); s != "0" {
t.Fatalf("expected encodeBase62(0) == \"0\", got %q", s)
}
if v, ok := decodeBase62("0"); !ok || v != 0 {
t.Fatalf("decodeBase62(0) unexpected result v=%d ok=%v", v, ok)
}
}
// TestNewCartIDUniqueness generates a number of IDs and checks for duplicates.
func TestNewCartIDUniqueness(t *testing.T) {
const n = 10000
seen := make(map[string]struct{}, n) seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
id, err := NewCartID() id, err := NewCartId()
if err != nil { if err != nil {
t.Fatalf("NewCartID error: %v", err) t.Fatalf("NewCartId error: %v", err)
} }
s := id.String() s := id.String()
if _, exists := seen[s]; exists { if _, exists := seen[s]; exists {
t.Fatalf("duplicate CartID generated: %s", s) t.Fatalf("duplicate id encountered: %s", s)
} }
seen[s] = struct{}{} seen[s] = struct{}{}
if id.IsZero() { if s == "" {
t.Fatalf("NewCartID returned zero value") t.Fatalf("empty string representation for id %d", id)
}
if len(s) > 11 {
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
}
if id == 0 {
// We force regeneration on zero, extremely unlikely but test guards intent.
t.Fatalf("zero id generated (should be regenerated)")
} }
} }
} }
// TestParseCartIDValidation tests parsing of valid and invalid base62 strings. // TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
func TestParseCartIDValidation(t *testing.T) { func TestParseCartIdRoundTrip(t *testing.T) {
id, err := NewCartID() id := MustNewCartId()
if err != nil { txt := id.String()
t.Fatalf("NewCartID error: %v", err) parsed, ok := ParseCartId(txt)
}
parsed, ok := ParseCartID(id.String())
if !ok { if !ok {
t.Fatalf("ParseCartID failed for valid id %s", id) t.Fatalf("ParseCartId failed for valid text %q", txt)
} }
if parsed.raw != id.raw { if parsed != id {
t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw) t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
}
if _, ok := ParseCartID(""); ok {
t.Fatalf("expected empty string to be invalid")
}
// Invalid char ('-')
if _, ok := ParseCartID("abc-123"); ok {
t.Fatalf("expected invalid chars to fail parse")
}
// Overly long ( >16 )
if _, ok := ParseCartID("1234567890abcdefg"); ok {
t.Fatalf("expected overly long string to fail parse")
} }
} }
// TestFallbackDeterminism ensures fallback hashing is deterministic. // TestParseCartIdInvalid covers invalid inputs.
func TestFallbackDeterminism(t *testing.T) { func TestParseCartIdInvalid(t *testing.T) {
inputs := []string{ invalid := []string{
"legacy-cart-1", "", // empty
"legacy-cart-2", " ", // space
"UPPER_lower_123", "01234567890abc", // >11 chars
"🚀unicode", // unicode bytes (will hash byte sequence) "!!!!", // invalid chars
"-underscore-", // invalid chars
"abc_def", // underscore invalid for base62
"0123456789ABCD", // 14 chars
} }
for _, in := range inputs { for _, s := range invalid {
a := FallbackFromString(in) if _, ok := ParseCartId(s); ok {
b := FallbackFromString(in) t.Fatalf("expected parse failure for %q", s)
if a.raw != b.raw || a.String() != b.String() {
t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b)
} }
} }
// Distinct inputs should almost always differ; sample check
a := FallbackFromString("distinct-A")
b := FallbackFromString("distinct-B")
if a.raw == b.raw {
t.Fatalf("unexpected identical fallback hashes for distinct inputs")
}
} }
// TestCanonicalizeIncomingBehavior covers main control flow branches. // TestMustParseCartIdPanics verifies panic behavior for invalid input.
func TestCanonicalizeIncomingBehavior(t *testing.T) { func TestMustParseCartIdPanics(t *testing.T) {
// Empty => new id defer func() {
id1, generated, err := CanonicalizeIncoming("") if r := recover(); r == nil {
if err != nil || !generated || id1.IsZero() { t.Fatalf("expected panic for invalid MustParseCartId input")
t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err) }
}()
_ = MustParseCartId("not*base62")
} }
// Valid base62 => parse; no generation // TestJSONMarshalUnmarshalCartId verifies JSON round trip.
id2, gen2, err := CanonicalizeIncoming(id1.String()) func TestJSONMarshalUnmarshalCartId(t *testing.T) {
if err != nil || gen2 || id2.raw != id1.raw { id := MustNewCartId()
t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err) data, err := json.Marshal(struct {
Cart CartId `json:"cart"`
}{Cart: id})
if err != nil {
t.Fatalf("marshal error: %v", err)
} }
var out struct {
// Legacy-like random containing invalid chars -> fallback Cart CartId `json:"cart"`
fallbackInput := "legacy\x00\x00padding"
id3, gen3, err := CanonicalizeIncoming(fallbackInput)
if err != nil || gen3 {
t.Fatalf("CanonicalizeIncoming fallback unexpected: id3=%v gen3=%v err=%v", id3, gen3, err)
} }
if err := json.Unmarshal(data, &out); err != nil {
// Deterministic fallback t.Fatalf("unmarshal error: %v", err)
id4, _, _ := CanonicalizeIncoming(fallbackInput) }
if id3.raw != id4.raw { if out.Cart != id {
t.Fatalf("fallback canonicalization not deterministic") t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
} }
} }
// TestUpgradeLegacyCartId ensures mapping of old CartId is stable. // TestBase62LengthBound checks worst-case length (near max uint64).
func TestUpgradeLegacyCartId(t *testing.T) { func TestBase62LengthBound(t *testing.T) {
var legacy CartId // Largest uint64
copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding const maxU64 = ^uint64(0)
up1 := UpgradeLegacyCartId(legacy) s := encodeBase62(maxU64)
up2 := UpgradeLegacyCartId(legacy) if len(s) > 11 {
if up1.raw != up2.raw { t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2)
} }
if up1.String() != up2.String() { dec, ok := decodeBase62(s)
t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2) if !ok || dec != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
} }
} }
// BenchmarkNewCartID gives a rough idea of generation cost. // TestZeroEncoding ensures zero value encodes to "0" and parses back.
func BenchmarkNewCartID(b *testing.B) { func TestZeroEncoding(t *testing.T) {
if s := encodeBase62(0); s != "0" {
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
}
v, ok := decodeBase62("0")
if !ok || v != 0 {
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
}
if _, ok := ParseCartId("0"); !ok {
t.Fatalf("ParseCartId(\"0\") should succeed")
}
}
// TestSequentialParse ensures sequentially generated ids parse correctly.
func TestSequentialParse(t *testing.T) {
for i := 0; i < 1000; i++ {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok || parsed != id {
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
}
}
}
// BenchmarkNewCartId measures generation performance.
func BenchmarkNewCartId(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
if _, err := NewCartID(); err != nil { if _, err := NewCartId(); err != nil {
b.Fatalf("error: %v", err) b.Fatalf("NewCartId error: %v", err)
} }
} }
} }
// BenchmarkEncodeBase62 measures encode speed in isolation. // BenchmarkEncodeBase62 measures encoding performance.
func BenchmarkEncodeBase62(b *testing.B) { func BenchmarkEncodeBase62(b *testing.B) {
// Random sample of values // Precompute sample values
samples := make([]uint64, 1024) samples := make([]uint64, 1024)
for i := range samples { for i := range samples {
var buf [8]byte // Spread bits without crypto randomness overhead
if _, err := rand.Read(buf[:]); err != nil { samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
b.Fatalf("rand: %v", err)
}
samples[i] = binary.BigEndian.Uint64(buf[:])
} }
b.ResetTimer() b.ResetTimer()
var sink string var sink string
@@ -171,30 +159,27 @@ func BenchmarkEncodeBase62(b *testing.B) {
_ = sink _ = sink
} }
// BenchmarkDecodeBase62 measures decode speed. // BenchmarkDecodeBase62 measures decoding performance.
func BenchmarkDecodeBase62(b *testing.B) { func BenchmarkDecodeBase62(b *testing.B) {
// Pre-encode
encoded := make([]string, 1024) encoded := make([]string, 1024)
for i := range encoded { for i := range encoded {
encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i)) encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
} }
b.ResetTimer() b.ResetTimer()
var sum uint64 var sum uint64
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)]) v, ok := decodeBase62(encoded[i%len(encoded)])
if !ok { if !ok {
b.Fatalf("decode failed") b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
} }
sum ^= v sum ^= v
} }
_ = sum _ = sum
} }
// Removed TestLookupNDeterminism (ring-based ownership deprecated) // ExampleCartIdString documents usage of CartId string form.
func ExampleCartId_string() {
// Removed TestRingFingerprintChanges (ring-based ownership deprecated) id := MustNewCartId()
fmt.Println(len(id.String()) <= 11) // outputs true
// Removed TestRingDiffHosts (ring-based ownership deprecated) // Output: true
}
// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned.
// Removed TestRingLookupConsistency (ring-based ownership deprecated)

View File

@@ -7,16 +7,21 @@ import (
"time" "time"
) )
func init() {
gob.Register(map[uint64]int64{})
}
type DiskStorage struct { type DiskStorage struct {
stateFile string stateFile string
lastSave int64 lastSave int64
LastSaves map[CartId]int64 LastSaves map[uint64]int64
} }
func NewDiskStorage(stateFile string) (*DiskStorage, error) { func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{ ret := &DiskStorage{
stateFile: stateFile, stateFile: stateFile,
LastSaves: make(map[CartId]int64), LastSaves: make(map[uint64]int64),
} }
err := ret.loadState() err := ret.loadState()
return ret, err return ret, err
@@ -64,7 +69,7 @@ func (s *DiskStorage) loadState() error {
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error { func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
// With the removal of the legacy message log, we only update the timestamp. // With the removal of the legacy message log, we only update the timestamp.
ts := time.Now().Unix() ts := time.Now().Unix()
s.LastSaves[id] = ts s.LastSaves[uint64(id)] = ts
s.lastSave = ts s.lastSave = ts
return nil return nil
} }

View File

@@ -88,7 +88,7 @@ func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*Cart
// keyFromCartId derives the uint64 key from a legacy CartId deterministically. // keyFromCartId derives the uint64 key from a legacy CartId deterministically.
func keyFromCartId(id CartId) uint64 { func keyFromCartId(id CartId) uint64 {
return LegacyToCartKey(id) return uint64(id)
} }
// storeGrain indexes a grain in both maps. // storeGrain indexes a grain in both maps.

View File

@@ -69,12 +69,12 @@ func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.Negot
// ControlPlane: GetCartIds (locally owned carts only) // ControlPlane: GetCartIds (locally owned carts only)
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) { func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
s.syncedPool.local.mu.RLock() s.syncedPool.local.mu.RLock()
ids := make([]string, 0, len(s.syncedPool.local.grains)) ids := make([]uint64, 0, len(s.syncedPool.local.grains))
for _, g := range s.syncedPool.local.grains { for _, g := range s.syncedPool.local.grains {
if g == nil { if g == nil {
continue continue
} }
ids = append(ids, g.GetId().String()) ids = append(ids, uint64(g.GetId()))
} }
s.syncedPool.local.mu.RUnlock() s.syncedPool.local.mu.RUnlock()
return &messages.CartIdsReply{CartIds: ids}, nil return &messages.CartIdsReply{CartIds: ids}, nil

View File

@@ -31,7 +31,7 @@ export const options = {
{ duration: "1m", target: 0 }, { duration: "1m", target: 0 },
], ],
gracefulStop: "30s", gracefulStop: "30s",
startTime: "5m", startTime: "30s",
}, },
}, },
thresholds: { thresholds: {
@@ -157,7 +157,7 @@ function addRandomItem() {
country: "no", country: "no",
}); });
const start = Date.now(); const start = Date.now();
const res = http.post(baseUrl() + "/", payload, { headers: headers() }); const res = http.post(baseUrl(), payload, { headers: headers() });
const dur = Date.now() - start; const dur = Date.now() - start;
addItemTrend.add(dur, { op: "add" }); addItemTrend.add(dur, { op: "add" });
if (res.status === 200) { if (res.status === 200) {
@@ -182,16 +182,16 @@ function fetchCart() {
// Occasional checkout trigger // Occasional checkout trigger
function maybeCheckout() { function maybeCheckout() {
if (!cartState.cartid) return; if (!cartState.cartid) return;
// Small probability // // Small probability
if (Math.random() < 0.02) { // if (Math.random() < 0.02) {
const start = Date.now(); // const start = Date.now();
const res = http.get(baseUrl() + "/checkout", { headers: headers() }); // const res = http.get(baseUrl() + "/checkout", { headers: headers() });
const dur = Date.now() - start; // const dur = Date.now() - start;
checkoutTrend.add(dur, { op: "checkout" }); // checkoutTrend.add(dur, { op: "checkout" });
updateCookies(res); // updateCookies(res);
if (res.status === 200) checkoutCounter.add(1); // if (res.status === 200) checkoutCounter.add(1);
check(res, { "checkout status ok": (r) => r.status === 200 }); // check(res, { "checkout status ok": (r) => r.status === 200 });
} // }
} }
// ---------------- k6 lifecycle ---------------- // ---------------- k6 lifecycle ----------------

22
main.go
View File

@@ -72,7 +72,7 @@ func (a *App) Save() error {
if grain == nil { if grain == nil {
continue continue
} }
if grain.GetLastChange() > a.storage.LastSaves[id] { if grain.GetLastChange() > a.storage.LastSaves[uint64(id)] {
hasChanges = true hasChanges = true
err := a.storage.Store(id, grain) err := a.storage.Store(id, grain)
if err != nil { if err != nil {
@@ -160,12 +160,12 @@ func GetDiscovery() Discovery {
func main() { func main() {
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name)) storage, err := NewDiskStorage(fmt.Sprintf("data/s_%s.gob", name))
if err != nil { if err != nil {
log.Printf("Error loading state: %v\n", err) log.Printf("Error loading state: %v\n", err)
} }
app := &App{ app := &App{
pool: NewGrainLocalPool(65535, 2*time.Hour, spawn), pool: NewGrainLocalPool(65535, 15*time.Minute, spawn),
storage: storage, storage: storage,
} }
@@ -253,7 +253,13 @@ func main() {
w.Write([]byte("no cart id to checkout is empty")) w.Write([]byte("no cart id to checkout is empty"))
return return
} }
cartId := ToCartId(cookie.Value) parsed, ok := ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid cart id format"))
return
}
cartId := parsed
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId) order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -393,9 +399,13 @@ func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutO
OrderId: order.ID, OrderId: order.ID,
Status: order.Status, Status: order.Status,
} }
_, applyErr := syncedServer.pool.Apply(ToCartId(order.MerchantReference1), mutation) cid, ok := ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.pool.Apply(cid, mutation)
if applyErr == nil { if applyErr == nil {
_ = AppendCartEvent(ToCartId(order.MerchantReference1), mutation) _ = AppendCartEvent(cid, mutation)
} }
return applyErr return applyErr
} }

View File

@@ -156,9 +156,12 @@ func OwnershipProxyMiddleware(pool *SyncedPool) func(http.Handler) http.Handler
func extractCartIdFromRequest(r *http.Request) (CartId, bool) { func extractCartIdFromRequest(r *http.Request) (CartId, bool) {
// Cookie // Cookie
if c, err := r.Cookie("cartid"); err == nil && c.Value != "" { if c, err := r.Cookie("cartid"); err == nil && c.Value != "" {
if cid, _, _, err2 := CanonicalizeOrLegacy(c.Value); err2 == nil { if parsed, ok := ParseCartId(c.Value); ok {
return CartIDToLegacy(cid), true return parsed, true
} }
// Invalid existing cookie value: issue a fresh id (breaking change behavior)
newId := MustNewCartId()
return newId, true
} }
// Path-based: locate "byid" segment // Path-based: locate "byid" segment
parts := splitPath(r.URL.Path) parts := splitPath(r.URL.Path)
@@ -166,8 +169,8 @@ func extractCartIdFromRequest(r *http.Request) (CartId, bool) {
if parts[i] == "byid" && i+1 < len(parts) { if parts[i] == "byid" && i+1 < len(parts) {
raw := parts[i+1] raw := parts[i+1]
if raw != "" { if raw != "" {
if cid, _, _, err := CanonicalizeOrLegacy(raw); err == nil { if parsed, ok := ParseCartId(raw); ok {
return CartIDToLegacy(cid), true return parsed, true
} }
} }
} }

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -267,75 +266,64 @@ func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id C
return json.NewEncoder(w).Encode(klarnaOrder) return json.NewEncoder(w).Encode(klarnaOrder)
} }
func NewCartId() CartId { /*
// Deprecated: legacy random/time based cart id generator. Legacy wrapper NewCartId removed.
// Retained for compatibility; new code should prefer canonical CartID path. Use the unified generator in cart_id.go:
cid, err := NewCartID() id, err := NewCartId()
if err != nil { or panic-on-error helper:
// Fallback to legacy method only if crypto/rand fails id := MustNewCartId()
id := time.Now().UnixNano() + rand.Int63() */
return ToCartId(fmt.Sprintf("%d", id))
}
return CartIDToLegacy(cid)
}
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error { func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error { return func(w http.ResponseWriter, r *http.Request) error {
var legacy CartId var id CartId
cookies := r.CookiesNamed("cartid") cookie, err := r.Cookie("cartid")
if len(cookies) == 0 { if err != nil || cookie.Value == "" {
cid, generated, _, err := CanonicalizeOrLegacy("") id = MustNewCartId()
if err != nil {
return fmt.Errorf("failed to generate cart id: %w", err)
}
legacy = CartIDToLegacy(cid)
if generated {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "cartid", Name: "cartid",
Value: cid.String(), Value: id.String(),
Secure: r.TLS != nil, Secure: r.TLS != nil,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Expires: time.Now().AddDate(0, 0, 14), Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
w.Header().Set("Set-Cart-Id", cid.String()) w.Header().Set("Set-Cart-Id", id.String())
}
} else { } else {
raw := cookies[0].Value parsed, ok := ParseCartId(cookie.Value)
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) if !ok {
if err != nil { id = MustNewCartId()
return fmt.Errorf("failed to canonicalize cart id: %w", err)
}
legacy = CartIDToLegacy(cid)
if generated && wasBase62 {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "cartid", Name: "cartid",
Value: cid.String(), Value: id.String(),
Secure: r.TLS != nil, Secure: r.TLS != nil,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Expires: time.Now().AddDate(0, 0, 14), Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
w.Header().Set("Set-Cart-Id", cid.String()) w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
} }
} }
// Ownership proxy AFTER id extraction (cookie mode)
if ownershipProxyAfterExtraction != nil { if ownershipProxyAfterExtraction != nil {
if handled, err := ownershipProxyAfterExtraction(legacy, w, r); handled || err != nil { if handled, err := ownershipProxyAfterExtraction(id, w, r); handled || err != nil {
return err return err
} }
} }
return fn(w, r, legacy) return fn(w, r, id)
} }
} }
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error { func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
cartId = NewCartId() // Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "cartid", Name: "cartid",
Value: cartId.String(), Value: "",
Path: "/", Path: "/",
Secure: r.TLS != nil, Secure: r.TLS != nil,
HttpOnly: true, HttpOnly: true,
@@ -349,21 +337,28 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error { func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error { return func(w http.ResponseWriter, r *http.Request) error {
raw := r.PathValue("id") raw := r.PathValue("id")
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) // If no id supplied, generate a new one
if err != nil { if raw == "" {
return fmt.Errorf("invalid cart id: %w", err) id := MustNewCartId()
} w.Header().Set("Set-Cart-Id", id.String())
legacy := CartIDToLegacy(cid)
if generated && wasBase62 {
w.Header().Set("Set-Cart-Id", cid.String())
}
// Ownership proxy AFTER path id extraction (explicit id mode)
if ownershipProxyAfterExtraction != nil { if ownershipProxyAfterExtraction != nil {
if handled, err := ownershipProxyAfterExtraction(legacy, w, r); handled || err != nil { if handled, err := ownershipProxyAfterExtraction(id, w, r); handled || err != nil {
return err return err
} }
} }
return fn(w, r, legacy) return fn(w, r, id)
}
// Parse base62 cart id
id, ok := ParseCartId(raw)
if !ok {
return fmt.Errorf("invalid cart id format")
}
if ownershipProxyAfterExtraction != nil {
if handled, err := ownershipProxyAfterExtraction(id, w, r); handled || err != nil {
return err
}
}
return fn(w, r, id)
} }
} }

View File

@@ -204,7 +204,7 @@ func (x *NegotiateReply) GetHosts() []string {
// CartIdsReply returns the list of cart IDs (string form) currently owned locally. // CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type CartIdsReply struct { type CartIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` CartIds []uint64 `protobuf:"varint,1,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -239,7 +239,7 @@ func (*CartIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4} return file_control_plane_proto_rawDescGZIP(), []int{4}
} }
func (x *CartIdsReply) GetCartIds() []string { func (x *CartIdsReply) GetCartIds() []uint64 {
if x != nil { if x != nil {
return x.CartIds return x.CartIds
} }
@@ -349,7 +349,7 @@ func (x *ClosingNotice) GetHost() string {
type OwnershipAnnounce struct { type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
CartIds []string `protobuf:"bytes,2,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` // newly claimed cart ids CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -391,7 +391,7 @@ func (x *OwnershipAnnounce) GetHost() string {
return "" return ""
} }
func (x *OwnershipAnnounce) GetCartIds() []string { func (x *OwnershipAnnounce) GetCartIds() []uint64 {
if x != nil { if x != nil {
return x.CartIds return x.CartIds
} }
@@ -413,7 +413,7 @@ const file_control_plane_proto_rawDesc = "" +
"\x0eNegotiateReply\x12\x14\n" + "\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" + "\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
"\fCartIdsReply\x12\x19\n" + "\fCartIdsReply\x12\x19\n" +
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"F\n" + "\bcart_ids\x18\x01 \x03(\x04R\acartIds\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" + "\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" + "\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
@@ -421,7 +421,7 @@ const file_control_plane_proto_rawDesc = "" +
"\x04host\x18\x01 \x01(\tR\x04host\"B\n" + "\x04host\x18\x01 \x01(\tR\x04host\"B\n" +
"\x11OwnershipAnnounce\x12\x12\n" + "\x11OwnershipAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" +
"\bcart_ids\x18\x02 \x03(\tR\acartIds2\xc0\x02\n" + "\bcart_ids\x18\x02 \x03(\x04R\acartIds2\xc0\x02\n" +
"\fControlPlane\x12,\n" + "\fControlPlane\x12,\n" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" + "\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" + "\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +

View File

@@ -38,7 +38,7 @@ message NegotiateReply {
// CartIdsReply returns the list of cart IDs (string form) currently owned locally. // CartIdsReply returns the list of cart IDs (string form) currently owned locally.
message CartIdsReply { message CartIdsReply {
repeated string cart_ids = 1; repeated uint64 cart_ids = 1;
} }
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed). // OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
@@ -56,7 +56,7 @@ message ClosingNotice {
// First claim wins; receivers SHOULD NOT overwrite an existing different owner. // First claim wins; receivers SHOULD NOT overwrite an existing different owner.
message OwnershipAnnounce { message OwnershipAnnounce {
string host = 1; // announcing host string host = 1; // announcing host
repeated string cart_ids = 2; // newly claimed cart ids repeated uint64 cart_ids = 2; // newly claimed cart ids
} }
// ControlPlane defines cluster coordination and ownership operations. // ControlPlane defines cluster coordination and ownership operations.

View File

@@ -205,14 +205,11 @@ func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) {
count := 0 count := 0
// Record remote ownership (first-touch model) instead of spawning remote grain proxies. // Record remote ownership (first-touch model) instead of spawning remote grain proxies.
p.mu.Lock() p.mu.Lock()
for _, idStr := range reply.CartIds { for _, cid := range reply.CartIds {
if idStr == "" {
continue
}
cid := ToCartId(idStr)
// Only set if not already claimed (first claim wins) // Only set if not already claimed (first claim wins)
if _, exists := p.remoteOwners[cid]; !exists { if _, exists := p.remoteOwners[CartId(cid)]; !exists {
p.remoteOwners[cid] = remote.Host p.remoteOwners[CartId(cid)] = remote.Host
} }
count++ count++
} }
@@ -383,7 +380,7 @@ func (p *SyncedPool) DebugOwnerHost(id CartId) string {
func (p *SyncedPool) removeLocalGrain(id CartId) { func (p *SyncedPool) removeLocalGrain(id CartId) {
p.mu.Lock() p.mu.Lock()
delete(p.local.grains, LegacyToCartKey(id)) delete(p.local.grains, uint64(id))
p.mu.Unlock() p.mu.Unlock()
} }
@@ -404,7 +401,7 @@ var ErrNotOwner = fmt.Errorf("not owner")
func (p *SyncedPool) resolveOwnerFirstTouch(id CartId) (string, error) { func (p *SyncedPool) resolveOwnerFirstTouch(id CartId) (string, error) {
// Fast local existence check // Fast local existence check
p.local.mu.RLock() p.local.mu.RLock()
_, existsLocal := p.local.grains[LegacyToCartKey(id)] _, existsLocal := p.local.grains[uint64(id)]
p.local.mu.RUnlock() p.local.mu.RUnlock()
if existsLocal { if existsLocal {
return p.LocalHostname, nil return p.LocalHostname, nil
@@ -485,14 +482,18 @@ func (p *SyncedPool) AdoptRemoteOwnership(host string, ids []string) {
if s == "" { if s == "" {
continue continue
} }
id := ToCartId(s) parsed, ok := ParseCartId(s)
if !ok {
continue // skip invalid cart id strings
}
id := parsed
// Do not overwrite if already claimed by another host (first wins). // Do not overwrite if already claimed by another host (first wins).
if existing, ok := p.remoteOwners[id]; ok && existing != host { if existing, ok := p.remoteOwners[id]; ok && existing != host {
continue continue
} }
// Skip if we own locally (local wins for our own process) // Skip if we own locally (local wins for our own process)
p.local.mu.RLock() p.local.mu.RLock()
_, localHas := p.local.grains[LegacyToCartKey(id)] _, localHas := p.local.grains[uint64(id)]
p.local.mu.RUnlock() p.local.mu.RUnlock()
if localHas { if localHas {
continue continue