@@ -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
3536const (
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
8185type CapacityTrigger interface {
8286 Trigger ()
8387}
8488
8589type 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
95101type 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
248280func (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+
637694func (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-
712763func (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+
737795func (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