Skip to content

Commit 969c878

Browse files
authored
fix: don't buffer functions and don't keep them all in memory for duration of hashing and uploading (#649)
Fix for https://linear.app/netlify/issue/FRB-2285/upwell-or-builds-hanging-during-function-uploads
1 parent 517b027 commit 969c878

2 files changed

Lines changed: 306 additions & 69 deletions

File tree

go/porcelain/deploy.go

Lines changed: 173 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"encoding/hex"
1212
"encoding/json"
1313
"fmt"
14+
"hash"
1415
"io"
1516
"io/ioutil"
1617
"os"
@@ -125,9 +126,17 @@ type FileBundle struct {
125126
Size *int64 `json:"size,omitempty"`
126127
FunctionMetadata *FunctionMetadata
127128

128-
// Path OR Buffer should be populated
129-
Path string
129+
// Path is the location of the file on disk. Uploads always stream from Path.
130+
Path string
131+
132+
// Deprecated: uploads always stream from Path; this package no longer reads Buffer. It is retained
133+
// only for backwards compatibility with external callers and may be removed in a future release.
134+
// Leave it nil to have the (also deprecated) Read/Seek/Close methods stream from Path instead.
130135
Buffer io.ReadSeeker
136+
137+
// pathReader is lazily opened from Path when Buffer is nil, so the deprecated Read/Seek/Close
138+
// methods keep working for external callers that treat a FileBundle as an io.ReadSeekCloser.
139+
pathReader *os.File
131140
}
132141

133142
type FunctionMetadata struct {
@@ -139,15 +148,46 @@ type toolchainSpec struct {
139148
Runtime string `json:"runtime"`
140149
}
141150

151+
// Deprecated: read directly from Path (e.g. via os.Open) instead. When Buffer is set, Read reads
152+
// from it; otherwise it streams from Path. Retained for backwards compatibility with external
153+
// callers and may be removed in a future release.
142154
func (f *FileBundle) Read(p []byte) (n int, err error) {
143-
return f.Buffer.Read(p)
155+
if f.Buffer != nil {
156+
return f.Buffer.Read(p)
157+
}
158+
if f.pathReader == nil {
159+
if f.pathReader, err = os.Open(f.Path); err != nil {
160+
return 0, err
161+
}
162+
}
163+
return f.pathReader.Read(p)
144164
}
145165

166+
// Deprecated: read directly from Path (e.g. via os.Open) instead. When Buffer is set, Seek seeks
167+
// it; otherwise it seeks the stream opened from Path. Retained for backwards compatibility with
168+
// external callers and may be removed in a future release.
146169
func (f *FileBundle) Seek(offset int64, whence int) (int64, error) {
147-
return f.Buffer.Seek(offset, whence)
170+
if f.Buffer != nil {
171+
return f.Buffer.Seek(offset, whence)
172+
}
173+
if f.pathReader == nil {
174+
var err error
175+
if f.pathReader, err = os.Open(f.Path); err != nil {
176+
return 0, err
177+
}
178+
}
179+
return f.pathReader.Seek(offset, whence)
148180
}
149181

182+
// Deprecated: retained for backwards compatibility with external callers and may be removed in a
183+
// future release. It closes the stream lazily opened from Path by Read/Seek; it never closes a
184+
// caller-supplied Buffer.
150185
func (f *FileBundle) Close() error {
186+
if f.pathReader != nil {
187+
err := f.pathReader.Close()
188+
f.pathReader = nil
189+
return err
190+
}
151191
return nil
152192
}
153193

@@ -263,7 +303,13 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy *
263303

264304
options.files = files
265305

266-
functions, schedules, functionsConfig, err := bundle(ctx, options.FunctionsDir, options.Observer)
306+
// The temp dir is created lazily, only if a function actually needs to be zipped. Pre-bundled
307+
// .zip/.tar functions stream from their original path and never touch it, so a deploy with no
308+
// unbundled functions creates no temp dir at all.
309+
functionsTmpDir := &lazyTempDir{}
310+
defer functionsTmpDir.remove()
311+
312+
functions, schedules, functionsConfig, err := bundle(ctx, options.FunctionsDir, functionsTmpDir, options.Observer)
267313
if err != nil {
268314
if options.Observer != nil {
269315
options.Observer.OnFailedWalk()
@@ -456,6 +502,7 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl
456502
log := context.GetLogger(ctx)
457503
log.Infof("Uploading %v files", count)
458504

505+
var abortErr error
459506
for _, sha := range required {
460507
if files, exist := files.Hashed[sha]; exist {
461508
file := files[0]
@@ -466,7 +513,11 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl
466513
go n.uploadFile(ctx, d, file, observer, t, timeout, wg, sem, sharedErr, skipRetry)
467514
case <-ctx.Done():
468515
log.Info("Context terminated, aborting file upload")
469-
return errors.Wrap(ctx.Err(), "aborted file upload early")
516+
abortErr = errors.Wrap(ctx.Err(), "aborted file upload early")
517+
}
518+
519+
if abortErr != nil {
520+
break
470521
}
471522

472523
if len(files) > 1 {
@@ -478,8 +529,16 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl
478529
}
479530
}
480531

532+
// Always wait for in-flight uploads to finish before returning. On the ctx.Done()
533+
// path this prevents orphaned uploadFile goroutines from racing against the caller's
534+
// deferred temp-dir cleanup (os.RemoveAll), which would otherwise open files that are
535+
// being deleted and surface spurious "no such file or directory" errors.
481536
wg.Wait()
482537

538+
if abortErr != nil {
539+
return abortErr
540+
}
541+
483542
return sharedErr.err
484543
}
485544

@@ -539,23 +598,25 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl
539598
_, operationError = n.Operations.UploadDeployFile(params, authInfo)
540599
}
541600
case functionUpload:
542-
params := operations.NewUploadDeployFunctionParams().WithDeployID(d.ID).WithName(f.Name).WithFileBody(f).WithRuntime(&f.Runtime)
601+
var body io.ReadCloser
602+
body, operationError = os.Open(f.Path)
603+
if operationError == nil {
604+
defer body.Close()
605+
params := operations.NewUploadDeployFunctionParams().WithDeployID(d.ID).WithName(f.Name).WithFileBody(body).WithRuntime(&f.Runtime)
543606

544-
if retryCount > 0 {
545-
params = params.WithXNfRetryCount(&retryCount)
546-
}
607+
if retryCount > 0 {
608+
params = params.WithXNfRetryCount(&retryCount)
609+
}
547610

548-
if f.FunctionMetadata != nil {
549-
params = params.WithInvocationMode(&f.FunctionMetadata.InvocationMode)
550-
params = params.WithTimeout(&f.FunctionMetadata.Timeout)
551-
}
611+
if f.FunctionMetadata != nil {
612+
params = params.WithInvocationMode(&f.FunctionMetadata.InvocationMode)
613+
params = params.WithTimeout(&f.FunctionMetadata.Timeout)
614+
}
552615

553-
if timeout != 0 {
554-
params.SetRequestTimeout(timeout)
555-
}
556-
_, operationError = n.Operations.UploadDeployFunction(params, authInfo)
557-
if operationError != nil {
558-
f.Buffer.Seek(0, 0)
616+
if timeout != 0 {
617+
params.SetRequestTimeout(timeout)
618+
}
619+
_, operationError = n.Operations.UploadDeployFunction(params, authInfo)
559620
}
560621
}
561622

@@ -601,6 +662,14 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl
601662
}
602663

603664
func createFileBundle(rel, path string) (*FileBundle, error) {
665+
return createFileBundleWithHasher(rel, path, sha1.New())
666+
}
667+
668+
func createFunctionFileBundle(rel, path string) (*FileBundle, error) {
669+
return createFileBundleWithHasher(rel, path, sha256.New())
670+
}
671+
672+
func createFileBundleWithHasher(rel, path string, s hash.Hash) (*FileBundle, error) {
604673
o, err := os.Open(path)
605674
if err != nil {
606675
return nil, err
@@ -612,7 +681,6 @@ func createFileBundle(rel, path string) (*FileBundle, error) {
612681
Path: path,
613682
}
614683

615-
s := sha1.New()
616684
if _, err := io.Copy(s, o); err != nil {
617685
return nil, err
618686
}
@@ -713,7 +781,30 @@ func addInternalFilesToDeploy(dir, internalPath string, files *deployFiles, obse
713781
})
714782
}
715783

716-
func bundle(ctx context.Context, functionDir string, observer DeployObserver) (*deployFiles, []*models.FunctionSchedule, map[string]models.FunctionConfig, error) {
784+
type lazyTempDir struct {
785+
root string
786+
path string
787+
created bool
788+
}
789+
790+
func (l *lazyTempDir) get() (string, error) {
791+
if !l.created {
792+
path, err := os.MkdirTemp(l.root, "netlify-deploy-functions-")
793+
if err != nil {
794+
return "", err
795+
}
796+
l.path, l.created = path, true
797+
}
798+
return l.path, nil
799+
}
800+
801+
func (l *lazyTempDir) remove() {
802+
if l.created {
803+
os.RemoveAll(l.path)
804+
}
805+
}
806+
807+
func bundle(ctx context.Context, functionDir string, tmpDir *lazyTempDir, observer DeployObserver) (*deployFiles, []*models.FunctionSchedule, map[string]models.FunctionConfig, error) {
717808
if functionDir == "" {
718809
return nil, nil, nil, nil
719810
}
@@ -725,7 +816,7 @@ func bundle(ctx context.Context, functionDir string, observer DeployObserver) (*
725816
if err == nil {
726817
defer manifestFile.Close()
727818

728-
return bundleFromManifest(ctx, manifestFile, observer)
819+
return bundleFromManifest(ctx, manifestFile, tmpDir, observer)
729820
}
730821

731822
functions := newDeployFiles()
@@ -744,19 +835,19 @@ func bundle(ctx context.Context, functionDir string, observer DeployObserver) (*
744835
if err != nil {
745836
return nil, nil, nil, err
746837
}
747-
file, err := newFunctionFile(filePath, i, runtime, nil, observer)
838+
file, err := newFunctionFile(filePath, i, runtime, nil, tmpDir, observer)
748839
if err != nil {
749840
return nil, nil, nil, err
750841
}
751842
functions.Add(file.Name, file)
752843
case jsFile(i):
753-
file, err := newFunctionFile(filePath, i, jsRuntime, nil, observer)
844+
file, err := newFunctionFile(filePath, i, jsRuntime, nil, tmpDir, observer)
754845
if err != nil {
755846
return nil, nil, nil, err
756847
}
757848
functions.Add(file.Name, file)
758849
case goFile(filePath, i, observer):
759-
file, err := newFunctionFile(filePath, i, amazonLinux2, nil, observer)
850+
file, err := newFunctionFile(filePath, i, amazonLinux2, nil, tmpDir, observer)
760851
if err != nil {
761852
return nil, nil, nil, err
762853
}
@@ -771,7 +862,7 @@ func bundle(ctx context.Context, functionDir string, observer DeployObserver) (*
771862
return functions, nil, nil, nil
772863
}
773864

774-
func bundleFromManifest(ctx context.Context, manifestFile *os.File, observer DeployObserver) (*deployFiles, []*models.FunctionSchedule, map[string]models.FunctionConfig, error) {
865+
func bundleFromManifest(ctx context.Context, manifestFile *os.File, tmpDir *lazyTempDir, observer DeployObserver) (*deployFiles, []*models.FunctionSchedule, map[string]models.FunctionConfig, error) {
775866
manifestBytes, err := ioutil.ReadAll(manifestFile)
776867
if err != nil {
777868
return nil, nil, nil, err
@@ -808,7 +899,7 @@ func bundleFromManifest(ctx context.Context, manifestFile *os.File, observer Dep
808899
InvocationMode: function.InvocationMode,
809900
Timeout: function.Timeout,
810901
}
811-
file, err := newFunctionFile(function.Path, fileInfo, runtime, &meta, observer)
902+
file, err := newFunctionFile(function.Path, fileInfo, runtime, &meta, tmpDir, observer)
812903
if err != nil {
813904
return nil, nil, nil, err
814905
}
@@ -911,60 +1002,79 @@ func readZipRuntime(filePath string) (string, error) {
9111002
return jsRuntime, nil
9121003
}
9131004

914-
func newFunctionFile(filePath string, i os.FileInfo, runtime string, metadata *FunctionMetadata, observer DeployObserver) (*FileBundle, error) {
915-
file := &FileBundle{
916-
Name: strings.TrimSuffix(i.Name(), filepath.Ext(i.Name())),
917-
Runtime: runtime,
918-
}
919-
920-
s := sha256.New()
1005+
func newFunctionFile(filePath string, i os.FileInfo, runtime string, metadata *FunctionMetadata, tmpDir *lazyTempDir, observer DeployObserver) (*FileBundle, error) {
1006+
var file *FileBundle
1007+
var err error
9211008

922-
fileEntry, err := os.Open(filePath)
1009+
if zipFile(i) || tarFile(i) {
1010+
name := strings.TrimSuffix(i.Name(), filepath.Ext(i.Name()))
1011+
file, err = createFunctionFileBundle(name, filePath)
1012+
} else {
1013+
file, err = zipFunctionFile(filePath, i, runtime, tmpDir)
1014+
}
9231015
if err != nil {
9241016
return nil, err
9251017
}
926-
defer fileEntry.Close()
9271018

928-
var buf io.ReadWriter
929-
930-
if zipFile(i) || tarFile(i) {
931-
buf = fileEntry
932-
} else {
933-
buf = new(bytes.Buffer)
934-
archive := zip.NewWriter(buf)
1019+
file.Runtime = runtime
1020+
file.FunctionMetadata = metadata
9351021

936-
fileHeader, err := createHeader(archive, i, runtime)
937-
if err != nil {
1022+
if observer != nil {
1023+
if err := observer.OnSuccessfulStep(file); err != nil {
9381024
return nil, err
9391025
}
1026+
}
9401027

941-
if _, err = io.Copy(fileHeader, fileEntry); err != nil {
942-
return nil, err
943-
}
1028+
return file, nil
1029+
}
9441030

945-
if err := archive.Close(); err != nil {
946-
return nil, err
947-
}
1031+
func zipFunctionFile(filePath string, i os.FileInfo, runtime string, tmpDir *lazyTempDir) (*FileBundle, error) {
1032+
src, err := os.Open(filePath)
1033+
if err != nil {
1034+
return nil, err
9481035
}
1036+
defer src.Close()
9491037

950-
fileBuffer := new(bytes.Buffer)
951-
m := io.MultiWriter(s, fileBuffer)
952-
953-
if _, err := io.Copy(m, buf); err != nil {
1038+
dir, err := tmpDir.get()
1039+
if err != nil {
9541040
return nil, err
9551041
}
956-
file.Sum = hex.EncodeToString(s.Sum(nil))
957-
file.Buffer = bytes.NewReader(fileBuffer.Bytes())
9581042

959-
if observer != nil {
960-
if err := observer.OnSuccessfulStep(file); err != nil {
961-
return nil, err
1043+
tmp, err := os.CreateTemp(dir, "function-*.zip")
1044+
if err != nil {
1045+
return nil, err
1046+
}
1047+
defer func() {
1048+
if tmp != nil {
1049+
_ = tmp.Close()
9621050
}
1051+
}()
1052+
1053+
s := sha256.New()
1054+
archive := zip.NewWriter(io.MultiWriter(tmp, s))
1055+
1056+
fileHeader, err := createHeader(archive, i, runtime)
1057+
if err != nil {
1058+
return nil, err
1059+
}
1060+
if _, err := io.Copy(fileHeader, src); err != nil {
1061+
return nil, err
1062+
}
1063+
if err := archive.Close(); err != nil {
1064+
return nil, err
9631065
}
9641066

965-
file.FunctionMetadata = metadata
1067+
tmpName := tmp.Name()
1068+
if err := tmp.Close(); err != nil {
1069+
return nil, err
1070+
}
1071+
tmp = nil
9661072

967-
return file, nil
1073+
return &FileBundle{
1074+
Name: strings.TrimSuffix(i.Name(), filepath.Ext(i.Name())),
1075+
Sum: hex.EncodeToString(s.Sum(nil)),
1076+
Path: tmpName,
1077+
}, nil
9681078
}
9691079

9701080
func zipFile(i os.FileInfo) bool {

0 commit comments

Comments
 (0)