Count interrupted misses and the upstream bytes they read (closes #56)

The miss and transform counters are written with context.WithoutCancel,
so a client disconnect or the request timeout during or after the work
no longer loses them. A failed read of the upstream body now returns
the bytes read before the error, so an over-size or cut-off body still
moves the upstream fetch counters. processFromSourceOrFetch passes the
cached source's length directly instead of through a local named
fetchBytes.

Model: opus-5-5
This commit is contained in:
2026-09-29 02:46:30 +00:00
parent 64108c05bb
commit 614bcd9eb6
2 changed files with 19 additions and 19 deletions
+5 -4
View File
@@ -47,10 +47,11 @@ exhaustion
plus `variant_content`) and takes their size from `Cache.UsageBytes`, plus `variant_content`) and takes their size from `Cache.UsageBytes`,
instead of reading `request_cache` and `output_content`, which nothing instead of reading `request_cache` and `output_content`, which nothing
writes; those two tables are left in the schema. A miss is counted after writes; those two tables are left in the schema. A miss is counted after
it is served or fails, with the bytes it fetched from upstream, so it is served or fails, even when the request context has ended by then,
`upstream_fetch_count` and `upstream_fetch_bytes` move, including for a with the bytes it read from upstream, so `upstream_fetch_count` and
fetched source that then fails the magic byte check; `transform_count` `upstream_fetch_bytes` move, including for an upstream body that fails
counts each image the image processor transcodes. partway or a fetched source that then fails the magic byte check;
`transform_count` counts each image the image processor transcodes.
- 2026-09-28 strip metadata from processed images (closes #82): every output is - 2026-09-28 strip metadata from processed images (closes #82): every output is
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
profile; the image is first turned upright with `AutoRotate` (before sizes are profile; the image is first turned upright with `AutoRotate` (before sizes are
+14 -15
View File
@@ -156,12 +156,13 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
} }
// Cache miss - process the cached source or fetch it, then count the // Cache miss - process the cached source or fetch it, then count the
// miss with the bytes it fetched from upstream, also when it failed // miss with the bytes it fetched from upstream, also when it failed or
// the request context has ended meanwhile
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey) response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
s.cache.IncrementStats(ctx, false, fetchedBytes) s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -283,10 +284,7 @@ func (s *Service) processFromSourceOrFetch(
s.log.Warn("source lookup failed", "error", err) s.log.Warn("source lookup failed", "error", err)
} }
var ( var sourceData []byte
sourceData []byte
fetchBytes int64
)
if contentHash != "" { if contentHash != "" {
s.log.Debug("using cached source", "hash", contentHash) s.log.Debug("using cached source", "hash", contentHash)
@@ -299,16 +297,16 @@ func (s *Service) processFromSourceOrFetch(
} }
// Process using cached source; nothing was fetched from upstream // Process using cached source; nothing was fetched from upstream
fetchBytes = int64(len(sourceData)) resp, err := s.processAndStore(
ctx, req, cacheKey, sourceData, int64(len(sourceData)),
resp, err := s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes) )
return resp, 0, err return resp, 0, err
} }
// fetchAndProcess fetches from upstream, processes, and caches the result. // fetchAndProcess fetches from upstream, processes, and caches the result.
// It also returns the number of bytes fetched from upstream once the // It also returns the number of bytes read from upstream, including when
// response has been read, including when a later step fails. // reading the response or a later step fails.
func (s *Service) fetchAndProcess( func (s *Service) fetchAndProcess(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
@@ -339,13 +337,13 @@ func (s *Service) fetchAndProcess(
// Read and validate the source content // Read and validate the source content
sourceData, err := io.ReadAll(fetchResult.Content) sourceData, err := io.ReadAll(fetchResult.Content)
fetchBytes := int64(len(sourceData))
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("failed to read upstream response: %w", err) return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err)
} }
// Calculate download bitrate // Calculate download bitrate
fetchBytes := int64(len(sourceData))
var downloadRate string var downloadRate string
if fetchResult.FetchDurationMs > 0 { if fetchResult.FetchDurationMs > 0 {
@@ -410,7 +408,8 @@ func (s *Service) processAndStore(
processDuration := time.Since(processStart) processDuration := time.Since(processStart)
s.cache.IncrementTransformCount(ctx) // Counted also when the request context has ended meanwhile
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
// Read processed content // Read processed content
processedData, err := io.ReadAll(processResult.Content) processedData, err := io.ReadAll(processResult.Content)