Files
silo-server/internal/api/handlers/diagnostics_chunked.go
f235524365 feat(diagnostics): chunked report upload fallback for proxy body caps (#494)
* feat(diagnostics): chunked report upload fallback for proxy body caps

Diagnostics bundles can be up to max_bundle_bytes (10 MiB default), but a
reverse proxy in front of Silo commonly caps request bodies at nginx's
default client_max_body_size of 1 MiB. Such a proxy answers the single-shot
multipart upload with its own 413 before Silo ever sees the request, so any
report over the cap could never be delivered.

Add a chunked upload fallback under /api/v1/diagnostics/reports/uploads:

- POST   /                      {manifest, bundle_bytes} opens a session
- PUT    /{id}/chunks/{index}   streams one ≤768 KiB chunk (proxy-safe)
- POST   /{id}/complete         ingests the assembled bundle
- DELETE /{id}                  best-effort abandon

The assembled bundle goes through the exact same Ingest path as the
single-shot endpoint, so every content check (manifest contract, archive
sha/bytes/entries, quotas, profile attribution) applies identically.
Sessions reuse internal/uploads (the plugin chunked-upload spool manager)
plus a small owner map for per-user isolation; they spool to disk, expire
after 15 minutes, cap at one per user / 16 global, and complete shares the
existing per-user + global in-flight ingest limiter.

/diagnostics/status now advertises upload_chunk_bytes so clients can detect
support; older servers omit the field and clients treat that as
unsupported. The demo guard's diagnostics prefix gains PUT to cover the
chunk route.

Verified end to end against an OpenResty proxy with a 1m body cap: the
single-shot upload 413s, the same 1.6 MiB bundle uploads in three chunks
and lands as an accepted report; also exercised from the tvOS client's
fallback path in the simulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(diagnostics): harden chunked upload sessions per review

- Reserve the per-user slot and global cap atomically in init (a
  reservation map counted with live sessions), so concurrent inits by one
  account can no longer fan out past one session or transiently exceed the
  cap. Creation failures roll the reservation back.
- Move chunk body I/O outside the uploads.Manager mutex: a slow client
  streaming one chunk no longer serializes every other session's chunk
  writes, completes, and cancels. A per-chunk in-flight flag rejects
  duplicate concurrent writes to the same offset (ErrChunkBusy → 409), and
  cancel/expiry defer spool-directory removal to the last finishing
  writer.
- Chunk arrivals refresh the session expiry, making the TTL an idle
  timeout instead of an absolute deadline so a slow-but-progressing upload
  cannot expire mid-transfer.
- Extend the request read deadline on chunk PUTs and both deadlines on
  complete, matching the single-shot handler's slow-uplink handling.
- Keep the session when complete's availability re-check fails
  transiently (status load error → 500): only definitive
  disabled/storage-unavailable answers discard the spool, so a retried
  complete succeeds without re-uploading every chunk.
- Reclaim orphaned spool directories at startup (a restart previously
  stranded the old process's partial uploads forever) and sweep expired
  sessions on a timer instead of only from later init traffic.
- Document that session state is process-local and what that means for
  multi-replica deployments.

Adds concurrency/race tests (go test -race) for atomic admission,
same-chunk write exclusion, expiry refresh, transient-status retry, and
startup reclaim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(diagnostics): count detached chunk writers and lift chunk PUT write deadline

Second review round:

- A canceled session whose slow chunk writer was still draining held a
  connection and spool disk but vanished from every count, so a
  cancel-and-reinit loop could stack unbounded live writers behind the
  16-session cap. The uploads manager now parks such sessions in a
  detached set (exposed as DetachedWriterSessions) until their last
  writer returns, and diagnostics init counts them in its admission gate.
- Chunk PUTs now extend the write deadline as well as the read deadline:
  on an uplink slow enough to eat the server's 120s WriteTimeout, the
  stored chunk's JSON acknowledgement would otherwise be lost and the
  client would retry an already-accepted chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:07:37 -04:00

483 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/uploads"
)
// Chunked diagnostics upload: a fallback path for bundles that a reverse proxy
// in front of Silo refuses as a single request (nginx's default
// client_max_body_size is 1 MiB; /diagnostics/status advertises bundles up to
// 10 MiB). The client:
//
// 1. POST /diagnostics/reports/uploads {manifest, bundle_bytes}
// 2. PUT /diagnostics/reports/uploads/{id}/chunks/{index} raw bundle bytes, ≤ upload_chunk_bytes each
// 3. POST /diagnostics/reports/uploads/{id}/complete → same 201 as the single-shot upload
// DELETE /diagnostics/reports/uploads/{id} best-effort abandon
//
// The assembled bundle goes through the exact same Ingest path as the
// single-shot endpoint, so every content check (manifest contract, archive
// sha/bytes/entries, quotas, profile attribution) applies identically. The
// manifest is validated only for size at init; Ingest judges it at complete.
//
// Sessions spool to local disk and expire after diagnosticsChunkSessionTTL of
// inactivity (chunk arrivals refresh the deadline). They do NOT hold the
// shared in-flight ingest slot while chunks stream in — only complete
// acquires it, for the ingest itself — so a client that dies mid-upload
// leaves nothing behind but spool bytes the TTL sweep reclaims, and never
// blocks its user's later uploads. Concurrency is bounded by one session per
// user (a new init replaces the previous session) plus a global session cap,
// both reserved atomically in init.
//
// Session state (ownership map + spool files) is process-local. That is fine
// for the integrated single-process server this feature targets; a
// multi-replica API deployment would need sticky routing for the few requests
// of one chunked upload, or a chunk PUT landing on another replica answers
// 404 and the client restarts from init.
const (
// diagnosticsChunkSessionTTL bounds how long an in-progress chunked upload
// may sit idle before its spooled bytes are reclaimed. Generous enough for
// a slow uplink to push 10 MiB in 768 KiB chunks, small enough that
// abandoned sessions don't hold disk for long.
diagnosticsChunkSessionTTL = 15 * time.Minute
// diagnosticsChunkSessionCap bounds concurrent chunked sessions across all
// users, capping worst-case spool disk at cap × max_bundle_bytes
// (≈ 160 MiB at defaults).
diagnosticsChunkSessionCap = 16
)
type diagnosticsChunkInitRequest struct {
// Manifest is the same part-1 manifest the multipart endpoint takes,
// embedded verbatim. Deferring all content validation to Ingest keeps one
// authority for what a valid manifest is.
Manifest json.RawMessage `json:"manifest"`
// BundleBytes is the exact assembled bundle size the client will upload.
// Declared up front so over-limit uploads fail at init, before any chunk
// bytes are spent.
BundleBytes int64 `json:"bundle_bytes"`
}
type diagnosticsChunkInitResponse struct {
UploadID string `json:"upload_id"`
ChunkBytes int64 `json:"chunk_bytes"`
TotalChunks int `json:"total_chunks"`
ExpiresAt string `json:"expires_at"`
}
type diagnosticsChunkStateResponse struct {
ReceivedChunks int `json:"received_chunks"`
TotalChunks int `json:"total_chunks"`
}
// diagnosticsChunkSessions pairs the generic upload session manager with the
// per-session diagnostics state it does not track: the manifest captured at
// init and the owning user (sessions must not be readable or writable across
// accounts).
type diagnosticsChunkSessions struct {
manager *uploads.Manager
mu sync.Mutex
owners map[string]diagnosticsChunkOwner
byUser map[int]string
// reserved counts init calls that have claimed a cap slot but not yet
// registered their created session in owners. Counting reservations and
// registrations together makes the per-user + global-cap admission atomic:
// concurrent inits cannot each pass the checks before any of them
// registers.
reserved map[int]struct{}
}
type diagnosticsChunkOwner struct {
userID int
manifest []byte
}
func newDiagnosticsChunkSessions(spoolDir string) *diagnosticsChunkSessions {
sessions := &diagnosticsChunkSessions{
manager: uploads.NewManager(uploads.ManagerOptions{
RootDir: spoolDir,
TTL: diagnosticsChunkSessionTTL,
MaxChunkSize: diagnostics.UploadChunkBytes,
// MaxSize is enforced per-init against the live max_bundle_bytes
// setting; the manager-level bound is just a hard backstop.
MaxSize: 256 << 20,
}),
owners: make(map[string]diagnosticsChunkOwner),
byUser: make(map[int]string),
reserved: make(map[int]struct{}),
}
// A restart leaves the previous process's spool directories on disk with
// no session map entry to ever expire them; reclaim them now.
sessions.manager.ReclaimOrphanedDirs()
return sessions
}
// startSweeper begins the timed expiry sweep. Split from the constructor so
// tests can run without background goroutines.
func (s *diagnosticsChunkSessions) startSweeper(stop <-chan struct{}) {
s.manager.StartExpirySweeper(time.Minute, stop)
}
// owner returns the session owner entry when id belongs to userID.
func (s *diagnosticsChunkSessions) owner(id string, userID int) (diagnosticsChunkOwner, bool) {
s.mu.Lock()
defer s.mu.Unlock()
owner, ok := s.owners[id]
if !ok || owner.userID != userID {
return diagnosticsChunkOwner{}, false
}
return owner, true
}
// reserve atomically claims the user's session slot and a global cap slot,
// evicting the user's own previous session first (its id is returned for the
// caller to cancel outside the lock — the caller cancels it whether or not
// admission succeeds, since the eviction already happened). A false capOK
// means the cap is full, or a concurrent init by the same user holds an
// unfinished reservation — that racing call must not create a second session.
//
// The cap counts live sessions, unfinished reservations, AND the manager's
// detached-writer sessions: a canceled session whose slow chunk writer is
// still draining holds real disk and a connection until the writer's read
// deadline, so a cancel-and-reinit loop must stall at the cap rather than
// stack unbounded live writers behind it.
func (s *diagnosticsChunkSessions) reserve(userID int) (previousID string, capOK bool) {
detached := s.manager.DetachedWriterSessions()
s.mu.Lock()
defer s.mu.Unlock()
if _, inFlight := s.reserved[userID]; inFlight {
return "", false
}
if id, ok := s.byUser[userID]; ok {
previousID = id
delete(s.owners, id)
delete(s.byUser, userID)
}
if len(s.owners)+len(s.reserved)+detached >= diagnosticsChunkSessionCap {
return previousID, false
}
s.reserved[userID] = struct{}{}
return previousID, true
}
// commit registers the created session under an existing reservation.
func (s *diagnosticsChunkSessions) commit(userID int, id string, manifest []byte) {
s.mu.Lock()
delete(s.reserved, userID)
s.owners[id] = diagnosticsChunkOwner{userID: userID, manifest: manifest}
s.byUser[userID] = id
s.mu.Unlock()
}
// unreserve rolls back a reservation whose session creation failed.
func (s *diagnosticsChunkSessions) unreserve(userID int) {
s.mu.Lock()
delete(s.reserved, userID)
s.mu.Unlock()
}
// drop removes the owner entry. Idempotent; the manager session is the
// caller's to cancel.
func (s *diagnosticsChunkSessions) drop(id string) {
s.mu.Lock()
owner, ok := s.owners[id]
delete(s.owners, id)
if ok && s.byUser[owner.userID] == id {
delete(s.byUser, owner.userID)
}
s.mu.Unlock()
}
// sweepExpired drops owner entries whose manager session has expired or is
// gone, so the owner map (and the cap headroom it counts against) doesn't
// leak alongside the manager's own spool cleanup. Called from init, the only
// entry point that needs the freed headroom.
func (s *diagnosticsChunkSessions) sweepExpired() {
s.mu.Lock()
stale := make([]string, 0, len(s.owners))
for id := range s.owners {
if _, err := s.manager.Peek(id); errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
stale = append(stale, id)
}
}
s.mu.Unlock()
for _, id := range stale {
s.drop(id)
}
}
// HandleChunkedUploadInit handles POST /diagnostics/reports/uploads.
func (h *DiagnosticsHandler) HandleChunkedUploadInit(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
status, ok := h.diagnosticsUploadStatus(w, r, userID)
if !ok {
return
}
maxBundleBytes := status.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = diagnostics.DefaultMaxBundleBytes
}
// Manifest cap plus a small envelope allowance keeps init requests tiny —
// they must themselves fit under restrictive proxy body caps.
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.MaxManifestBytes+diagnosticsMultipartOverheadBytes)
var req diagnosticsChunkInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
writeError(w, http.StatusBadRequest, "invalid_bundle", "Invalid diagnostics upload init")
return
}
if len(req.Manifest) == 0 || int64(len(req.Manifest)) > diagnostics.MaxManifestBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
if req.BundleBytes <= 0 {
writeError(w, http.StatusBadRequest, "invalid_bundle", "bundle_bytes must be positive")
return
}
if req.BundleBytes > maxBundleBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics upload is too large")
return
}
h.chunkSessions.sweepExpired()
// Atomically evict the user's previous session (a new init replaces it,
// so a client that died mid-upload can start over immediately) and claim
// a cap slot. Cancel the evicted spool outside the lock either way.
previousID, capOK := h.chunkSessions.reserve(userID)
if previousID != "" {
h.abortChunkedUpload(previousID)
}
if !capOK {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
session, err := h.chunkSessions.manager.Create(uploads.CreateRequest{
Filename: "bundle.tar.gz",
SizeBytes: req.BundleBytes,
ChunkSize: diagnostics.UploadChunkBytes,
})
if err != nil {
h.chunkSessions.unreserve(userID)
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
h.chunkSessions.commit(userID, session.ID, append([]byte(nil), req.Manifest...))
writeJSON(w, http.StatusCreated, diagnosticsChunkInitResponse{
UploadID: session.ID,
ChunkBytes: session.ChunkSize,
TotalChunks: session.TotalChunks,
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
})
}
// HandleChunkedUploadChunk handles PUT /diagnostics/reports/uploads/{upload_id}/chunks/{chunk_index}.
func (h *DiagnosticsHandler) HandleChunkedUploadChunk(w http.ResponseWriter, r *http.Request) {
// The integrated server's 30s ReadTimeout kills a 768 KiB chunk arriving
// below ~26 KiB/s — exactly the slow uplinks the chunked fallback exists
// for. The write deadline needs lifting too: the 120s WriteTimeout starts
// at request start, so on a sufficiently slow uplink the stored chunk's
// JSON acknowledgement would miss it and the client would retry an
// already-accepted chunk.
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
chunkIndex, err := strconv.Atoi(chi.URLParam(r, "chunk_index"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid chunk index")
return
}
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.UploadChunkBytes+1)
defer r.Body.Close()
session, err := h.chunkSessions.manager.PutChunk(r.Context(), uploadID, chunkIndex, r.Body, r.ContentLength)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
writeJSON(w, http.StatusOK, diagnosticsChunkStateResponse{
ReceivedChunks: session.ReceivedChunks,
TotalChunks: session.TotalChunks,
})
}
// HandleChunkedUploadComplete handles POST /diagnostics/reports/uploads/{upload_id}/complete.
func (h *DiagnosticsHandler) HandleChunkedUploadComplete(w http.ResponseWriter, r *http.Request) {
// Ingest + object-store write can outlast the server's 120s WriteTimeout;
// without the extension the stored report's 201 would be lost and the
// client would retry an upload that already succeeded (see the single-shot
// handler).
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
owner, ok := h.chunkSessions.owner(uploadID, userID)
if !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
// Availability can have changed since init (admin toggle, storage loss);
// re-check so a completed spool is not ingested into a disabled feature.
// Only a definitive non-available answer discards the session — a
// transient status load failure (500) keeps it so a retried complete can
// succeed without re-uploading every chunk.
status, statusErr := h.service.Status(r.Context(), userID)
if statusErr != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return
}
if status.Status != diagnostics.StatusAvailable {
h.abortChunkedUpload(uploadID)
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
}
return
}
// The ingest itself shares capacity with single-shot uploads. Check
// before consuming the manager session so a busy answer leaves the
// session intact for a client-side retry of complete.
release, acquired := h.inflight.acquire(userID)
if !acquired {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
defer release()
upload, err := h.chunkSessions.manager.Complete(uploadID)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
// The manager session is consumed; a failed ingest is retried by the
// client from a fresh init, never by re-completing a spent session.
defer h.chunkSessions.drop(uploadID)
defer upload.Cleanup()
bundle, err := os.Open(upload.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics upload failed")
return
}
defer bundle.Close()
profileID := strings.TrimSpace(r.Header.Get("X-Profile-Id"))
var profileIDPtr *string
if profileID != "" {
profileIDPtr = &profileID
}
result, err := h.service.Ingest(r.Context(), userID, profileIDPtr, owner.manifest, io.Reader(bundle))
if err != nil {
writeDiagnosticsServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, result)
}
// HandleChunkedUploadAbort handles DELETE /diagnostics/reports/uploads/{upload_id}.
func (h *DiagnosticsHandler) HandleChunkedUploadAbort(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
// Unknown or foreign session: abort is a best-effort courtesy, and
// idempotent success leaks nothing about other users' session ids.
w.WriteHeader(http.StatusNoContent)
return
}
h.abortChunkedUpload(uploadID)
w.WriteHeader(http.StatusNoContent)
}
func (h *DiagnosticsHandler) abortChunkedUpload(uploadID string) {
if err := h.chunkSessions.manager.Cancel(uploadID); err != nil && !errors.Is(err, uploads.ErrNotFound) {
h.diagnosticsLogger().Warn("diagnostics chunked upload cancel failed",
"component", "diagnostics",
"error", err,
)
}
h.chunkSessions.drop(uploadID)
}
// diagnosticsUploadStatus loads the feature status and writes the
// disabled/storage-unavailable rejection when uploads cannot proceed,
// mirroring the single-shot endpoint's gate.
func (h *DiagnosticsHandler) diagnosticsUploadStatus(w http.ResponseWriter, r *http.Request, userID int) (diagnostics.Status, bool) {
status, err := h.service.Status(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return diagnostics.Status{}, false
}
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
return diagnostics.Status{}, false
case diagnostics.StatusStorageUnavailable:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
return diagnostics.Status{}, false
case diagnostics.StatusAvailable:
return status, true
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
return diagnostics.Status{}, false
}
}