Files
LocalAI/core/services/addons/hostops/transfer.go
T
Ettore Di Giacinto 4ba1b7b2bd feat(addons): move connector file bytes by handle
A connector add-on that declares files.transfer no longer carries file
bytes in the connector envelope. The host puts a random handle in the
request of the op media (write) or send_file (read), and the add-on
streams the file with two new host operations, connector.file.put and
connector.file.get, in chunks of at most 256 KiB.

The process that runs the add-on registers the handle when the call
arrives and ends it when the call returns. A handle is bound to the
add-on, so another add-on gets the same answer as for an unknown
handle. An unfinished inbound file is dropped with its partial bytes,
and a handle with no call for two minutes expires. Inbound bytes go
to the blob store of the workspace, outbound bytes come from it, so a
cluster needs the shared object store and no shared disk. The file is
never held in memory. The host overwrites the size and hash of an
inbound file with its own record.

The change is additive: the field files.transfer needs manifest API 2,
a kind without it keeps the inline path and its 1 MiB cap, and the host
never sends it a handle. A call that carries a handle may run up to 15
minutes. The ceiling of a file also stays under the largest file of a
workspace.

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 15:54:45 +00:00

451 lines
13 KiB
Go

// SPDX-License-Identifier: MIT
package hostops
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/core/services/studio/workspace/blob"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Direction of a transfer, from the host.
const (
// TransferIn moves a file from an add-on to the host: the add-on puts.
TransferIn = "in"
// TransferOut moves a file from the host to an add-on: the add-on gets.
TransferOut = "out"
)
// Results of a transfer, as the audit shows them.
const (
TransferDone = "done"
TransferTooLarge = "too_large"
TransferExpired = "expired"
TransferRefused = "refused"
TransferIncomplete = "incomplete"
)
// Default times of a handle.
const (
// DefaultTransferIdle ends a handle that has seen no call for this long.
DefaultTransferIdle = 2 * time.Minute
// DefaultTransferLife ends a handle this long after it was made, whatever
// it does. A connector call is shorter than this.
DefaultTransferLife = time.Hour
)
// ErrTransferTooLarge means a put went past the bound of its handle.
var ErrTransferTooLarge = errors.New("the file is larger than the limit of the handle")
// TransferGrant is what the host that issued a handle records about it. The
// owner of the add-ons registers it when a connector call that carries a
// ConnectorTransfer arrives.
type TransferGrant struct {
Handle string
Direction string
// AddonID is the only add-on that may use the handle.
AddonID string
// BindingID is for the audit.
BindingID string
// MaxBytes bounds an inbound file.
MaxBytes int64
// SHA and Size name the outbound content, a blob of the workspace store.
SHA string
Size int64
}
// TransferOutcome is what a handle ended with. It never holds a name or any
// content.
type TransferOutcome struct {
Direction string
BindingID string
Bytes int64
Result string
Duration time.Duration
// SHA is the hash of a finished inbound file.
SHA string
}
// FileScan is the hook after the last byte of an inbound file. An error
// rejects the file. It is a no-op until an owner sets one.
type FileScan func(ctx context.Context, sha string, size int64) error
// Transfers is the table of live handles of one process, and the bytes behind
// them. Inbound bytes stream into the blob store of the workspace, which in a
// cluster is the shared object store, so no node needs a shared disk. Outbound
// bytes stream out of the same store. A file is never held in memory: a put
// or a get moves one chunk.
type Transfers struct {
// Blobs is the blob store of the workspace. UseBlobs sets it when the
// files start, which can be after the table is made.
Blobs blob.Store
Scan FileScan
// Audit gets the outcome of every handle when it ends. Nil: none.
Audit func(TransferOutcome)
// Idle and Life default to DefaultTransferIdle and DefaultTransferLife.
Idle, Life time.Duration
mu sync.Mutex
m map[string]*xfer
}
type xfer struct {
t *Transfers
g TransferGrant
start time.Time
mu sync.Mutex // one call at a time per handle
received int64
pw *io.PipeWriter
done chan struct{} // closed when the blob Put of an inbound handle returned
sha string
size int64
putErr error
final bool
rd io.ReadSeekCloser
cancel context.CancelFunc
idle *time.Timer
life *time.Timer
ended bool
result string
}
// NewTransfers builds a table over the blob store of the workspace.
func NewTransfers(blobs blob.Store) *Transfers {
return &Transfers{Blobs: blobs, m: map[string]*xfer{}}
}
// UseBlobs sets the blob store. Handles that begin before it is set are
// refused as unavailable.
func (t *Transfers) UseBlobs(b blob.Store) {
t.mu.Lock()
t.Blobs = b
t.mu.Unlock()
}
func (t *Transfers) store() blob.Store {
t.mu.Lock()
defer t.mu.Unlock()
return t.Blobs
}
func (t *Transfers) idle() time.Duration {
if t.Idle > 0 {
return t.Idle
}
return DefaultTransferIdle
}
func (t *Transfers) life() time.Duration {
if t.Life > 0 {
return t.Life
}
return DefaultTransferLife
}
// Begin registers a handle. The returned function ends it: it drops a file
// that is not finished and reports the outcome. For a finished inbound file the
// outcome holds the hash and the size. Calling it again returns the same
// outcome. A handle that is already registered is refused, so a handle is
// never used for two calls.
func (t *Transfers) Begin(g TransferGrant) (end func() TransferOutcome, err error) {
if t == nil || t.store() == nil {
return nil, errors.New("file transfer is not available here")
}
if g.Handle == "" || len(g.Handle) > contract.MaxTransferHandle || g.AddonID == "" {
return nil, fmt.Errorf("%w: bad handle", ErrBadRequest)
}
switch g.Direction {
case TransferIn:
if g.MaxBytes <= 0 {
return nil, fmt.Errorf("%w: no bound", ErrBadRequest)
}
case TransferOut:
if g.SHA == "" || g.Size <= 0 {
return nil, fmt.Errorf("%w: no content", ErrBadRequest)
}
default:
return nil, fmt.Errorf("%w: bad direction", ErrBadRequest)
}
x := &xfer{t: t, g: g, start: time.Now()}
t.mu.Lock()
if t.m == nil {
t.m = map[string]*xfer{}
}
if _, dup := t.m[g.Handle]; dup {
t.mu.Unlock()
return nil, fmt.Errorf("%w: handle in use", ErrConflict)
}
t.m[g.Handle] = x
t.mu.Unlock()
x.idle = time.AfterFunc(t.idle(), func() { x.finish(TransferExpired) })
x.life = time.AfterFunc(t.life(), func() { x.finish(TransferExpired) })
return func() TransferOutcome { return x.finish("") }, nil
}
// Live reports how many handles exist; a test and the sweep use it.
func (t *Transfers) Live() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.m)
}
func (t *Transfers) lookup(addonID, handle string) (*xfer, error) {
t.mu.Lock()
x := t.m[handle]
t.mu.Unlock()
// The same answer for a handle that does not exist and one of another
// add-on: a handle cannot be probed.
if x == nil || subtle.ConstantTimeCompare([]byte(x.g.AddonID), []byte(addonID)) != 1 {
return nil, ErrNotFound
}
return x, nil
}
// finish ends the handle once. why is the result when the handle did not end
// by its own end function.
func (x *xfer) finish(why string) TransferOutcome {
x.idle.Stop()
x.life.Stop()
// A put or a get in progress holds mu; an expiry must not wait for a
// stuck one, so it cancels first.
if x.cancel != nil {
x.cancel()
}
if x.pw != nil {
_ = x.pw.CloseWithError(context.Canceled)
}
x.mu.Lock()
defer x.mu.Unlock()
if x.ended {
return x.outcome()
}
x.ended = true
x.t.mu.Lock()
delete(x.t.m, x.g.Handle)
x.t.mu.Unlock()
if x.rd != nil {
_ = x.rd.Close()
}
if x.done != nil {
<-x.done // the blob Put drops its temp file when its reader fails
}
switch {
case x.result != "":
case why != "":
x.result = why
case x.g.Direction == TransferIn && !x.final:
x.result = TransferIncomplete
default:
x.result = TransferDone
}
o := x.outcome()
if x.t.Audit != nil {
x.t.Audit(o)
}
return o
}
func (x *xfer) outcome() TransferOutcome {
o := TransferOutcome{Direction: x.g.Direction, BindingID: x.g.BindingID, Bytes: x.received, Result: x.result, Duration: time.Since(x.start)}
if x.g.Direction == TransferOut {
o.Bytes = x.size
}
if x.final && x.result != TransferTooLarge && x.result != TransferRefused {
o.SHA = x.sha
}
return o
}
func (x *xfer) touch() { x.idle.Reset(x.t.idle()) }
// put writes the next chunk of an inbound file.
func (x *xfer) put(ctx context.Context, p contract.ConnectorFilePut) (int64, error) {
if x.g.Direction != TransferIn {
return 0, ErrNotFound
}
x.mu.Lock()
defer x.mu.Unlock()
if x.ended || x.final {
return 0, ErrConflict
}
x.touch()
if len(p.Data) > contract.TransferChunkBytes {
return 0, fmt.Errorf("%w: chunk too large", ErrBadRequest)
}
if p.Offset != x.received {
// The add-on resumes where the host is.
return x.received, nil
}
if x.received+int64(len(p.Data)) > x.g.MaxBytes {
x.result = TransferTooLarge
x.abortLocked()
return x.received, ErrTransferTooLarge
}
if x.pw == nil {
pr, pw := io.Pipe()
bctx, cancel := context.WithCancel(context.Background())
x.pw, x.cancel, x.done = pw, cancel, make(chan struct{})
go func() {
defer close(x.done)
sha, size, err := x.t.store().Put(bctx, pr, x.g.MaxBytes)
x.sha, x.size, x.putErr = sha, size, err
_ = pr.CloseWithError(errors.New("closed"))
}()
}
if len(p.Data) > 0 {
if _, err := io.Copy(x.pw, bytes.NewReader(p.Data)); err != nil {
return x.received, x.failedLocked(err)
}
x.received += int64(len(p.Data))
}
if !p.Final {
return x.received, nil
}
if x.received == 0 {
x.result = TransferRefused
x.abortLocked()
return 0, fmt.Errorf("%w: an empty file", ErrBadRequest)
}
_ = x.pw.Close()
<-x.done
switch {
case errors.Is(x.putErr, workspace.ErrTooLarge):
x.result = TransferTooLarge
return x.received, ErrTransferTooLarge
case x.putErr != nil:
return x.received, x.putErr
case x.size != x.received:
return x.received, errors.New("the stored size differs from the bytes received")
}
if x.t.Scan != nil {
if err := x.t.Scan(ctx, x.sha, x.size); err != nil {
x.result = TransferRefused
return x.received, fmt.Errorf("%w: the file was refused", ErrBadRequest)
}
}
x.final = true
return x.received, nil
}
// failedLocked turns a failed write into the error of the blob store, which
// ended the pipe, so that a bound reached inside the store is too large.
func (x *xfer) failedLocked(err error) error {
<-x.done
if errors.Is(x.putErr, workspace.ErrTooLarge) {
x.result = TransferTooLarge
return ErrTransferTooLarge
}
if x.putErr != nil {
return x.putErr
}
return err
}
func (x *xfer) abortLocked() {
if x.pw != nil {
_ = x.pw.CloseWithError(context.Canceled)
if x.cancel != nil {
x.cancel()
}
<-x.done
}
}
// get reads the next chunk of an outbound file.
func (x *xfer) get(ctx context.Context, g contract.ConnectorFileGet) (contract.ConnectorFileGetResult, error) {
if x.g.Direction != TransferOut {
return contract.ConnectorFileGetResult{}, ErrNotFound
}
x.mu.Lock()
defer x.mu.Unlock()
if x.ended {
return contract.ConnectorFileGetResult{}, ErrConflict
}
x.touch()
if g.Offset < 0 || g.Offset > x.g.Size || g.Length < 1 || g.Length > contract.TransferChunkBytes {
return contract.ConnectorFileGetResult{}, fmt.Errorf("%w: offset or length", ErrBadRequest)
}
if x.rd == nil {
r, err := x.t.store().Open(ctx, x.g.SHA)
if err != nil {
return contract.ConnectorFileGetResult{}, err
}
n, err := r.Seek(0, io.SeekEnd)
if err != nil || n != x.g.Size {
_ = r.Close()
return contract.ConnectorFileGetResult{}, errors.New("the file changed since the handle was made")
}
x.rd, x.size = r, n
}
if _, err := x.rd.Seek(g.Offset, io.SeekStart); err != nil {
return contract.ConnectorFileGetResult{}, err
}
buf := make([]byte, min(g.Length, x.g.Size-g.Offset))
n, err := io.ReadFull(x.rd, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return contract.ConnectorFileGetResult{}, err
}
buf = buf[:n]
return contract.ConnectorFileGetResult{Data: buf, EOF: g.Offset+int64(n) >= x.g.Size}, nil
}
// RegisterConnectorTransfer registers the two operations with which a
// connector add-on moves file bytes by handle. Both are inbound class: the
// credential of the live incarnation authorizes them, and the handle binds
// them to one connector call. The arguments name no user.
func RegisterConnectorTransfer(b *Broker, t *Transfers) error {
if t == nil {
return errors.New("connector file transfer needs a table")
}
decode := func(raw json.RawMessage, v any) error {
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
return ErrBadRequest
}
return nil
}
if err := b.Register(Spec{Op: contract.ConnectorFilePutOp, Scope: ScopeInbound, Timeout: 2 * time.Minute, Handler: func(ctx context.Context, c Call) (json.RawMessage, error) {
var p contract.ConnectorFilePut
if err := decode(c.Arguments, &p); err != nil {
return nil, err
}
x, err := t.lookup(c.AddonID, p.Handle)
if err != nil {
return nil, err
}
got, err := x.put(ctx, p)
if err != nil {
return nil, err
}
return json.Marshal(contract.ConnectorFilePutResult{Received: got})
}}); err != nil {
return err
}
return b.Register(Spec{Op: contract.ConnectorFileGetOp, Scope: ScopeInbound, Timeout: 2 * time.Minute, Handler: func(ctx context.Context, c Call) (json.RawMessage, error) {
var g contract.ConnectorFileGet
if err := decode(c.Arguments, &g); err != nil {
return nil, err
}
x, err := t.lookup(c.AddonID, g.Handle)
if err != nil {
return nil, err
}
r, err := x.get(ctx, g)
if err != nil {
return nil, err
}
return json.Marshal(r)
}})
}