eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– Package idalloc is the shared, global, monotonic id allocator behind
2// region-neutral space addressing (multi-edge Phase 1, docs/multi-edge.md).
3//
4// The problem it solves: once a space's hostname is <sqids(globalID)>.dev.11ch.at
5// β€” ONE namespace, no country label β€” the id feeding sqids must be unique across
6// the WHOLE fleet, or two region clouds minting concurrently would produce the
7// same hostname. Home is the primary caller, but demo and direct/operator
8// creates bypass home (they go straight to a box's control daemon), so every
9// place that mints an id must draw from the SAME sequence.
10//
11// The source of truth is a single small object in the shared S3 bucket (the
12// mesh's substrate β€” LCHAT_HIBERNATE_BUCKET) holding the high-water mark: the
13// count of ids reserved so far. Advancing it is a compare-and-swap on S3's ETag,
14// exactly the conditional-write style the hibernation lease (internal/activator)
15// and the home vault (home/s3store.go) already ride β€” no new dependency, no
16// second S3 client.
17//
18// Block allocation is the scaling trick. Id allocation happens once per space
19// create, and Paul wants this to reach billions of spaces; one S3 round-trip per
20// create would put the counter object on the hot path of every create fleet-wide.
21// Instead an allocator reserves a RANGE of N ids in one CAS, then hands them out
22// from memory, refilling with another CAS only when the block is spent. That
23// bounds S3 ops to ~(creates / N) β€” with the default N=100, a rounding error on
24// the bill even at scale (#836's lesson: request count IS the S3 cost).
25//
26// Guarantees: ids are unique and strictly increasing across all allocators (the
27// CAS makes overlap impossible β€” a conflict just retries against the new
28// high-water). The high-water lives in S3, so a restarted process continues past
29// it, skipping the unused tail of its last block; ids need only be unique and
30// increasing, never gapless, so skipped tails are fine. In-process hand-outs are
31// mutex-guarded.
32//
33// Where it plugs in (the NEXT slice β€” this package does not wire it): cloud's
34// createSpaceHandler (cloud.go) currently takes the id from the cloud.db spaces
35// PK via cloudDB.allocateSpace; that id source becomes Allocate(ctx). The demo
36// path (demo.go) and controld's direct add-instance create the same way. Home,
37// cloud, and controld each construct an Allocator from the shared bucket and use
38// it identically.
39package idalloc
40
41import (
42 "bytes"
43 "context"
44 "errors"
45 "fmt"
46 "io"
47 "math/rand"
48 "net/http"
49 "path"
50 "strconv"
51 "strings"
52 "sync"
53 "time"
54
55 "github.com/aws/aws-sdk-go-v2/aws"
56 awsconfig "github.com/aws/aws-sdk-go-v2/config"
57 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
58 "github.com/aws/smithy-go"
59)
60
61// DefaultBlockSize is the number of ids reserved per S3 CAS when a caller names
62// none. 100 keeps S3 ops to ~1% of creates while wasting at most 99 ids on a
63// restart β€” ids are cheap (uint64) and gaps are allowed, so a larger block only
64// trades a slightly bigger restart gap for even fewer S3 ops.
65const DefaultBlockSize uint64 = 100
66
67// counterKey is the object under the bucket prefix that holds the high-water
68// mark. It sits beside the mesh's membership heartbeats (<prefix>/mesh/members),
69// under the same "mesh/" namespace β€” the bare label never collides with a
70// per-instance prefix, which is always a dotted FQDN.
71const counterKey = "mesh/id-sequence"
72
73// maxReserveAttempts caps the CAS-conflict retry loop. Block allocation makes a
74// conflict a 1-in-N event and each conflict means a competitor made progress
75// (the high-water strictly rose), so the loser wins on a later attempt; the
76// number of real allocator instances is tiny (home + a handful of region clouds
77// + controld), so contention never approaches this. It exists only so a
78// pathological store (e.g. a swap that always reports conflict) fails loudly
79// instead of spinning forever. The context deadline is the primary bound.
80const maxReserveAttempts = 100
81
82// s3CallTimeout bounds each individual S3 call so a stalled counter object can't
83// hang a space create indefinitely (the caller's ctx is the outer bound; this is
84// the per-request floor, matching s3blobstore.go's per-call timeout).
85const s3CallTimeout = 30 * time.Second
86
87// counterStore is the minimal compare-and-swap surface the allocator needs over
88// the one shared counter object. Splitting it out keeps the block/retry logic
89// pure and unit-testable against an in-memory fake, while the S3-backed
90// implementation stays a thin adapter.
91//
92// The version token is opaque (S3's ETag in production). By convention an empty
93// token means "the object does not exist yet" β€” load returns "" when absent, and
94// swap treats "" as a create-only precondition. S3 never returns an empty ETag
95// for an existing object, so the convention is unambiguous there.
96type counterStore interface {
97 // load reads the current high-water value and its version token. A missing
98 // object reads as value 0 with an empty token (the first writer's state).
99 load(ctx context.Context) (value uint64, token string, err error)
100 // swap writes newValue only if the object still matches token (or is still
101 // absent, when token is ""). ok=false means a concurrent writer won the race
102 // and the caller must reload and retry; a non-nil err is a real failure.
103 swap(ctx context.Context, token string, newValue uint64) (ok bool, err error)
104}
105
106// Allocator hands out globally-unique, strictly-increasing ids drawn from blocks
107// it reserves in the shared S3 counter. Safe for concurrent use.
108type Allocator struct {
109 store counterStore
110 blockSize uint64
111
112 // mu guards the in-memory block AND serializes refills: holding it across the
113 // refill CAS means two goroutines can never both reserve a block (which would
114 // skip a whole block's worth of ids β€” still correct, but wasteful). Refills
115 // are 1-in-blockSize, so the serialization is negligible.
116 mu sync.Mutex
117 next uint64 // next id to hand out; ids are 1-based
118 end uint64 // one past the last id in the reserved block; next>=end β‡’ exhausted
119}
120
121// Config constructs an S3-backed Allocator. Bucket/Prefix/Region mirror the
122// hibernation config (LCHAT_HIBERNATE_*) so a caller that already reads that env
123// can build one with no new plumbing; the counter object lands at
124// <Prefix>/mesh/id-sequence in Bucket.
125type Config struct {
126 Bucket string // shared S3 bucket (the mesh substrate); required
127 Prefix string // key prefix inside the bucket (may be empty)
128 Region string // optional; the AWS default chain resolves "" from env/profile
129 Endpoint string // optional S3-compatible endpoint (R2, a test fake); implies path-style
130 BlockSize uint64 // ids per CAS; 0 β†’ DefaultBlockSize
131}
132
133// New builds an S3-backed Allocator. Credentials ride the standard AWS chain
134// (the same as the hibernation lease and blob store); Endpoint lets an
135// S3-compatible store or a test fake stand in.
136func New(ctx context.Context, cfg Config) (*Allocator, error) {
137 if strings.TrimSpace(cfg.Bucket) == "" {
138 return nil, errors.New("idalloc: bucket is required")
139 }
140 var opts []func(*awsconfig.LoadOptions) error
141 if cfg.Region != "" {
142 opts = append(opts, awsconfig.WithRegion(cfg.Region))
143 }
144 awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
145 if err != nil {
146 return nil, fmt.Errorf("idalloc: load aws config: %w", err)
147 }
148 client := awss3.NewFromConfig(awsCfg, func(o *awss3.Options) {
149 if cfg.Endpoint != "" {
150 o.BaseEndpoint = &cfg.Endpoint
151 o.UsePathStyle = true // custom endpoints (R2, fakes) address by path
152 }
153 // Match s3blobstore.go: the SDK's default-on flexible checksums wrap
154 // PutObject bodies in aws-chunked framing that non-AWS stores reject; the
155 // counter body is tiny with a known length, so plain bodies are correct.
156 o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
157 o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
158 })
159 store := &s3Counter{
160 client: client,
161 bucket: cfg.Bucket,
162 key: path.Join(cfg.Prefix, counterKey),
163 }
164 return newWithStore(store, cfg.BlockSize), nil
165}
166
167// newWithStore builds an Allocator over any counterStore β€” the seam the tests
168// drive with an in-memory fake, and the shared constructor path for New.
169func newWithStore(store counterStore, blockSize uint64) *Allocator {
170 if blockSize == 0 {
171 blockSize = DefaultBlockSize
172 }
173 return &Allocator{store: store, blockSize: blockSize}
174}
175
176// Allocate returns the next globally-unique id, reserving a fresh block from S3
177// when the current one is spent.
178func (a *Allocator) Allocate(ctx context.Context) (uint64, error) {
179 a.mu.Lock()
180 defer a.mu.Unlock()
181 if a.next >= a.end {
182 if err := a.reserveLocked(ctx); err != nil {
183 return 0, err
184 }
185 }
186 id := a.next
187 a.next++
188 return id, nil
189}
190
191// reserveLocked reserves the next block via a CAS on the shared counter,
192// retrying on conflict. Called with a.mu held.
193//
194// The CAS: read the high-water H (the count reserved so far) and its version
195// token, then conditional-write H+blockSize keyed on that token. Success means
196// no other allocator advanced the counter in between, so this process now owns
197// ids [H+1, H+blockSize]. A conflict means a competitor reserved a block first;
198// its write bumped the token, so ours is stale and the store reports ok=false β€”
199// we reload the now-higher H and try again, never reusing the ids the competitor
200// took.
201func (a *Allocator) reserveLocked(ctx context.Context) error {
202 for attempt := 0; attempt < maxReserveAttempts; attempt++ {
203 if err := ctx.Err(); err != nil {
204 return err
205 }
206 high, token, err := a.store.load(ctx)
207 if err != nil {
208 return fmt.Errorf("idalloc: load counter: %w", err)
209 }
210 newHigh := high + a.blockSize
211 ok, err := a.store.swap(ctx, token, newHigh)
212 if err != nil {
213 return fmt.Errorf("idalloc: reserve block: %w", err)
214 }
215 if ok {
216 a.next = high + 1 // ids are 1-based, so the block starts at H+1
217 a.end = newHigh + 1 // exclusive: hand out H+1 .. H+blockSize
218 return nil
219 }
220 // Lost the race β€” back off a touch before reloading, so a burst of
221 // allocators contending on a fresh counter doesn't livelock hammering it.
222 if err := sleepCtx(ctx, backoff(attempt)); err != nil {
223 return err
224 }
225 }
226 return fmt.Errorf("idalloc: reserve block: gave up after %d CAS conflicts", maxReserveAttempts)
227}
228
229// backoff is capped exponential with full jitter β€” small, since a conflict
230// resolves in one or two retries in any realistic fleet.
231func backoff(attempt int) time.Duration {
232 const base = 5 * time.Millisecond
233 const cap = 250 * time.Millisecond
234 d := base << attempt
235 if d > cap || d <= 0 { // <=0 guards the shift overflowing at high attempts
236 d = cap
237 }
238 return time.Duration(rand.Int63n(int64(d) + 1))
239}
240
241// sleepCtx sleeps for d unless ctx is cancelled first.
242func sleepCtx(ctx context.Context, d time.Duration) error {
243 if d <= 0 {
244 return ctx.Err()
245 }
246 t := time.NewTimer(d)
247 defer t.Stop()
248 select {
249 case <-ctx.Done():
250 return ctx.Err()
251 case <-t.C:
252 return nil
253 }
254}
255
256// --- S3-backed counterStore --------------------------------------------------
257
258// s3Counter is the production counterStore: the high-water lives as a decimal
259// string in one S3 object, and CAS rides S3's ETag conditional writes β€” the same
260// mechanism home/s3store.go uses for its user docs, over the same aws-sdk-go-v2
261// client s3blobstore.go builds.
262type s3Counter struct {
263 client *awss3.Client
264 bucket string
265 key string
266}
267
268func (c *s3Counter) load(ctx context.Context) (uint64, string, error) {
269 ctx, cancel := context.WithTimeout(ctx, s3CallTimeout)
270 defer cancel()
271 out, err := c.client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &c.bucket, Key: &c.key})
272 if err != nil {
273 if isNotFound(err) {
274 return 0, "", nil // never allocated yet: value 0, empty (create-only) token
275 }
276 return 0, "", err
277 }
278 defer out.Body.Close()
279 // The body is a short decimal; cap the read so a corrupt/huge object can't
280 // balloon memory.
281 b, err := io.ReadAll(io.LimitReader(out.Body, 64))
282 if err != nil {
283 return 0, "", err
284 }
285 v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64)
286 if err != nil {
287 return 0, "", fmt.Errorf("idalloc: corrupt counter object %q: %w", strings.TrimSpace(string(b)), err)
288 }
289 token := ""
290 if out.ETag != nil {
291 token = *out.ETag // S3 ETags are quoted; kept verbatim for If-Match
292 }
293 return v, token, nil
294}
295
296func (c *s3Counter) swap(ctx context.Context, token string, newValue uint64) (bool, error) {
297 ctx, cancel := context.WithTimeout(ctx, s3CallTimeout)
298 defer cancel()
299 body := []byte(strconv.FormatUint(newValue, 10))
300 n := int64(len(body))
301 in := &awss3.PutObjectInput{
302 Bucket: &c.bucket,
303 Key: &c.key,
304 Body: bytes.NewReader(body),
305 ContentLength: &n,
306 }
307 if token == "" {
308 star := "*"
309 in.IfNoneMatch = &star // create-only: fails 412 if someone created it first
310 } else {
311 in.IfMatch = &token // replace-only: fails 412 if the object moved on
312 }
313 if _, err := c.client.PutObject(ctx, in); err != nil {
314 if isCASConflict(err) {
315 return false, nil
316 }
317 return false, err
318 }
319 return true, nil
320}
321
322// isNotFound folds the shapes a missing object answers with (mirrors
323// s3blobstore.go's s3NotFound β€” kept local so idalloc doesn't import package
324// main).
325func isNotFound(err error) bool {
326 var apiErr smithy.APIError
327 if errors.As(err, &apiErr) {
328 switch apiErr.ErrorCode() {
329 case "NoSuchKey", "NotFound", "404":
330 return true
331 }
332 }
333 return false
334}
335
336// isCASConflict reports whether a PutObject error is a lost conditional write:
337// 412 (If-Match / If-None-Match precondition failed) or 409 (S3's
338// ConditionalRequestConflict when two conditional writes race). Matched by both
339// the S3 error code and the raw HTTP status, so an S3-compatible store that only
340// sets one of them still classifies correctly.
341func isCASConflict(err error) bool {
342 var apiErr smithy.APIError
343 if errors.As(err, &apiErr) {
344 switch apiErr.ErrorCode() {
345 case "PreconditionFailed", "ConditionalRequestConflict":
346 return true
347 }
348 }
349 var httpErr interface{ HTTPStatusCode() int }
350 if errors.As(err, &httpErr) {
351 switch httpErr.HTTPStatusCode() {
352 case http.StatusPreconditionFailed, http.StatusConflict:
353 return true
354 }
355 }
356 return false
357}
358