1
0
Fork 0
mirror of https://github.com/imjasonh/gcsbuf synced 2026-07-08 09:05:20 +00:00
gcsbuf/writer.go
Jason Hall 5292972f87 first agent crack
Signed-off-by: Jason Hall <imjasonh@gmail.com>
2026-03-24 12:18:58 -04:00

255 lines
5.5 KiB
Go

package buf
import (
"bytes"
"context"
"fmt"
"sync"
"time"
"cloud.google.com/go/storage"
)
const (
maxComponentCount = 32
defaultFlushInterval = 5 * time.Second
defaultFlushSize = 4 * 1024 * 1024 // 4MB
)
var now = time.Now // var for testing
// WriterOptions configures a Writer.
type WriterOptions struct {
// Client is the GCS client used for all operations.
Client *storage.Client
// Bucket is the GCS bucket name.
Bucket string
// Object is the GCS object name.
Object string
// FlushInterval is how often the background goroutine flushes buffered data. Default 5s.
FlushInterval time.Duration
// FlushSize is the buffer size threshold that triggers a synchronous flush on Write. Default 4MB.
FlushSize int
}
type Writer struct {
ctx context.Context
client *storage.Client
bucket string
object string
flushInterval time.Duration
flushSize int
mu sync.Mutex
buf *bytes.Buffer
created bool // whether the GCS object exists yet
closed bool
err error
stopCh chan struct{}
doneCh chan struct{}
}
func NewWriter(ctx context.Context, opts WriterOptions) *Writer {
interval := opts.FlushInterval
if interval == 0 {
interval = defaultFlushInterval
}
size := opts.FlushSize
if size == 0 {
size = defaultFlushSize
}
w := &Writer{
ctx: ctx,
client: opts.Client,
bucket: opts.Bucket,
object: opts.Object,
flushInterval: interval,
flushSize: size,
buf: bytes.NewBuffer(nil),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
go w.backgroundFlush()
return w
}
func (w *Writer) backgroundFlush() {
defer close(w.doneCh)
ticker := time.NewTicker(w.flushInterval)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
w.mu.Lock()
if w.buf.Len() > 0 {
w.flushLocked()
}
w.mu.Unlock()
}
}
}
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, fmt.Errorf("write to closed writer")
}
if w.err != nil {
return 0, w.err
}
n, err := w.buf.Write(p)
if err != nil {
return n, err
}
if w.buf.Len() >= w.flushSize {
w.flushLocked()
}
if w.err != nil {
return 0, w.err
}
return n, nil
}
// Flush flushes any buffered data to GCS.
func (w *Writer) Flush() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.buf.Len() > 0 {
w.flushLocked()
}
return w.err
}
// flushLocked writes buffered data to GCS. Must be called with w.mu held.
func (w *Writer) flushLocked() {
data := w.buf.Bytes()
if len(data) == 0 {
return
}
bh := w.client.Bucket(w.bucket)
objh := bh.Object(w.object)
if !w.created {
// First write: create the object directly.
gw := objh.NewWriter(w.ctx)
if _, err := gw.Write(data); err != nil {
w.err = fmt.Errorf("writing initial object: %w", err)
return
}
if err := gw.Close(); err != nil {
w.err = fmt.Errorf("closing initial object writer: %w", err)
return
}
w.created = true
w.buf.Reset()
return
}
// Subsequent writes: write to temp, compose, delete temp.
tmpname := fmt.Sprintf("%s-temp-%d", w.object, now().UnixNano())
tmpobj := bh.Object(tmpname)
gw := tmpobj.NewWriter(w.ctx)
if _, err := gw.Write(data); err != nil {
w.err = fmt.Errorf("writing temp object: %w", err)
return
}
if err := gw.Close(); err != nil {
w.err = fmt.Errorf("closing temp object writer: %w", err)
return
}
// Check component count before composing.
attrs, err := objh.Attrs(w.ctx)
if err != nil {
w.err = fmt.Errorf("getting object attrs: %w", err)
return
}
if attrs.ComponentCount >= maxComponentCount-1 {
// Reset component count: copy to temp2, copy back, delete temp2.
if err := w.resetComponentCount(bh, objh); err != nil {
w.err = fmt.Errorf("resetting component count: %w", err)
return
}
}
// Compose existing + temp into existing.
if _, err := objh.ComposerFrom(objh, tmpobj).Run(w.ctx); err != nil {
w.err = fmt.Errorf("composing objects: %w", err)
return
}
// Delete temp object.
if err := tmpobj.Delete(w.ctx); err != nil {
w.err = fmt.Errorf("deleting temp object: %w", err)
return
}
w.buf.Reset()
}
// resetComponentCount copies the object to a temp location and back to reset the component count.
func (w *Writer) resetComponentCount(bh *storage.BucketHandle, objh *storage.ObjectHandle) error {
tmpname := fmt.Sprintf("%s-reset-%d", w.object, now().UnixNano())
tmpobj := bh.Object(tmpname)
// Copy to temp (this creates a non-composite object).
if _, err := tmpobj.CopierFrom(objh).Run(w.ctx); err != nil {
return fmt.Errorf("copying to reset temp: %w", err)
}
// Copy back.
if _, err := objh.CopierFrom(tmpobj).Run(w.ctx); err != nil {
return fmt.Errorf("copying back from reset temp: %w", err)
}
// Delete the reset temp.
if err := tmpobj.Delete(w.ctx); err != nil {
return fmt.Errorf("deleting reset temp: %w", err)
}
return nil
}
func (w *Writer) Close() error {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return fmt.Errorf("writer already closed")
}
w.closed = true
w.mu.Unlock()
// Stop background goroutine and wait for it.
close(w.stopCh)
<-w.doneCh
// Final flush.
w.mu.Lock()
if w.buf.Len() > 0 {
w.flushLocked()
}
err := w.err
w.mu.Unlock()
if err != nil {
return err
}
// Set done metadata. Only if the object was created.
if w.created {
objh := w.client.Bucket(w.bucket).Object(w.object)
if _, err := objh.Update(w.ctx, storage.ObjectAttrsToUpdate{
Metadata: map[string]string{"done": "true"},
}); err != nil {
return fmt.Errorf("setting done metadata: %w", err)
}
}
return nil
}