eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– s3BlobStore β€” blobStore implementation #2 (#550 Phase 1): attachment
4// bytes in an S3-compatible object store (Cloudflare R2 is the intended
5// target β€” zero egress; see docs/tier-economics.md). Enabled per instance by
6// LCHAT_BLOB_S3_BUCKET in its env; unset keeps today's on-disk layout, so
7// self-hosters and the fleet flip independently, instance by instance.
8//
9// The store carries the same E2EE posture as disk: bytes are client-side
10// ciphertext, keys/filenames/types never reach here, and object keys are the
11// server-minted blob ids β€” the object store learns exactly what the local
12// disk knew, nothing more.
13//
14// Shape decisions, in the order they matter:
15//
16// - WRAPS the disk store as a fallback, never replaces it: blobs uploaded
17// before the flip live on disk, and open/remove consult S3 first, then
18// disk. New writes always land in S3, so the disk set only shrinks β€”
19// the migration sweep (#550 Phase 2) drains it and the fallback then
20// never fires. Marker-free by construction.
21// - Staged writes SPOOL TO DISK (the same temp-in-blobs-dir shape as the
22// disk writer) and upload on commit. S3 has no rename, but a completed
23// PUT is atomic β€” an object is fetchable only in full β€” so "invisible
24// until commit" holds with no staging keys to garbage-collect. The spool
25// also gives the PUT a known ContentLength (R2 requires one).
26// - Reads are CHUNKED RANGED GETs behind an io.ReadSeekCloser:
27// http.ServeContent seeks (size probe, Range requests β€” video scrubs)
28// and then reads in 32KB slices; fetching per slice would be an S3
29// request per 32KB. The reader fetches aligned 512KB windows instead β€”
30// ~2 requests/MB served, and a scrub costs one window, not a full
31// download. (#836's lesson: request count is the S3 bill, watch it.)
32//
33// Config (instance env):
34//
35// LCHAT_BLOB_S3_BUCKET the switch β€” set = S3 store, unset = disk
36// LCHAT_BLOB_S3_PREFIX key prefix; default = the DB's basename (the
37// host), so a shared fleet bucket needs no
38// per-instance wiring
39// LCHAT_BLOB_S3_REGION optional ("auto" for R2)
40// LCHAT_BLOB_S3_ENDPOINT optional custom endpoint (R2's
41// https://<account>.r2.cloudflarestorage.com);
42// implies path-style addressing
43//
44// Credentials ride the standard AWS chain (AWS_ACCESS_KEY_ID /
45// AWS_SECRET_ACCESS_KEY in the instance env file β€” R2 issues S3-compatible
46// pairs).
47
48import (
49 "context"
50 "errors"
51 "fmt"
52 "io"
53 "io/fs"
54 "os"
55 "path"
56 "strings"
57 "time"
58
59 "github.com/aws/aws-sdk-go-v2/aws"
60 awsconfig "github.com/aws/aws-sdk-go-v2/config"
61 "github.com/aws/aws-sdk-go-v2/credentials"
62 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
63 "github.com/aws/smithy-go"
64)
65
66// s3ReadChunk is the ranged-GET window. 512KB β‰ˆ 2 requests per MB served;
67// small enough that a video scrub's first paint isn't waiting on a huge
68// window, large enough that request count stays a rounding error on the bill.
69const s3ReadChunk = 512 << 10
70
71// s3BlobTimeout bounds each individual S3 call (not a whole serve β€” a member
72// streaming a long video holds the reader open far longer than any one GET).
73const s3BlobTimeout = 60 * time.Second
74
75type s3BlobStore struct {
76 client *awss3.Client
77 bucket string
78 prefix string
79 // fallback serves blobs the remote lacks (pre-flip disk blobs for the
80 // primary store). Nil for a mirror store β€” a miss there is just a miss.
81 fallback blobStore
82 // spoolDir stages in-flight uploads β€” the disk store's dir, so the
83 // temp-name convention ("." never appears in a valid blob id) keeps
84 // staged bytes unfetchable exactly as before.
85 spoolDir string
86}
87
88// s3BlobConfig reads the env switch. Nil config = feature off (disk store).
89type s3BlobConfig struct {
90 Bucket string
91 Prefix string
92 Region string
93 Endpoint string
94 // Static credentials for THIS store β€” primary and mirror are different
95 // providers with different key pairs, and the AWS default chain can only
96 // hold one. Empty = the default chain (the primary's usual shape).
97 AccessKey string
98 SecretKey string
99}
100
101// s3BlobEnv reads one store's env quartet (+ key pair) under a prefix:
102// LCHAT_BLOB_S3_* for the primary, LCHAT_BLOB_S3_MIRROR_* for the mirror.
103func s3BlobEnv(envPrefix string) *s3BlobConfig {
104 bucket := strings.TrimSpace(os.Getenv(envPrefix + "BUCKET"))
105 if bucket == "" {
106 return nil
107 }
108 return &s3BlobConfig{
109 Bucket: bucket,
110 Prefix: strings.Trim(strings.TrimSpace(os.Getenv(envPrefix+"PREFIX")), "/"),
111 Region: strings.TrimSpace(os.Getenv(envPrefix + "REGION")),
112 Endpoint: strings.TrimSpace(os.Getenv(envPrefix + "ENDPOINT")),
113 AccessKey: strings.TrimSpace(os.Getenv(envPrefix + "ACCESS_KEY")),
114 SecretKey: strings.TrimSpace(os.Getenv(envPrefix + "SECRET_KEY")),
115 }
116}
117
118func s3BlobConfigFromEnv() *s3BlobConfig { return s3BlobEnv("LCHAT_BLOB_S3_") }
119func s3BlobMirrorConfigFromEnv() *s3BlobConfig { return s3BlobEnv("LCHAT_BLOB_S3_MIRROR_") }
120
121// newS3BlobStore builds one remote store. fallback may be nil (a mirror);
122// spoolDir stages writes; defaultPrefix is the DB basename β€” used when the
123// env names none.
124func newS3BlobStore(cfg *s3BlobConfig, spoolDir string, fallback blobStore, defaultPrefix string) (*s3BlobStore, error) {
125 var opts []func(*awsconfig.LoadOptions) error
126 if cfg.Region != "" {
127 opts = append(opts, awsconfig.WithRegion(cfg.Region))
128 }
129 if cfg.AccessKey != "" {
130 opts = append(opts, awsconfig.WithCredentialsProvider(
131 credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")))
132 }
133 awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
134 if err != nil {
135 return nil, fmt.Errorf("blob s3: load aws config: %w", err)
136 }
137 client := awss3.NewFromConfig(awsCfg, func(o *awss3.Options) {
138 if cfg.Endpoint != "" {
139 o.BaseEndpoint = &cfg.Endpoint
140 // Custom endpoints (R2, test fakes) address by path, not vhost.
141 o.UsePathStyle = true
142 }
143 // The SDK's default-on flexible checksums wrap PutObject bodies in
144 // aws-chunked framing, which R2 rejects; when-required sends plain
145 // bodies (ContentLength is always known β€” the spool measured it).
146 o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
147 o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
148 })
149 prefix := cfg.Prefix
150 if prefix == "" {
151 prefix = defaultPrefix
152 }
153 return &s3BlobStore{client: client, bucket: cfg.Bucket, prefix: prefix,
154 fallback: fallback, spoolDir: spoolDir}, nil
155}
156
157func (s *s3BlobStore) key(id string) string { return path.Join(s.prefix, id) }
158
159// s3NotFound folds the three shapes a missing object answers with (NoSuchKey
160// on GET, NotFound on HEAD, and bare 404s from S3-compatibles) into one test.
161func s3NotFound(err error) bool {
162 var apiErr smithy.APIError
163 if errors.As(err, &apiErr) {
164 switch apiErr.ErrorCode() {
165 case "NoSuchKey", "NotFound", "404":
166 return true
167 }
168 }
169 return false
170}
171
172func (s *s3BlobStore) create() (blobWriter, error) {
173 f, err := os.CreateTemp(s.spoolDir, "up.*.part")
174 if err != nil {
175 return nil, err
176 }
177 return &s3BlobWriter{f: f, store: s}, nil
178}
179
180func (s *s3BlobStore) open(id string) (io.ReadSeekCloser, int64, time.Time, error) {
181 ctx, cancel := context.WithTimeout(context.Background(), s3BlobTimeout)
182 defer cancel()
183 key := s.key(id)
184 head, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: &s.bucket, Key: &key})
185 if err != nil {
186 if s3NotFound(err) {
187 if s.fallback != nil {
188 // Pre-flip blob (or genuinely absent β€” the fallback answers
189 // with fs.ErrNotExist either way, which is the contract).
190 return s.fallback.open(id)
191 }
192 return nil, 0, time.Time{}, fs.ErrNotExist
193 }
194 return nil, 0, time.Time{}, err
195 }
196 size := int64(0)
197 if head.ContentLength != nil {
198 size = *head.ContentLength
199 }
200 mod := time.Time{}
201 if head.LastModified != nil {
202 mod = *head.LastModified
203 }
204 return &s3Reader{store: s, key: key, size: size}, size, mod, nil
205}
206
207func (s *s3BlobStore) remove(id string) error {
208 ctx, cancel := context.WithTimeout(context.Background(), s3BlobTimeout)
209 defer cancel()
210 key := s.key(id)
211 if _, err := s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: &s.bucket, Key: &key}); err != nil && !s3NotFound(err) {
212 return err
213 }
214 // A pre-flip blob's bytes live on disk β€” GC must reap those too. The
215 // fallback treats a missing file as success, so this is free for S3-era ids.
216 if s.fallback != nil {
217 return s.fallback.remove(id)
218 }
219 return nil
220}
221
222type s3BlobWriter struct {
223 f *os.File
224 store *s3BlobStore
225}
226
227func (w *s3BlobWriter) Write(p []byte) (int, error) { return w.f.Write(p) }
228
229func (w *s3BlobWriter) commit(id string) error {
230 defer os.Remove(w.f.Name())
231 size, err := w.f.Seek(0, io.SeekEnd)
232 if err != nil {
233 w.f.Close()
234 return err
235 }
236 if _, err := w.f.Seek(0, io.SeekStart); err != nil {
237 w.f.Close()
238 return err
239 }
240 ctx, cancel := context.WithTimeout(context.Background(), s3BlobTimeout)
241 defer cancel()
242 key := w.store.key(id)
243 _, err = w.store.client.PutObject(ctx, &awss3.PutObjectInput{
244 Bucket: &w.store.bucket,
245 Key: &key,
246 Body: w.f,
247 ContentLength: &size,
248 })
249 w.f.Close()
250 return err
251}
252
253func (w *s3BlobWriter) abort() {
254 w.f.Close()
255 os.Remove(w.f.Name())
256}
257
258// s3Reader is a lazy io.ReadSeekCloser over one object: seeks are position
259// math (http.ServeContent's size probe costs nothing), reads fetch aligned
260// s3ReadChunk windows on demand.
261type s3Reader struct {
262 store *s3BlobStore
263 key string
264 size int64
265 pos int64
266 // The current window: buf holds object bytes [bufStart, bufStart+len(buf)).
267 buf []byte
268 bufStart int64
269}
270
271func (r *s3Reader) Read(p []byte) (int, error) {
272 if r.pos >= r.size {
273 return 0, io.EOF
274 }
275 // Miss (or first read): fetch the aligned window containing pos.
276 if r.buf == nil || r.pos < r.bufStart || r.pos >= r.bufStart+int64(len(r.buf)) {
277 start := (r.pos / s3ReadChunk) * s3ReadChunk
278 end := start + s3ReadChunk - 1 // Range is inclusive
279 if end >= r.size {
280 end = r.size - 1
281 }
282 ctx, cancel := context.WithTimeout(context.Background(), s3BlobTimeout)
283 rng := fmt.Sprintf("bytes=%d-%d", start, end)
284 out, err := r.store.client.GetObject(ctx, &awss3.GetObjectInput{
285 Bucket: &r.store.bucket, Key: &r.key, Range: &rng,
286 })
287 if err != nil {
288 cancel()
289 if s3NotFound(err) {
290 // Deleted mid-serve (GC race) β€” surface as the fs error the
291 // handler already maps to 404.
292 return 0, fs.ErrNotExist
293 }
294 return 0, err
295 }
296 buf, err := io.ReadAll(out.Body)
297 out.Body.Close()
298 cancel()
299 if err != nil {
300 return 0, err
301 }
302 r.buf, r.bufStart = buf, start
303 }
304 n := copy(p, r.buf[r.pos-r.bufStart:])
305 r.pos += int64(n)
306 return n, nil
307}
308
309func (r *s3Reader) Seek(offset int64, whence int) (int64, error) {
310 var next int64
311 switch whence {
312 case io.SeekStart:
313 next = offset
314 case io.SeekCurrent:
315 next = r.pos + offset
316 case io.SeekEnd:
317 next = r.size + offset
318 default:
319 return 0, fmt.Errorf("blob s3: bad whence %d", whence)
320 }
321 if next < 0 {
322 return 0, fmt.Errorf("blob s3: negative seek")
323 }
324 r.pos = next
325 return next, nil
326}
327
328func (r *s3Reader) Close() error {
329 r.buf = nil
330 return nil
331}
332