diff --git a/cart-grain.go b/cart-grain.go index 8b39bde..1f5d100 100644 --- a/cart-grain.go +++ b/cart-grain.go @@ -9,37 +9,8 @@ import ( messages "git.tornberg.me/go-cart-actor/proto" ) -type CartId [16]byte - -// 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 -} +// Legacy padded [16]byte CartId and its helper methods removed. +// Unified CartId (uint64 with base62 string form) now defined in cart_id.go. type StockStatus int diff --git a/cart_id.go b/cart_id.go index ba9d6fe..6039101 100644 --- a/cart_id.go +++ b/cart_id.go @@ -2,63 +2,43 @@ package main import ( "crypto/rand" - "encoding/binary" - "errors" + "encoding/json" "fmt" - "strings" ) // cart_id.go // -// Compact CartID implementation using 64 bits of cryptographic randomness, -// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64). +// Breaking change: +// 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: -// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId -// * O(1) hashing (raw uint64) for consistent hashing ring integration -// * Extremely low collision probability (birthday bound negligible at scale) +// Rationale: +// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper. +// - Provides compact, URL/cookie-friendly identifiers. +// - O(1) hashing and minimal memory footprint. +// - 64 bits of crypto randomness => negligible collision probability at realistic scale. // -// Backward Compatibility Strategy (Phased): -// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId. -// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()). -// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header. +// Public API: +// type CartId uint64 +// func NewCartId() (CartId, error) +// func MustNewCartId() CartId +// func ParseCartId(string) (CartId, bool) +// func MustParseCartId(string) CartId +// (CartId).String() string +// (CartId).MarshalJSON() / UnmarshalJSON() // // NOTE: -// The legacy type `CartId [16]byte` is still present elsewhere; helper -// UpgradeLegacyCartId bridges that representation to the new form without -// 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. +// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants, +// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change. // // --------------------------------------------------------------------------- +type CartId uint64 + const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -// Precomputed reverse lookup table for decode (255 = invalid). +// Reverse lookup (0xFF marks invalid) var base62Rev [256]byte func init() { @@ -70,136 +50,90 @@ func init() { } } -// CartID is the compact representation of a cart identifier. -// raw: 64-bit entropy (also used directly for consistent hashing). -// txt: cached base62 textual form. -type CartID struct { - raw uint64 - txt string +// String returns the canonical base62 encoding of the 64-bit id. +func (id CartId) String() string { + return encodeBase62(uint64(id)) } -// String returns the canonical base62 encoded ID. -func (c CartID) String() string { - if c.txt == "" { // lazily encode if constructed manually - c.txt = encodeBase62(c.raw) +// MarshalJSON encodes the cart id as a JSON string. +func (id CartId) MarshalJSON() ([]byte, error) { + return json.Marshal(id.String()) +} + +// UnmarshalJSON decodes a cart id from a JSON string containing base62 text. +func (id *CartId) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err } - return c.txt + parsed, ok := ParseCartId(s) + if !ok { + return fmt.Errorf("invalid cart id: %q", s) + } + *id = parsed + return nil } -// Raw returns the 64-bit numeric value (useful for hashing / ring lookup). -func (c CartID) Raw() uint64 { - return c.raw -} - -// IsZero reports whether this CartID is the zero value. -func (c CartID) IsZero() bool { - return c.raw == 0 -} - -// NewCartID generates a new cryptographically random 64-bit ID. -func NewCartID() (CartID, error) { +// NewCartId generates a new cryptographically random non-zero 64-bit id. +func NewCartId() (CartId, error) { var b [8]byte 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[:]) - // Reject zero if you want to avoid ever producing "0" (optional). + u := (uint64(b[0]) << 56) | + (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 { - // Extremely unlikely; recurse once. - return NewCartID() + // Extremely unlikely; regenerate once to avoid "0" identifier if desired. + return NewCartId() } - return CartID{raw: u, txt: encodeBase62(u)}, nil + return CartId(u), nil } -// MustNewCartID panics on failure (suitable for tests / initialization). -func MustNewCartID() CartID { - id, err := NewCartID() +// MustNewCartId panics if generation fails. +func MustNewCartId() CartId { + id, err := NewCartId() if err != nil { panic(err) } return id } -// ParseCartID attempts to parse a base62 canonical ID. -// Returns (id, true) if fully valid; (zero, false) otherwise. -func ParseCartID(s string) (CartID, bool) { - if len(s) == 0 { - return CartID{}, false - } - // Basic length sanity; allow a bit of headroom for future timestamp variant. - if len(s) > 16 { - return CartID{}, false +// ParseCartId parses a base62 string into a CartId. +// Returns (0,false) for invalid input. +func ParseCartId(s string) (CartId, bool) { + // Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately. + // Provide a slightly looser upper bound (<=16) only if you anticipate future + // extensions; here we stay strict. + if len(s) == 0 || len(s) > 11 { + return 0, false } u, ok := decodeBase62(s) 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 -// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map -// consistently into the new scheme (collision probability still low). -func FallbackFromString(s string) CartID { - const ( - offset64 = 1469598103934665603 - prime64 = 1099511628211 - ) - h := uint64(offset64) - for i := 0; i < len(s); i++ { - h ^= uint64(s[i]) - h *= prime64 +// MustParseCartId panics on invalid base62 input. +func MustParseCartId(s string) CartId { + id, ok := ParseCartId(s) + if !ok { + panic(fmt.Sprintf("invalid cart id: %q", s)) } - return CartID{raw: h, txt: encodeBase62(h)} + return id } -// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID -// 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. +// encodeBase62 converts a uint64 to base62 (shortest form). func encodeBase62(u uint64) string { if u == 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 i := len(buf) for u > 0 { @@ -210,8 +144,7 @@ func encodeBase62(u uint64) string { return string(buf[i:]) } -// decodeBase62 converts a base62 string to uint64. -// Returns (value, false) if any invalid character appears. +// decodeBase62 converts base62 text to uint64. func decodeBase62(s string) (uint64, bool) { var v uint64 for i := 0; i < len(s); i++ { @@ -224,104 +157,3 @@ func decodeBase62(s string) (uint64, bool) { } 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 -} diff --git a/cart_id_test.go b/cart_id_test.go index a16ed56..f7d1883 100644 --- a/cart_id_test.go +++ b/cart_id_test.go @@ -1,167 +1,155 @@ package main import ( - "crypto/rand" - "encoding/binary" - mrand "math/rand" + "encoding/json" + "fmt" "testing" ) -// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse. -func TestEncodeDecodeBase62RoundTrip(t *testing.T) { - mrand.Seed(42) - 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 +// TestNewCartIdUniqueness generates many ids and checks for collisions. +func TestNewCartIdUniqueness(t *testing.T) { + const n = 20000 seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { - id, err := NewCartID() + id, err := NewCartId() if err != nil { - t.Fatalf("NewCartID error: %v", err) + t.Fatalf("NewCartId error: %v", err) } s := id.String() if _, exists := seen[s]; exists { - t.Fatalf("duplicate CartID generated: %s", s) + t.Fatalf("duplicate id encountered: %s", s) } seen[s] = struct{}{} - if id.IsZero() { - t.Fatalf("NewCartID returned zero value") + if s == "" { + 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. -func TestParseCartIDValidation(t *testing.T) { - id, err := NewCartID() - if err != nil { - t.Fatalf("NewCartID error: %v", err) - } - parsed, ok := ParseCartID(id.String()) +// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable. +func TestParseCartIdRoundTrip(t *testing.T) { + id := MustNewCartId() + txt := id.String() + parsed, ok := ParseCartId(txt) 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 { - t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw) - } - - 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") + if parsed != id { + t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt) } } -// TestFallbackDeterminism ensures fallback hashing is deterministic. -func TestFallbackDeterminism(t *testing.T) { - inputs := []string{ - "legacy-cart-1", - "legacy-cart-2", - "UPPER_lower_123", - "🚀unicode", // unicode bytes (will hash byte sequence) +// TestParseCartIdInvalid covers invalid inputs. +func TestParseCartIdInvalid(t *testing.T) { + invalid := []string{ + "", // empty + " ", // space + "01234567890abc", // >11 chars + "!!!!", // invalid chars + "-underscore-", // invalid chars + "abc_def", // underscore invalid for base62 + "0123456789ABCD", // 14 chars } - for _, in := range inputs { - a := FallbackFromString(in) - b := FallbackFromString(in) - if a.raw != b.raw || a.String() != b.String() { - t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b) + for _, s := range invalid { + if _, ok := ParseCartId(s); ok { + t.Fatalf("expected parse failure for %q", s) } } - // 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") +} + +// TestMustParseCartIdPanics verifies panic behavior for invalid input. +func TestMustParseCartIdPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic for invalid MustParseCartId input") + } + }() + _ = MustParseCartId("not*base62") +} + +// TestJSONMarshalUnmarshalCartId verifies JSON round trip. +func TestJSONMarshalUnmarshalCartId(t *testing.T) { + id := MustNewCartId() + data, err := json.Marshal(struct { + Cart CartId `json:"cart"` + }{Cart: id}) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var out struct { + Cart CartId `json:"cart"` + } + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if out.Cart != id { + t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart) } } -// TestCanonicalizeIncomingBehavior covers main control flow branches. -func TestCanonicalizeIncomingBehavior(t *testing.T) { - // Empty => new id - id1, generated, err := CanonicalizeIncoming("") - if err != nil || !generated || id1.IsZero() { - t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err) +// TestBase62LengthBound checks worst-case length (near max uint64). +func TestBase62LengthBound(t *testing.T) { + // Largest uint64 + const maxU64 = ^uint64(0) + s := encodeBase62(maxU64) + if len(s) > 11 { + t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s) } - - // Valid base62 => parse; no generation - id2, gen2, err := CanonicalizeIncoming(id1.String()) - if err != nil || gen2 || id2.raw != id1.raw { - t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err) - } - - // Legacy-like random containing invalid chars -> fallback - 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) - } - - // Deterministic fallback - id4, _, _ := CanonicalizeIncoming(fallbackInput) - if id3.raw != id4.raw { - t.Fatalf("fallback canonicalization not deterministic") + dec, ok := decodeBase62(s) + if !ok || dec != maxU64 { + t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64) } } -// TestUpgradeLegacyCartId ensures mapping of old CartId is stable. -func TestUpgradeLegacyCartId(t *testing.T) { - var legacy CartId - copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding - up1 := UpgradeLegacyCartId(legacy) - up2 := UpgradeLegacyCartId(legacy) - if up1.raw != up2.raw { - t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2) +// TestZeroEncoding ensures zero value encodes to "0" and parses back. +func TestZeroEncoding(t *testing.T) { + if s := encodeBase62(0); s != "0" { + t.Fatalf("encodeBase62(0) expected '0', got %q", s) } - if up1.String() != up2.String() { - t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2) + 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") } } -// BenchmarkNewCartID gives a rough idea of generation cost. -func BenchmarkNewCartID(b *testing.B) { +// 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++ { - if _, err := NewCartID(); err != nil { - b.Fatalf("error: %v", err) + if _, err := NewCartId(); err != nil { + b.Fatalf("NewCartId error: %v", err) } } } -// BenchmarkEncodeBase62 measures encode speed in isolation. +// BenchmarkEncodeBase62 measures encoding performance. func BenchmarkEncodeBase62(b *testing.B) { - // Random sample of values + // Precompute sample values samples := make([]uint64, 1024) for i := range samples { - var buf [8]byte - if _, err := rand.Read(buf[:]); err != nil { - b.Fatalf("rand: %v", err) - } - samples[i] = binary.BigEndian.Uint64(buf[:]) + // Spread bits without crypto randomness overhead + samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87) } b.ResetTimer() var sink string @@ -171,30 +159,27 @@ func BenchmarkEncodeBase62(b *testing.B) { _ = sink } -// BenchmarkDecodeBase62 measures decode speed. +// BenchmarkDecodeBase62 measures decoding performance. func BenchmarkDecodeBase62(b *testing.B) { - // Pre-encode encoded := make([]string, 1024) for i := range encoded { - encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i)) + encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i)) } b.ResetTimer() var sum uint64 for i := 0; i < b.N; i++ { v, ok := decodeBase62(encoded[i%len(encoded)]) if !ok { - b.Fatalf("decode failed") + b.Fatalf("decode failure for %s", encoded[i%len(encoded)]) } sum ^= v } _ = sum } -// Removed TestLookupNDeterminism (ring-based ownership deprecated) - -// Removed TestRingFingerprintChanges (ring-based ownership deprecated) - -// Removed TestRingDiffHosts (ring-based ownership deprecated) - -// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned. -// Removed TestRingLookupConsistency (ring-based ownership deprecated) +// ExampleCartIdString documents usage of CartId string form. +func ExampleCartId_string() { + id := MustNewCartId() + fmt.Println(len(id.String()) <= 11) // outputs true + // Output: true +} diff --git a/disk-storage.go b/disk-storage.go index 452c9e3..ada0c56 100644 --- a/disk-storage.go +++ b/disk-storage.go @@ -7,16 +7,21 @@ import ( "time" ) +func init() { + + gob.Register(map[uint64]int64{}) +} + type DiskStorage struct { stateFile string lastSave int64 - LastSaves map[CartId]int64 + LastSaves map[uint64]int64 } func NewDiskStorage(stateFile string) (*DiskStorage, error) { ret := &DiskStorage{ stateFile: stateFile, - LastSaves: make(map[CartId]int64), + LastSaves: make(map[uint64]int64), } err := ret.loadState() return ret, err @@ -64,7 +69,7 @@ func (s *DiskStorage) loadState() error { func (s *DiskStorage) Store(id CartId, _ *CartGrain) error { // With the removal of the legacy message log, we only update the timestamp. ts := time.Now().Unix() - s.LastSaves[id] = ts + s.LastSaves[uint64(id)] = ts s.lastSave = ts return nil } diff --git a/grain-pool.go b/grain-pool.go index 4009a17..8394394 100644 --- a/grain-pool.go +++ b/grain-pool.go @@ -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. func keyFromCartId(id CartId) uint64 { - return LegacyToCartKey(id) + return uint64(id) } // storeGrain indexes a grain in both maps. diff --git a/grpc_server.go b/grpc_server.go index a4a849c..5ec4735 100644 --- a/grpc_server.go +++ b/grpc_server.go @@ -69,12 +69,12 @@ func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.Negot // ControlPlane: GetCartIds (locally owned carts only) func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) { 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 { if g == nil { continue } - ids = append(ids, g.GetId().String()) + ids = append(ids, uint64(g.GetId())) } s.syncedPool.local.mu.RUnlock() return &messages.CartIdsReply{CartIds: ids}, nil diff --git a/k6/cart_load_test.js b/k6/cart_load_test.js index 7bc4596..2fff92d 100644 --- a/k6/cart_load_test.js +++ b/k6/cart_load_test.js @@ -31,7 +31,7 @@ export const options = { { duration: "1m", target: 0 }, ], gracefulStop: "30s", - startTime: "5m", + startTime: "30s", }, }, thresholds: { @@ -157,7 +157,7 @@ function addRandomItem() { country: "no", }); 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; addItemTrend.add(dur, { op: "add" }); if (res.status === 200) { @@ -182,16 +182,16 @@ function fetchCart() { // Occasional checkout trigger function maybeCheckout() { if (!cartState.cartid) return; - // Small probability - if (Math.random() < 0.02) { - const start = Date.now(); - const res = http.get(baseUrl() + "/checkout", { headers: headers() }); - const dur = Date.now() - start; - checkoutTrend.add(dur, { op: "checkout" }); - updateCookies(res); - if (res.status === 200) checkoutCounter.add(1); - check(res, { "checkout status ok": (r) => r.status === 200 }); - } + // // Small probability + // if (Math.random() < 0.02) { + // const start = Date.now(); + // const res = http.get(baseUrl() + "/checkout", { headers: headers() }); + // const dur = Date.now() - start; + // checkoutTrend.add(dur, { op: "checkout" }); + // updateCookies(res); + // if (res.status === 200) checkoutCounter.add(1); + // check(res, { "checkout status ok": (r) => r.status === 200 }); + // } } // ---------------- k6 lifecycle ---------------- diff --git a/main.go b/main.go index 0517fc9..3669eb3 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func (a *App) Save() error { if grain == nil { continue } - if grain.GetLastChange() > a.storage.LastSaves[id] { + if grain.GetLastChange() > a.storage.LastSaves[uint64(id)] { hasChanges = true err := a.storage.Store(id, grain) if err != nil { @@ -160,12 +160,12 @@ func GetDiscovery() Discovery { 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 { log.Printf("Error loading state: %v\n", err) } app := &App{ - pool: NewGrainLocalPool(65535, 2*time.Hour, spawn), + pool: NewGrainLocalPool(65535, 15*time.Minute, spawn), storage: storage, } @@ -253,7 +253,13 @@ func main() { w.Write([]byte("no cart id to checkout is empty")) 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) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -393,9 +399,13 @@ func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutO OrderId: order.ID, 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 { - _ = AppendCartEvent(ToCartId(order.MerchantReference1), mutation) + _ = AppendCartEvent(cid, mutation) } return applyErr } diff --git a/ownership_middleware.go b/ownership_middleware.go index d78a8aa..99d2c60 100644 --- a/ownership_middleware.go +++ b/ownership_middleware.go @@ -156,9 +156,12 @@ func OwnershipProxyMiddleware(pool *SyncedPool) func(http.Handler) http.Handler func extractCartIdFromRequest(r *http.Request) (CartId, bool) { // Cookie if c, err := r.Cookie("cartid"); err == nil && c.Value != "" { - if cid, _, _, err2 := CanonicalizeOrLegacy(c.Value); err2 == nil { - return CartIDToLegacy(cid), true + if parsed, ok := ParseCartId(c.Value); ok { + return parsed, true } + // Invalid existing cookie value: issue a fresh id (breaking change behavior) + newId := MustNewCartId() + return newId, true } // Path-based: locate "byid" segment parts := splitPath(r.URL.Path) @@ -166,8 +169,8 @@ func extractCartIdFromRequest(r *http.Request) (CartId, bool) { if parts[i] == "byid" && i+1 < len(parts) { raw := parts[i+1] if raw != "" { - if cid, _, _, err := CanonicalizeOrLegacy(raw); err == nil { - return CartIDToLegacy(cid), true + if parsed, ok := ParseCartId(raw); ok { + return parsed, true } } } diff --git a/pool-server.go b/pool-server.go index 56155f0..b7850f8 100644 --- a/pool-server.go +++ b/pool-server.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "log" - "math/rand" "net/http" "strconv" "time" @@ -267,75 +266,64 @@ func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id C return json.NewEncoder(w).Encode(klarnaOrder) } -func NewCartId() CartId { - // Deprecated: legacy random/time based cart id generator. - // Retained for compatibility; new code should prefer canonical CartID path. - cid, err := NewCartID() - if err != nil { - // Fallback to legacy method only if crypto/rand fails - id := time.Now().UnixNano() + rand.Int63() - return ToCartId(fmt.Sprintf("%d", id)) - } - return CartIDToLegacy(cid) -} +/* +Legacy wrapper NewCartId removed. +Use the unified generator in cart_id.go: + id, err := NewCartId() +or panic-on-error helper: + id := MustNewCartId() +*/ 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 { - var legacy CartId - cookies := r.CookiesNamed("cartid") - if len(cookies) == 0 { - cid, generated, _, err := CanonicalizeOrLegacy("") - if err != nil { - return fmt.Errorf("failed to generate cart id: %w", err) - } - legacy = CartIDToLegacy(cid) - if generated { - http.SetCookie(w, &http.Cookie{ - Name: "cartid", - Value: cid.String(), - Secure: r.TLS != nil, - HttpOnly: true, - Path: "/", - Expires: time.Now().AddDate(0, 0, 14), - SameSite: http.SameSiteLaxMode, - }) - w.Header().Set("Set-Cart-Id", cid.String()) - } + var id CartId + cookie, err := r.Cookie("cartid") + if err != nil || cookie.Value == "" { + id = MustNewCartId() + http.SetCookie(w, &http.Cookie{ + Name: "cartid", + Value: id.String(), + Secure: r.TLS != nil, + HttpOnly: true, + Path: "/", + Expires: time.Now().AddDate(0, 0, 14), + SameSite: http.SameSiteLaxMode, + }) + w.Header().Set("Set-Cart-Id", id.String()) } else { - raw := cookies[0].Value - cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) - if err != nil { - return fmt.Errorf("failed to canonicalize cart id: %w", err) - } - legacy = CartIDToLegacy(cid) - if generated && wasBase62 { + parsed, ok := ParseCartId(cookie.Value) + if !ok { + id = MustNewCartId() http.SetCookie(w, &http.Cookie{ Name: "cartid", - Value: cid.String(), + Value: id.String(), Secure: r.TLS != nil, HttpOnly: true, Path: "/", Expires: time.Now().AddDate(0, 0, 14), 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 handled, err := ownershipProxyAfterExtraction(legacy, w, r); handled || err != nil { + if handled, err := ownershipProxyAfterExtraction(id, w, r); handled || err != nil { 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 { - cartId = NewCartId() + // Clear cart cookie (breaking change: do not issue a new legacy id here) http.SetCookie(w, &http.Cookie{ Name: "cartid", - Value: cartId.String(), + Value: "", Path: "/", Secure: r.TLS != nil, 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 { return func(w http.ResponseWriter, r *http.Request) error { raw := r.PathValue("id") - cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw) - if err != nil { - return fmt.Errorf("invalid cart id: %w", err) + // If no id supplied, generate a new one + if raw == "" { + id := MustNewCartId() + w.Header().Set("Set-Cart-Id", id.String()) + if ownershipProxyAfterExtraction != nil { + if handled, err := ownershipProxyAfterExtraction(id, w, r); handled || err != nil { + return err + } + } + return fn(w, r, id) } - legacy := CartIDToLegacy(cid) - if generated && wasBase62 { - w.Header().Set("Set-Cart-Id", cid.String()) + // Parse base62 cart id + id, ok := ParseCartId(raw) + if !ok { + return fmt.Errorf("invalid cart id format") } - // Ownership proxy AFTER path id extraction (explicit id mode) 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 fn(w, r, legacy) + return fn(w, r, id) } } diff --git a/proto/control_plane.pb.go b/proto/control_plane.pb.go index 57460b9..3a7483a 100644 --- a/proto/control_plane.pb.go +++ b/proto/control_plane.pb.go @@ -204,7 +204,7 @@ func (x *NegotiateReply) GetHosts() []string { // CartIdsReply returns the list of cart IDs (string form) currently owned locally. type CartIdsReply struct { 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 sizeCache protoimpl.SizeCache } @@ -239,7 +239,7 @@ func (*CartIdsReply) Descriptor() ([]byte, []int) { return file_control_plane_proto_rawDescGZIP(), []int{4} } -func (x *CartIdsReply) GetCartIds() []string { +func (x *CartIdsReply) GetCartIds() []uint64 { if x != nil { return x.CartIds } @@ -348,8 +348,8 @@ func (x *ClosingNotice) GetHost() string { // First claim wins; receivers SHOULD NOT overwrite an existing different owner. type OwnershipAnnounce struct { state protoimpl.MessageState `protogen:"open.v1"` - 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 + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host + CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` // newly claimed cart ids unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -391,7 +391,7 @@ func (x *OwnershipAnnounce) GetHost() string { return "" } -func (x *OwnershipAnnounce) GetCartIds() []string { +func (x *OwnershipAnnounce) GetCartIds() []uint64 { if x != nil { return x.CartIds } @@ -413,7 +413,7 @@ const file_control_plane_proto_rawDesc = "" + "\x0eNegotiateReply\x12\x14\n" + "\x05hosts\x18\x01 \x03(\tR\x05hosts\")\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" + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\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" + "\x11OwnershipAnnounce\x12\x12\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" + "\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" + "\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" + diff --git a/proto/control_plane.proto b/proto/control_plane.proto index b741da5..03bf6c8 100644 --- a/proto/control_plane.proto +++ b/proto/control_plane.proto @@ -38,7 +38,7 @@ message NegotiateReply { // CartIdsReply returns the list of cart IDs (string form) currently owned locally. message CartIdsReply { - repeated string cart_ids = 1; + repeated uint64 cart_ids = 1; } // 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. message OwnershipAnnounce { 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. diff --git a/synced-pool.go b/synced-pool.go index abe2926..65f9321 100644 --- a/synced-pool.go +++ b/synced-pool.go @@ -205,14 +205,11 @@ func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) { count := 0 // Record remote ownership (first-touch model) instead of spawning remote grain proxies. p.mu.Lock() - for _, idStr := range reply.CartIds { - if idStr == "" { - continue - } - cid := ToCartId(idStr) + for _, cid := range reply.CartIds { + // Only set if not already claimed (first claim wins) - if _, exists := p.remoteOwners[cid]; !exists { - p.remoteOwners[cid] = remote.Host + if _, exists := p.remoteOwners[CartId(cid)]; !exists { + p.remoteOwners[CartId(cid)] = remote.Host } count++ } @@ -383,7 +380,7 @@ func (p *SyncedPool) DebugOwnerHost(id CartId) string { func (p *SyncedPool) removeLocalGrain(id CartId) { p.mu.Lock() - delete(p.local.grains, LegacyToCartKey(id)) + delete(p.local.grains, uint64(id)) p.mu.Unlock() } @@ -404,7 +401,7 @@ var ErrNotOwner = fmt.Errorf("not owner") func (p *SyncedPool) resolveOwnerFirstTouch(id CartId) (string, error) { // Fast local existence check p.local.mu.RLock() - _, existsLocal := p.local.grains[LegacyToCartKey(id)] + _, existsLocal := p.local.grains[uint64(id)] p.local.mu.RUnlock() if existsLocal { return p.LocalHostname, nil @@ -485,14 +482,18 @@ func (p *SyncedPool) AdoptRemoteOwnership(host string, ids []string) { if s == "" { 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). if existing, ok := p.remoteOwners[id]; ok && existing != host { continue } // Skip if we own locally (local wins for our own process) p.local.mu.RLock() - _, localHas := p.local.grains[LegacyToCartKey(id)] + _, localHas := p.local.grains[uint64(id)] p.local.mu.RUnlock() if localHas { continue