Skip to content

Commit 8ed3844

Browse files
committed
fix(cache): keep active uploads alive during cleanup
1 parent 0d2214c commit 8ed3844

9 files changed

Lines changed: 782 additions & 123 deletions

File tree

internal/cache/service.go

Lines changed: 136 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@ import (
2828
"github.com/MxOrbit/GitHubActionCacheServer/internal/storage"
2929
"github.com/MxOrbit/GitHubActionCacheServer/internal/storagelifecycle"
3030
"github.com/MxOrbit/GitHubActionCacheServer/internal/storageoutbox"
31+
"github.com/MxOrbit/GitHubActionCacheServer/internal/uploadsession"
3132
"github.com/google/uuid"
3233
"github.com/rs/zerolog"
3334
)
3435

3536
const (
3637
directDownloadTTL = storagelifecycle.DirectDownloadLeaseDuration
3738
lastDownloadedAtUpdateInterval = 10 * time.Minute
38-
abandonedUploadLifetime = 24 * time.Hour
39+
uploadHeartbeatInterval = 30 * time.Second
3940
mergeCleanupTimeout = 10 * time.Second
4041
maxDanglingPurgeAttempts = 10
42+
maxUploadContentionAttempts = 5
4143
)
4244

4345
// MaxBlockListEntries is the Azure block blob protocol limit.
@@ -76,20 +78,24 @@ type Service struct {
7678
acceptingMerges bool
7779
capacity CapacityTrigger
7880
readerLeaseReleaser *readerLeaseReleaser
81+
now func() time.Time
82+
uploadHeartbeat time.Duration
7983
}
8084

8185
type CapacityTrigger interface {
8286
Trigger()
8387
}
8488

8589
type Options struct {
86-
DB *ent.Client
87-
Storage storage.Adapter
88-
EnableDirectDownloads bool
89-
MergeConcurrency int
90-
Lifecycle *storagelifecycle.Service
91-
Logger *zerolog.Logger
92-
Capacity CapacityTrigger
90+
DB *ent.Client
91+
Storage storage.Adapter
92+
EnableDirectDownloads bool
93+
MergeConcurrency int
94+
Lifecycle *storagelifecycle.Service
95+
Logger *zerolog.Logger
96+
Capacity CapacityTrigger
97+
Now func() time.Time
98+
UploadHeartbeatInterval time.Duration
9399
}
94100

95101
type CreateUploadResult struct {
@@ -149,6 +155,14 @@ func NewService(options Options) *Service {
149155
if options.Logger != nil {
150156
logger = *options.Logger
151157
}
158+
now := options.Now
159+
if now == nil {
160+
now = time.Now
161+
}
162+
heartbeatInterval := options.UploadHeartbeatInterval
163+
if heartbeatInterval <= 0 {
164+
heartbeatInterval = uploadHeartbeatInterval
165+
}
152166

153167
service := &Service{
154168
db: options.DB,
@@ -162,6 +176,8 @@ func NewService(options Options) *Service {
162176
mergeSlots: make(chan struct{}, mergeConcurrency),
163177
acceptingMerges: true,
164178
capacity: options.Capacity,
179+
now: now,
180+
uploadHeartbeat: heartbeatInterval,
165181
}
166182
service.readerLeaseReleaser = newReaderLeaseReleaser(lifecycle, logger)
167183
return service
@@ -220,29 +236,45 @@ func (s *Service) CreateUpload(ctx context.Context, key, version string, scope a
220236
return nil, ErrNoWriteScope
221237
}
222238

223-
existingUploads, err := s.db.Upload.Query().
224-
Where(uploadTuple(key, version, writeScope, scope.RepoID)...).
225-
All(ctx)
226-
if err != nil {
227-
return nil, fmt.Errorf("query existing uploads: %w", err)
228-
}
229-
230-
for _, existingUpload := range existingUploads {
231-
if s.isUploadAbandoned(existingUpload) {
232-
if err := s.deleteUpload(ctx, existingUpload); err != nil {
233-
return nil, err
239+
// Removing an inactive legacy row is progress, not contention. Only races
240+
// that leave this request without a row or reservation consume the budget.
241+
contentionAttempts := 0
242+
for contentionAttempts < maxUploadContentionAttempts {
243+
existingUpload, err := s.firstUploadByTuple(ctx, key, version, writeScope, scope.RepoID)
244+
switch {
245+
case err == nil:
246+
cutoff := s.now().Add(-uploadsession.TakeoverIdleTimeout).UnixMilli()
247+
result, deleteErr := uploadsession.DeleteIfInactive(ctx, s.db, existingUpload.ID, existingUpload.FolderName, cutoff)
248+
if deleteErr != nil {
249+
return nil, deleteErr
250+
}
251+
if !result.Deleted {
252+
_, queryErr := s.uploadByID(ctx, existingUpload.ID)
253+
switch {
254+
case queryErr == nil:
255+
return nil, ErrUploadAlreadyExists
256+
case errors.Is(queryErr, ErrUploadNotFound):
257+
contentionAttempts++
258+
continue
259+
default:
260+
return nil, queryErr
261+
}
234262
}
235263
continue
264+
case !errors.Is(err, ErrUploadNotFound):
265+
return nil, err
236266
}
237-
return nil, ErrUploadAlreadyExists
238-
}
239267

240-
uploadID, err := s.createUploadRecord(ctx, key, version, writeScope, scope.RepoID)
241-
if err != nil {
242-
return nil, err
268+
uploadID, createErr := s.createUploadRecord(ctx, key, version, writeScope, scope.RepoID)
269+
if createErr == nil {
270+
return &CreateUploadResult{UploadID: uploadID}, nil
271+
}
272+
if !errors.Is(createErr, ErrUploadAlreadyExists) {
273+
return nil, createErr
274+
}
275+
contentionAttempts++
243276
}
244-
245-
return &CreateUploadResult{UploadID: uploadID}, nil
277+
return nil, ErrUploadAlreadyExists
246278
}
247279

248280
func (s *Service) UploadPart(ctx context.Context, uploadID int64, stream io.Reader) error {
@@ -286,23 +318,28 @@ func (c *BlockListCommit) Commit(ctx context.Context, blockIDs []string) error {
286318
return nil
287319
}
288320

289-
for index, blockID := range blockIDs {
290-
err := c.service.storage.CopyObject(
291-
ctx,
292-
blockObjectName(c.upload.FolderName, blockID),
293-
partObjectName(c.upload.FolderName, index),
294-
)
295-
if err != nil {
296-
if errors.Is(err, storage.ErrObjectNotFound) {
297-
return fmt.Errorf("%w: missing block %d", ErrPartCountMismatch, index)
321+
if err := c.service.withUploadActivity(ctx, c.upload.ID, func(activityCtx context.Context) error {
322+
for index, blockID := range blockIDs {
323+
err := c.service.storage.CopyObject(
324+
activityCtx,
325+
blockObjectName(c.upload.FolderName, blockID),
326+
partObjectName(c.upload.FolderName, index),
327+
)
328+
if err != nil {
329+
if errors.Is(err, storage.ErrObjectNotFound) {
330+
return fmt.Errorf("%w: missing block %d", ErrPartCountMismatch, index)
331+
}
332+
return err
298333
}
299-
return err
300334
}
335+
return nil
336+
}); err != nil {
337+
return err
301338
}
302339
if err := c.service.db.Upload.UpdateOneID(c.upload.ID).
303340
SetCommittedPartCount(len(blockIDs)).
304341
Exec(ctx); err != nil {
305-
return fmt.Errorf("record committed block list: %w", err)
342+
return wrapUploadRowError("record committed block list", err)
306343
}
307344

308345
return nil
@@ -324,35 +361,41 @@ func (s *Service) CompleteUpload(ctx context.Context, key, version string, scope
324361
return 0, ErrNoPartsUploaded
325362
}
326363

327-
partCount, err := s.committedPartCount(ctx, currentUpload)
364+
var partCount int
365+
var sizeBytes int64
366+
err = s.withUploadActivity(ctx, currentUpload.ID, func(activityCtx context.Context) error {
367+
var validationErr error
368+
partCount, validationErr = s.committedPartCount(activityCtx, currentUpload)
369+
if validationErr != nil {
370+
return validationErr
371+
}
372+
if partCount == 0 {
373+
return ErrNoPartsUploaded
374+
}
375+
if partCount > currentUpload.FinishedPartUploadCount {
376+
return fmt.Errorf(
377+
"%w: committed part count %d exceeds finished upload count %d",
378+
ErrPartCountMismatch,
379+
partCount,
380+
currentUpload.FinishedPartUploadCount,
381+
)
382+
}
383+
parts, inspectErr := s.storage.InspectFolder(activityCtx, partsFolderName(currentUpload.FolderName))
384+
if inspectErr != nil {
385+
return fmt.Errorf("inspect finalized cache parts: %w", inspectErr)
386+
}
387+
sizeBytes, validationErr = parts.LogicalIndexedSize(partCount)
388+
if validationErr != nil {
389+
return fmt.Errorf("%w: %v", ErrPartCountMismatch, validationErr)
390+
}
391+
return nil
392+
})
328393
if err != nil {
329-
if errors.Is(err, ErrPartCountMismatch) {
394+
if errors.Is(err, ErrNoPartsUploaded) || errors.Is(err, ErrPartCountMismatch) {
330395
s.deleteUploadBestEffort(ctx, currentUpload)
331396
}
332397
return 0, err
333398
}
334-
if partCount == 0 {
335-
s.deleteUploadBestEffort(ctx, currentUpload)
336-
return 0, ErrNoPartsUploaded
337-
}
338-
if partCount > currentUpload.FinishedPartUploadCount {
339-
s.deleteUploadBestEffort(ctx, currentUpload)
340-
return 0, fmt.Errorf(
341-
"%w: committed part count %d exceeds finished upload count %d",
342-
ErrPartCountMismatch,
343-
partCount,
344-
currentUpload.FinishedPartUploadCount,
345-
)
346-
}
347-
parts, err := s.storage.InspectFolder(ctx, partsFolderName(currentUpload.FolderName))
348-
if err != nil {
349-
return 0, fmt.Errorf("inspect finalized cache parts: %w", err)
350-
}
351-
sizeBytes, err := parts.LogicalIndexedSize(partCount)
352-
if err != nil {
353-
s.deleteUploadBestEffort(ctx, currentUpload)
354-
return 0, fmt.Errorf("%w: %v", ErrPartCountMismatch, err)
355-
}
356399

357400
location, cacheEntryID, err := s.completeUploadRecord(ctx, currentUpload, writeScope, scope.RepoID, partCount, sizeBytes)
358401
if err != nil {
@@ -585,7 +628,7 @@ func (s *Service) createUploadRecord(ctx context.Context, key, version, scope, r
585628
SetID(id).
586629
SetFolderName(strconv.FormatInt(id, 10)).
587630
SetCommittedPartCount(0).
588-
SetCreatedAt(time.Now().UnixMilli()).
631+
SetCreatedAt(s.now().UnixMilli()).
589632
SetKey(key).
590633
SetVersion(version).
591634
SetScope(scope).
@@ -634,19 +677,35 @@ func (s *Service) uploadByTuple(ctx context.Context, key, version, scope, repoID
634677
return currentUpload, nil
635678
}
636679

680+
func (s *Service) firstUploadByTuple(ctx context.Context, key, version, scope, repoID string) (*ent.Upload, error) {
681+
currentUpload, err := s.db.Upload.Query().
682+
Where(uploadTuple(key, version, scope, repoID)...).
683+
Order(upload.ByID()).
684+
First(ctx)
685+
if err != nil {
686+
if ent.IsNotFound(err) {
687+
return nil, ErrUploadNotFound
688+
}
689+
return nil, fmt.Errorf("query first upload: %w", err)
690+
}
691+
return currentUpload, nil
692+
}
693+
637694
func (s *Service) uploadObject(ctx context.Context, currentUpload *ent.Upload, objectName string, stream io.Reader, committedPartCount *int) error {
638-
if err := s.storage.UploadStream(ctx, objectName, stream); err != nil {
695+
if err := s.withUploadActivity(ctx, currentUpload.ID, func(activityCtx context.Context) error {
696+
return s.storage.UploadStream(activityCtx, objectName, stream)
697+
}); err != nil {
639698
return err
640699
}
641700

642701
update := s.db.Upload.UpdateOneID(currentUpload.ID).
643-
SetLastPartUploadedAt(time.Now().UnixMilli()).
702+
SetLastPartUploadedAt(s.now().UnixMilli()).
644703
AddFinishedPartUploadCount(1)
645704
if committedPartCount != nil {
646705
update.SetCommittedPartCount(*committedPartCount)
647706
}
648707
if err := update.Exec(ctx); err != nil {
649-
return fmt.Errorf("mark upload finished: %w", err)
708+
return wrapUploadRowError("mark upload finished", err)
650709
}
651710

652711
return nil
@@ -701,14 +760,6 @@ func uploadTupleHash(key, version, scope, repoID string) string {
701760
return hex.EncodeToString(hash.Sum(nil))
702761
}
703762

704-
func (s *Service) isUploadAbandoned(currentUpload *ent.Upload) bool {
705-
lastActivity := currentUpload.CreatedAt
706-
if currentUpload.LastPartUploadedAt != nil {
707-
lastActivity = *currentUpload.LastPartUploadedAt
708-
}
709-
return time.Since(time.UnixMilli(lastActivity)) > abandonedUploadLifetime
710-
}
711-
712763
func (s *Service) deleteUpload(ctx context.Context, currentUpload *ent.Upload) error {
713764
tx, err := s.db.Tx(ctx)
714765
if err != nil {
@@ -721,23 +772,30 @@ func (s *Service) deleteUpload(ctx context.Context, currentUpload *ent.Upload) e
721772
}
722773
}()
723774

775+
if err := tx.Upload.DeleteOneID(currentUpload.ID).Exec(ctx); err != nil {
776+
return wrapUploadRowError("delete upload", err)
777+
}
724778
if _, err := storageoutbox.Enqueue(ctx, tx.Client(), currentUpload.FolderName); err != nil {
725779
return err
726780
}
727-
if err := tx.Upload.DeleteOneID(currentUpload.ID).Exec(ctx); err != nil {
728-
return fmt.Errorf("delete upload: %w", err)
729-
}
730781
if err := tx.Commit(); err != nil {
731782
return fmt.Errorf("commit upload deletion: %w", err)
732783
}
733784
committed = true
734785
return nil
735786
}
736787

788+
func wrapUploadRowError(operation string, err error) error {
789+
if ent.IsNotFound(err) {
790+
return fmt.Errorf("%s: %w", operation, ErrUploadNotFound)
791+
}
792+
return fmt.Errorf("%s: %w", operation, err)
793+
}
794+
737795
func (s *Service) deleteUploadBestEffort(ctx context.Context, currentUpload *ent.Upload) {
738796
if err := s.deleteUpload(ctx, currentUpload); err != nil {
739797
event := s.logger.Error()
740-
if ctx.Err() != nil {
798+
if ctx.Err() != nil || errors.Is(err, ErrUploadNotFound) {
741799
event = s.logger.Debug()
742800
}
743801
event.
@@ -819,7 +877,7 @@ func (s *Service) completeUploadRecord(ctx context.Context, currentUpload *ent.U
819877
}
820878

821879
if err := tx.Upload.DeleteOneID(currentUpload.ID).Exec(ctx); err != nil {
822-
return nil, "", fmt.Errorf("delete upload: %w", err)
880+
return nil, "", wrapUploadRowError("delete upload", err)
823881
}
824882
if err := tx.Commit(); err != nil {
825883
return nil, "", fmt.Errorf("commit upload: %w", err)

0 commit comments

Comments
 (0)