serve s3: fix OOM and InvalidPart errors with concurrent multipart uploads - fixes #9616

Streamed multipart uploads had two problems when the client uploaded parts
concurrently:

Parts arriving ahead of the next part needed by the backend stream were buffered
in memory without limit, acknowledging each part as soon as it was received. A
client uploading faster than the backend could drain would therefore balloon the
server's memory to the size of the upload. Buffering is now bounded a new
--multipart-streaming-buffer-limit flag (default 256Mi, 0 for unlimited): a part
that would take the buffer over the limit is not read until the stream drains,
applying backpressure to the client instead of using unbounded memory.

A part uploaded again with the same number - typically a client retrying after
its request timed out - left a stale copy in the reorder buffer which made
CompleteMultipartUpload fail with InvalidPart, aborting the whole upload.
Re-uploaded parts are now handled properly: a copy still in the buffer is
replaced, an identical copy of an already streamed part is accepted as a no-op,
and only replacing an already streamed part with different content (which the
in-order stream cannot honour) is rejected.
This commit is contained in:
Nick Craig-Wood
2026-07-27 14:51:06 +01:00
parent d97e33fc88
commit b2d0642825
4 changed files with 299 additions and 34 deletions
+99 -19
View File
@@ -12,6 +12,7 @@
package s3 package s3
import ( import (
"bytes"
"context" "context"
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
@@ -49,31 +50,37 @@ type multipartUpload struct {
pipeW *io.PipeWriter // parts are streamed here, in part-number order pipeW *io.PipeWriter // parts are streamed here, in part-number order
mu sync.Mutex mu sync.Mutex
cond *sync.Cond // signalled when buffered shrinks, nextPart advances or the upload closes
partMD5s map[int][]byte // raw MD5 sums per part (for the final S3 multipart ETag) partMD5s map[int][]byte // raw MD5 sums per part (for the final S3 multipart ETag)
partSizes map[int]int64 // observed part sizes partSizes map[int]int64 // observed part sizes
closed bool closed bool
putCancel context.CancelFunc // cancels the background PutStream putCancel context.CancelFunc // cancels the background PutStream
putDone chan struct{} // closed when the background PutStream returns putDone chan struct{} // closed when the background PutStream returns
putErr error // PutStream result (read only after putDone is closed) putErr error // PutStream result (read only after putDone is closed)
nextPart int // next part number to stream (1-based) nextPart int // next part number to stream (1-based)
streamBuf map[int]*pool.RW // parts received ahead of nextPart, awaiting their turn streamBuf map[int]*pool.RW // parts received ahead of nextPart, awaiting their turn
pumping bool // a goroutine is currently writing to the pipe pumping bool // a goroutine is currently writing to the pipe
buffered int64 // bytes of parts admitted but not yet streamed or released
bufferLimit int64 // max buffered before parts ahead of nextPart must wait (<= 0 for no limit)
} }
// newMultipartUpload allocates an upload struct. // newMultipartUpload allocates an upload struct.
func newMultipartUpload(bucket, key, fp, streamFp string, meta map[string]string) *multipartUpload { func newMultipartUpload(bucket, key, fp, streamFp string, meta map[string]string, bufferLimit int64) *multipartUpload {
return &multipartUpload{ up := &multipartUpload{
bucket: bucket, bucket: bucket,
key: key, key: key,
fp: fp, fp: fp,
streamFp: streamFp, streamFp: streamFp,
meta: meta, meta: meta,
partMD5s: map[int][]byte{}, partMD5s: map[int][]byte{},
partSizes: map[int]int64{}, partSizes: map[int]int64{},
nextPart: 1, nextPart: 1,
streamBuf: map[int]*pool.RW{}, streamBuf: map[int]*pool.RW{},
bufferLimit: bufferLimit,
} }
up.cond = sync.NewCond(&up.mu)
return up
} }
// loadUpload looks up an in-flight upload by ID. // loadUpload looks up an in-flight upload by ID.
@@ -137,7 +144,7 @@ func (b *s3Backend) CreateMultipartUpload(ctx context.Context, bucketName, objec
streamFp = path.Join(objectDir, multipartUploadPrefix+string(uploadID)) streamFp = path.Join(objectDir, multipartUploadPrefix+string(uploadID))
} }
up := newMultipartUpload(bucketName, objectName, fp, streamFp, meta) up := newMultipartUpload(bucketName, objectName, fp, streamFp, meta, int64(b.s.opt.MultipartStreamingBufferLimit))
src := object.NewStaticObjectInfo(streamFp, time.Now(), -1, true, nil, f) src := object.NewStaticObjectInfo(streamFp, time.Now(), -1, true, nil, f)
pr, pw := io.Pipe() pr, pw := io.Pipe()
@@ -180,6 +187,12 @@ func (b *s3Backend) UploadPart(ctx context.Context, bucketName, objectName strin
return "", err return "", err
} }
// Wait until there is room to buffer this part, bounding the memory a
// client which uploads faster than the backend drains can consume.
if err := up.waitForTurn(partNumber, contentLength); err != nil {
return "", err
}
// Buffer the part in a pool-backed RW so we can MD5 it (for the ETag) and // Buffer the part in a pool-backed RW so we can MD5 it (for the ETag) and
// stream it once it is this part's turn. // stream it once it is this part's turn.
rw := multipart.NewRW().Reserve(contentLength) rw := multipart.NewRW().Reserve(contentLength)
@@ -187,10 +200,12 @@ func (b *s3Backend) UploadPart(ctx context.Context, bucketName, objectName strin
n, err := io.Copy(rw, io.TeeReader(body, hasher)) n, err := io.Copy(rw, io.TeeReader(body, hasher))
if err != nil { if err != nil {
_ = rw.Close() _ = rw.Close()
up.release(contentLength)
return "", err return "", err
} }
if n != contentLength { if n != contentLength {
_ = rw.Close() _ = rw.Close()
up.release(contentLength)
return "", gofakes3.ErrIncompleteBody return "", gofakes3.ErrIncompleteBody
} }
md5Sum := hasher.Sum(nil) md5Sum := hasher.Sum(nil)
@@ -202,15 +217,73 @@ func (b *s3Backend) UploadPart(ctx context.Context, bucketName, objectName strin
return etag, nil return etag, nil
} }
// waitForTurn blocks until size bytes can be admitted to the reorder buffer,
// then reserves them, bounding the memory an upload can consume when the
// client sends parts faster than the backend drains them.
//
// The next part the stream needs (and any retry of an earlier one) is always
// admitted so the pipe can keep draining; so is a single part bigger than the
// limit when the buffer is empty, to guarantee progress. Reserved bytes are
// returned with release, or by the pump as the part is streamed.
func (up *multipartUpload) waitForTurn(partNumber int, size int64) error {
up.mu.Lock()
defer up.mu.Unlock()
for {
if up.closed {
return gofakes3.ErrNoSuchUpload
}
if up.bufferLimit <= 0 || partNumber <= up.nextPart || up.buffered == 0 || up.buffered+size <= up.bufferLimit {
up.buffered += size
return nil
}
up.cond.Wait()
}
}
// release returns size bytes reserved by waitForTurn to the reorder buffer
// budget and wakes any parts waiting for room.
func (up *multipartUpload) release(size int64) {
up.mu.Lock()
up.buffered -= size
up.cond.Broadcast()
up.mu.Unlock()
}
// streamPart records a part and streams the parts into the pipe in order. // streamPart records a part and streams the parts into the pipe in order.
// //
// Parts must be uploaded in ascending, contiguous part-number order. A part // Parts must be uploaded in ascending, contiguous part-number order. A part
// that arrives ahead of the next expected one is buffered until its turn; the // that arrives ahead of the next expected one is buffered until its turn; the
// parts are then pumped into the pipe in order. Whichever goroutine finds the // parts are then pumped into the pipe in order. Whichever goroutine finds the
// next part available does the pumping, so concurrent (but in-order) clients // next part available does the pumping, so concurrent (but in-order) clients
// are tolerated with buffering bounded by how far ahead they run. // are tolerated, with the buffering bounded by waitForTurn.
//
// A part number may be uploaded more than once - typically a client retrying
// after its request timed out, but real S3 also allows replacing a part. If
// the earlier copy is still buffered it is replaced (last write wins). If it
// has already been streamed it can't be replaced: an identical re-upload is
// accepted idempotently (the data is already in the stream) and a different
// one is rejected.
func (up *multipartUpload) streamPart(partNumber int, size int64, md5Sum []byte, rw *pool.RW) error { func (up *multipartUpload) streamPart(partNumber int, size int64, md5Sum []byte, rw *pool.RW) error {
up.mu.Lock() up.mu.Lock()
if oldMD5, exists := up.partMD5s[partNumber]; exists {
if old, buffered := up.streamBuf[partNumber]; buffered {
_ = old.Close()
up.buffered -= up.partSizes[partNumber]
up.cond.Broadcast()
} else {
// Already streamed (or streaming right now): the stream can't be
// rewritten, so accept an identical part and reject the rest.
same := bytes.Equal(md5Sum, oldMD5) && size == up.partSizes[partNumber]
up.buffered -= size
up.cond.Broadcast()
up.mu.Unlock()
_ = rw.Close()
if !same {
return gofakes3.ErrorMessagef(gofakes3.ErrNotImplemented, "part %d has already been streamed to the backend and cannot be replaced with different contents", partNumber)
}
return nil
}
}
up.partMD5s[partNumber] = md5Sum up.partMD5s[partNumber] = md5Sum
up.partSizes[partNumber] = size up.partSizes[partNumber] = size
up.streamBuf[partNumber] = rw up.streamBuf[partNumber] = rw
@@ -230,6 +303,7 @@ func (up *multipartUpload) streamPart(partNumber int, size int64, md5Sum []byte,
return nil return nil
} }
delete(up.streamBuf, up.nextPart) delete(up.streamBuf, up.nextPart)
psize := prw.Size()
up.mu.Unlock() up.mu.Unlock()
err := pipePart(up.pipeW, prw) err := pipePart(up.pipeW, prw)
@@ -237,12 +311,16 @@ func (up *multipartUpload) streamPart(partNumber int, size int64, md5Sum []byte,
if err != nil { if err != nil {
up.mu.Lock() up.mu.Lock()
up.pumping = false up.pumping = false
up.buffered -= psize
up.cond.Broadcast()
up.mu.Unlock() up.mu.Unlock()
return err return err
} }
up.mu.Lock() up.mu.Lock()
up.nextPart++ up.nextPart++
up.buffered -= psize
up.cond.Broadcast()
} }
} }
@@ -400,6 +478,7 @@ func (up *multipartUpload) close(ctx context.Context) error {
return nil return nil
} }
up.closed = true up.closed = true
up.cond.Broadcast()
up.mu.Unlock() up.mu.Unlock()
err := up.pipeW.Close() err := up.pipeW.Close()
@@ -425,6 +504,7 @@ func (up *multipartUpload) abort(ctx context.Context) error {
up.closed = true up.closed = true
streamBuf := up.streamBuf streamBuf := up.streamBuf
up.streamBuf = nil up.streamBuf = nil
up.cond.Broadcast()
up.mu.Unlock() up.mu.Unlock()
for _, rw := range streamBuf { for _, rw := range streamBuf {
+169
View File
@@ -12,6 +12,7 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/credentials"
@@ -43,6 +44,12 @@ func newMultipartTestServer(t *testing.T, disableStreaming bool) (*minio.Core, f
// destination path is exercised as well as the temporary-object path that // destination path is exercised as well as the temporary-object path that
// local (PartialUploads=true) uses. // local (PartialUploads=true) uses.
func newMultipartTestServerBacking(t *testing.T, backing string, disableStreaming bool) (*minio.Core, fs.Fs, string) { func newMultipartTestServerBacking(t *testing.T, backing string, disableStreaming bool) (*minio.Core, fs.Fs, string) {
return newMultipartTestServerOpt(t, backing, disableStreaming, nil)
}
// newMultipartTestServerOpt is like newMultipartTestServerBacking but also
// applies tweak (if non-nil) to the server Options before starting it.
func newMultipartTestServerOpt(t *testing.T, backing string, disableStreaming bool, tweak func(*Options)) (*minio.Core, fs.Fs, string) {
fstest.Initialise() fstest.Initialise()
ctx := context.Background() ctx := context.Background()
if backing == "" { if backing == "" {
@@ -67,6 +74,9 @@ func newMultipartTestServerBacking(t *testing.T, backing string, disableStreamin
opt.DisableMultipartStreaming = disableStreaming opt.DisableMultipartStreaming = disableStreaming
opt.AuthKey = []string{fmt.Sprintf("%s,%s", keyid, keysec)} opt.AuthKey = []string{fmt.Sprintf("%s,%s", keyid, keysec)}
opt.HTTP.ListenAddr = []string{endpoint} opt.HTTP.ListenAddr = []string{endpoint}
if tweak != nil {
tweak(&opt)
}
w, err := newServer(ctx, f, &opt, &vfscommon.Opt, &proxy.Opt) w, err := newServer(ctx, f, &opt, &vfscommon.Opt, &proxy.Opt)
require.NoError(t, err) require.NoError(t, err)
go func() { _ = w.Serve() }() go func() { _ = w.Serve() }()
@@ -279,6 +289,165 @@ func TestMultipartAbortPreservesExisting(t *testing.T) {
} }
} }
// TestMultipartRetryAfterStreamed checks that a part which is uploaded again
// after its first copy has already been streamed to the backend - as a client
// whose HTTP request timed out will do when it retries - is accepted
// idempotently and doesn't fail the CompleteMultipartUpload.
func TestMultipartRetryAfterStreamed(t *testing.T) {
core, f, bucket := newMultipartTestServer(t, false)
ctx := context.Background()
const object = "retry.bin"
part1 := []byte(random.String(60 * 1024))
part2 := []byte(random.String(40 * 1024))
want := append(append([]byte(nil), part1...), part2...)
uploadID, err := core.NewMultipartUpload(ctx, bucket, object, minio.PutObjectOptions{})
require.NoError(t, err)
// Parts uploaded in order are streamed synchronously, so by the time
// PutObjectPart returns the part is already in the backend stream.
p1, err := core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(part1), int64(len(part1)), minio.PutObjectPartOptions{})
require.NoError(t, err)
p2, err := core.PutObjectPart(ctx, bucket, object, uploadID, 2, bytes.NewReader(part2), int64(len(part2)), minio.PutObjectPartOptions{})
require.NoError(t, err)
// Retry part 1 with identical content, as a client retrying after a
// timeout does.
p1retry, err := core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(part1), int64(len(part1)), minio.PutObjectPartOptions{})
require.NoError(t, err)
assert.Equal(t, p1.ETag, p1retry.ETag)
_, err = core.CompleteMultipartUpload(ctx, bucket, object, uploadID, []minio.CompletePart{
{PartNumber: 1, ETag: p1.ETag},
{PartNumber: 2, ETag: p2.ETag},
}, minio.PutObjectOptions{})
require.NoError(t, err)
assert.Equal(t, want, readObject(t, f, bucket, object))
}
// TestMultipartRetryDifferentContent checks that re-uploading a part with
// different content after the first copy has been streamed is rejected - the
// in-order stream can't replace data already sent to the backend.
func TestMultipartRetryDifferentContent(t *testing.T) {
core, _, bucket := newMultipartTestServer(t, false)
ctx := context.Background()
const object = "retry-different.bin"
uploadID, err := core.NewMultipartUpload(ctx, bucket, object, minio.PutObjectOptions{})
require.NoError(t, err)
part1 := []byte(random.String(60 * 1024))
_, err = core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(part1), int64(len(part1)), minio.PutObjectPartOptions{})
require.NoError(t, err)
other := []byte(random.String(60 * 1024))
_, err = core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(other), int64(len(other)), minio.PutObjectPartOptions{})
require.Error(t, err)
require.NoError(t, core.AbortMultipartUpload(ctx, bucket, object, uploadID))
}
// TestMultipartReplaceBuffered checks that re-uploading a part which has been
// received but not yet streamed (it is waiting for an earlier part) replaces
// the buffered copy, matching S3's last-write-wins semantics.
func TestMultipartReplaceBuffered(t *testing.T) {
core, f, bucket := newMultipartTestServer(t, false)
ctx := context.Background()
const object = "replace-buffered.bin"
uploadID, err := core.NewMultipartUpload(ctx, bucket, object, minio.PutObjectOptions{})
require.NoError(t, err)
// Part 2 arrives first so it is buffered awaiting part 1.
old := []byte(random.String(40 * 1024))
_, err = core.PutObjectPart(ctx, bucket, object, uploadID, 2, bytes.NewReader(old), int64(len(old)), minio.PutObjectPartOptions{})
require.NoError(t, err)
// Upload part 2 again with different content - the buffered copy must be
// replaced.
part2 := []byte(random.String(40 * 1024))
p2, err := core.PutObjectPart(ctx, bucket, object, uploadID, 2, bytes.NewReader(part2), int64(len(part2)), minio.PutObjectPartOptions{})
require.NoError(t, err)
part1 := []byte(random.String(60 * 1024))
p1, err := core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(part1), int64(len(part1)), minio.PutObjectPartOptions{})
require.NoError(t, err)
_, err = core.CompleteMultipartUpload(ctx, bucket, object, uploadID, []minio.CompletePart{
{PartNumber: 1, ETag: p1.ETag},
{PartNumber: 2, ETag: p2.ETag},
}, minio.PutObjectOptions{})
require.NoError(t, err)
want := append(append([]byte(nil), part1...), part2...)
assert.Equal(t, want, readObject(t, f, bucket, object))
}
// TestMultipartBufferLimit checks that a part arriving ahead of its turn
// blocks once the reorder buffer limit is reached, and is admitted once the
// missing part arrives and the buffer drains.
func TestMultipartBufferLimit(t *testing.T) {
core, f, bucket := newMultipartTestServerOpt(t, "", false, func(opt *Options) {
opt.MultipartStreamingBufferLimit = 50 * 1024
})
ctx := context.Background()
const object = "buffer-limit.bin"
sizes := []int{40 * 1024, 40 * 1024, 40 * 1024}
datas := make([][]byte, len(sizes))
var want []byte
for i, sz := range sizes {
datas[i] = []byte(random.String(sz))
want = append(want, datas[i]...)
}
uploadID, err := core.NewMultipartUpload(ctx, bucket, object, minio.PutObjectOptions{})
require.NoError(t, err)
// Part 3 arrives first: the buffer is empty so it is admitted even though
// it must wait for its turn.
p3, err := core.PutObjectPart(ctx, bucket, object, uploadID, 3, bytes.NewReader(datas[2]), int64(sizes[2]), minio.PutObjectPartOptions{})
require.NoError(t, err)
// Part 2 would take the buffer over the limit, so it must block until
// part 1 arrives and the stream drains.
type partResult struct {
part minio.ObjectPart
err error
}
done := make(chan partResult, 1)
go func() {
p, err := core.PutObjectPart(ctx, bucket, object, uploadID, 2, bytes.NewReader(datas[1]), int64(sizes[1]), minio.PutObjectPartOptions{})
done <- partResult{p, err}
}()
select {
case r := <-done:
t.Fatalf("part 2 was not blocked by the buffer limit (err=%v)", r.err)
case <-time.After(200 * time.Millisecond):
}
// Part 1 is the part the stream needs so it is admitted regardless of the
// limit; streaming it frees the buffer and unblocks part 2.
p1, err := core.PutObjectPart(ctx, bucket, object, uploadID, 1, bytes.NewReader(datas[0]), int64(sizes[0]), minio.PutObjectPartOptions{})
require.NoError(t, err)
var p2 minio.ObjectPart
select {
case r := <-done:
require.NoError(t, r.err)
p2 = r.part
case <-time.After(10 * time.Second):
t.Fatal("part 2 was never unblocked")
}
_, err = core.CompleteMultipartUpload(ctx, bucket, object, uploadID, []minio.CompletePart{
{PartNumber: 1, ETag: p1.ETag},
{PartNumber: 2, ETag: p2.ETag},
{PartNumber: 3, ETag: p3.ETag},
}, minio.PutObjectOptions{})
require.NoError(t, err)
assert.Equal(t, want, readObject(t, f, bucket, object))
}
// TestMultipartOverwrite checks that a completed multipart upload atomically // TestMultipartOverwrite checks that a completed multipart upload atomically
// replaces an existing object of the same name. // replaces an existing object of the same name.
func TestMultipartOverwrite(t *testing.T) { func TestMultipartOverwrite(t *testing.T) {
+12 -7
View File
@@ -41,6 +41,10 @@ var OptionsInfo = fs.Options{{
Name: "disable_multipart_streaming", Name: "disable_multipart_streaming",
Default: false, Default: false,
Help: "Buffer multipart uploads in memory instead of streaming them to the backend (see the Multipart uploads docs section)", Help: "Buffer multipart uploads in memory instead of streaming them to the backend (see the Multipart uploads docs section)",
}, {
Name: "multipart_streaming_buffer_limit",
Default: fs.SizeSuffix(256 * 1024 * 1024),
Help: "Maximum memory buffered per streamed multipart upload for parts arriving out of order, 0 for unlimited (see the Multipart uploads docs section)",
}}. }}.
Add(httplib.ConfigInfo). Add(httplib.ConfigInfo).
Add(httplib.AuthConfigInfo) Add(httplib.AuthConfigInfo)
@@ -48,13 +52,14 @@ var OptionsInfo = fs.Options{{
// Options contains options for the s3 Server // Options contains options for the s3 Server
type Options struct { type Options struct {
//TODO add more options //TODO add more options
ForcePathStyle bool `config:"force_path_style"` ForcePathStyle bool `config:"force_path_style"`
EtagHash string `config:"etag_hash"` EtagHash string `config:"etag_hash"`
AuthKey []string `config:"auth_key"` AuthKey []string `config:"auth_key"`
NoCleanup bool `config:"no_cleanup"` NoCleanup bool `config:"no_cleanup"`
DisableMultipartStreaming bool `config:"disable_multipart_streaming"` DisableMultipartStreaming bool `config:"disable_multipart_streaming"`
Auth httplib.AuthConfig MultipartStreamingBufferLimit fs.SizeSuffix `config:"multipart_streaming_buffer_limit"`
HTTP httplib.Config Auth httplib.AuthConfig
HTTP httplib.Config
} }
// Opt is options set by command line flags // Opt is options set by command line flags
+19 -8
View File
@@ -129,14 +129,25 @@ also need to support a server-side move or copy.
- Parts must arrive in ascending, contiguous part-number order - Parts must arrive in ascending, contiguous part-number order
(1, 2, 3, ...). Parts the client uploads concurrently or out of order (1, 2, 3, ...). Parts the client uploads concurrently or out of order
are buffered until their turn, so higher client upload concurrency are buffered until their turn. The memory used for this buffering is
uses more memory; non-contiguous part numbers are rejected. Configure capped, per upload, by `--multipart-streaming-buffer-limit` (default
the client to upload in part order, ideally with low concurrency, for `256M`, `0` for no limit): a part that would take the buffer over the
the lowest memory use. limit is stalled until the stream drains, so a client that uploads
- No per-part retry. Once a part has been streamed it is committed, so a faster than the remote can accept sees backpressure rather than
failure partway through aborts the whole upload and the client must unbounded server memory use. Since a stalled part holds its HTTP
start it again, rather than retrying a single part. (The remote's own request open, clients whose upload concurrency times chunk size
upload still retries its internal chunks.) exceeds the limit may need a longer read timeout when the remote is
slow. Non-contiguous part numbers are rejected on completion.
Configure the client to upload in part order, ideally with low
concurrency, for the lowest memory use.
- A part uploaded again before completion - typically a client retrying
after a timeout - is accepted: if the earlier copy is still buffered
it is replaced, and if it has already been streamed an identical
re-upload is a no-op. What isn't possible is replacing a part that has
already been streamed with *different* content - that is rejected. A
failure in the stream to the remote itself still aborts the whole
upload and the client must start it again. (The remote's own upload
still retries its internal chunks.)
- Parts are serialised into one stream, so ingest from the client is - Parts are serialised into one stream, so ingest from the client is
effectively single-threaded, although the remote's own upload still effectively single-threaded, although the remote's own upload still
runs concurrently. runs concurrently.