Skip to content

Commit 6ff463f

Browse files
committed
feat(139): add an opt-in parallel upload mode for personal_new
The official desktop client uploads with `parallelUpload: true` and gives every part the SHA-256 midstate reached at that part's offset. The server signs that context into the part's presigned URL, so each storage node can verify its own part independently and the parts no longer have to arrive in order. The driver already serialized a `parallelHashCtx`, but only ever filled in `partOffset` and always sent `parallelUpload: false`, so the parts had to be read in order off one shared reader and uploaded one at a time. Add a `parallel_upload` option that switches to the client's flow. The hash context is the 8 SHA-256 state registers, which crypto/sha256 already exposes through encoding.BinaryMarshaler, so the checkpoints are collected during the same pass that computes the whole-file hash. Parts are then uploaded concurrently with `upload_thread` workers, each reading its own section of the cached file. The existing serial path is untouched and stays the default. Two constraints come with the new mode: the file is always buffered to the temp dir, because concurrent uploads need random access, and the part size has to be a multiple of 64 bytes, because a checkpoint that falls inside a SHA-256 block cannot be expressed by the state registers alone.
1 parent 8d77000 commit 6ff463f

4 files changed

Lines changed: 467 additions & 0 deletions

File tree

drivers/139/driver.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@ func (d *Yun139) getPartSize(size int64) int64 {
513513
func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
514514
switch d.Addition.Type {
515515
case MetaPersonalNew:
516+
if d.ParallelUpload {
517+
return d.putPersonalNewParallel(ctx, dstDir, stream, up)
518+
}
516519
var err error
517520
fullHash := stream.GetHash().GetHash(utils.SHA256)
518521
if len(fullHash) != utils.SHA256.Width {

drivers/139/meta.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type Addition struct {
1818
CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0"`
1919
ReportRealSize bool `json:"report_real_size" type:"bool" default:"true"`
2020
UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false"`
21+
ParallelUpload bool `json:"parallel_upload" type:"bool" default:"false" help:"personal_new only. Upload parts concurrently the way the official client does. Always buffers the file to the temp dir first, and needs a part size that is a multiple of 64 bytes."`
22+
UploadThread int `json:"upload_thread" type:"number" default:"3" help:"Number of concurrent part uploads, only used when parallel_upload is enabled."`
2123
}
2224

2325
var config = driver.Config{

drivers/139/upload_parallel.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package _139
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding"
7+
"encoding/binary"
8+
"encoding/hex"
9+
"errors"
10+
"fmt"
11+
"hash"
12+
"io"
13+
"net/http"
14+
"sync"
15+
"time"
16+
17+
"github.com/alist-org/alist/v3/drivers/base"
18+
"github.com/alist-org/alist/v3/internal/driver"
19+
"github.com/alist-org/alist/v3/internal/model"
20+
"github.com/alist-org/alist/v3/pkg/utils"
21+
"github.com/alist-org/alist/v3/pkg/utils/random"
22+
log "github.com/sirupsen/logrus"
23+
"golang.org/x/sync/errgroup"
24+
)
25+
26+
// 官方客户端在开启 parallelUpload 时,会为每个分片附带上传到该分片起点时的
27+
// SHA256 中间状态。服务端把它签进分片的上传地址(X-Amz-Iteration-Hash-Ctx),
28+
// 存储节点据此独立校验该分片,因此分片可以乱序并发上传。
29+
//
30+
// 这里单独定义一套请求结构,避免影响现有串行上传路径的报文。
31+
type parallelHashCtx struct {
32+
// H 是 SHA256 的 8 个状态寄存器
33+
H []uint32 `json:"h"`
34+
PartOffset int64 `json:"partOffset"`
35+
}
36+
37+
type parallelPartInfo struct {
38+
PartNumber int64 `json:"partNumber"`
39+
PartSize int64 `json:"partSize"`
40+
// 第一个分片从 SHA256 初始向量开始,不携带上下文
41+
ParallelHashCtx *parallelHashCtx `json:"parallelHashCtx,omitempty"`
42+
43+
offset int64
44+
}
45+
46+
// sha256MidstateLen 是 crypto/sha256 序列化状态的最小长度:4 字节 magic +
47+
// 8 个状态寄存器 + 末尾 8 字节长度。
48+
const sha256MidstateLen = 4 + 8*4 + 8
49+
50+
// sha256Midstate 导出 h 当前的 8 个状态寄存器,以及已经写入的字节数。
51+
func sha256Midstate(h hash.Hash) ([]uint32, int64, error) {
52+
m, ok := h.(encoding.BinaryMarshaler)
53+
if !ok {
54+
return nil, 0, errors.New("sha256 hash state is not exportable")
55+
}
56+
state, err := m.MarshalBinary()
57+
if err != nil {
58+
return nil, 0, err
59+
}
60+
if len(state) < sha256MidstateLen {
61+
return nil, 0, fmt.Errorf("unexpected sha256 state length %d", len(state))
62+
}
63+
regs := make([]uint32, 8)
64+
for i := range regs {
65+
regs[i] = binary.BigEndian.Uint32(state[4+i*4:])
66+
}
67+
return regs, int64(binary.BigEndian.Uint64(state[len(state)-8:])), nil
68+
}
69+
70+
// planParallelParts 顺序扫描一遍文件,同时得到整文件 SHA256 和每个分片起点的
71+
// 哈希中间状态。
72+
func planParallelParts(f model.File, size, partSize int64) (string, []parallelPartInfo, error) {
73+
// 分片边界必须落在 SHA256 的分组边界上,否则未满一组的字节还留在缓冲区里,
74+
// 只导出状态寄存器会把它们丢掉。
75+
if partSize%64 != 0 {
76+
return "", nil, fmt.Errorf("part size %d is not a multiple of 64 bytes", partSize)
77+
}
78+
h := sha256.New()
79+
buf := make([]byte, 1024*1024)
80+
var partInfos []parallelPartInfo
81+
82+
for offset, partNumber := int64(0), int64(1); offset < size; partNumber++ {
83+
byteSize := size - offset
84+
if byteSize > partSize {
85+
byteSize = partSize
86+
}
87+
partInfo := parallelPartInfo{
88+
PartNumber: partNumber,
89+
PartSize: byteSize,
90+
offset: offset,
91+
}
92+
if partNumber > 1 {
93+
regs, hashed, err := sha256Midstate(h)
94+
if err != nil {
95+
return "", nil, err
96+
}
97+
// 分片大小不是 64 的整数倍时中间状态无法只用寄存器表达,此处再校验一次
98+
if hashed != offset {
99+
return "", nil, fmt.Errorf("sha256 state covers %d bytes, expected %d", hashed, offset)
100+
}
101+
partInfo.ParallelHashCtx = &parallelHashCtx{H: regs, PartOffset: offset}
102+
}
103+
partInfos = append(partInfos, partInfo)
104+
105+
if _, err := io.CopyBuffer(h, io.NewSectionReader(f, offset, byteSize), buf); err != nil {
106+
return "", nil, err
107+
}
108+
offset += byteSize
109+
}
110+
if len(partInfos) == 0 {
111+
partInfos = append(partInfos, parallelPartInfo{PartNumber: 1})
112+
}
113+
return hex.EncodeToString(h.Sum(nil)), partInfos, nil
114+
}
115+
116+
// parallelProgress 汇总所有并发分片已上传的字节数。回调本身不保证并发安全,
117+
// 因此上报要串行化。
118+
type parallelProgress struct {
119+
total int64
120+
done int64
121+
mu sync.Mutex
122+
up driver.UpdateProgress
123+
}
124+
125+
func (p *parallelProgress) add(n int64) {
126+
if p.total <= 0 {
127+
return
128+
}
129+
p.mu.Lock()
130+
defer p.mu.Unlock()
131+
p.done += n
132+
percentage := float64(p.done) / float64(p.total) * 100
133+
if percentage > 100 {
134+
percentage = 100
135+
}
136+
p.up(percentage)
137+
}
138+
139+
type parallelProgressReader struct {
140+
io.Reader
141+
progress *parallelProgress
142+
}
143+
144+
func (r *parallelProgressReader) Read(p []byte) (int, error) {
145+
n, err := r.Reader.Read(p)
146+
if n > 0 {
147+
r.progress.add(int64(n))
148+
}
149+
return n, err
150+
}
151+
152+
// putPersonalNewParallel 走官方客户端的并发上传流程,仅在 parallel_upload
153+
// 打开时使用。串行上传逻辑保持不变。
154+
func (d *Yun139) putPersonalNewParallel(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
155+
size := stream.GetSize()
156+
partSize := d.getPartSize(size)
157+
if partSize%64 != 0 {
158+
return fmt.Errorf("parallel upload needs a part size that is a multiple of 64 bytes, got %d", partSize)
159+
}
160+
161+
// 并发上传需要随机读取,分片哈希上下文也必须在本地先算出来
162+
tmpF, err := stream.CacheFullInTempFile()
163+
if err != nil {
164+
return err
165+
}
166+
167+
fullHash, partInfos, err := planParallelParts(tmpF, size, partSize)
168+
if err != nil {
169+
return err
170+
}
171+
172+
// 筛选出前 100 个 partInfos
173+
firstPartInfos := partInfos
174+
if len(firstPartInfos) > 100 {
175+
firstPartInfos = firstPartInfos[:100]
176+
}
177+
178+
// 创建任务,获取上传信息和前100个分片的上传地址
179+
data := base.Json{
180+
"contentHash": fullHash,
181+
"contentHashAlgorithm": "SHA256",
182+
"contentType": "application/octet-stream",
183+
"parallelUpload": true,
184+
"partInfos": firstPartInfos,
185+
"size": size,
186+
"parentFileId": dstDir.GetID(),
187+
"name": stream.GetName(),
188+
"type": "file",
189+
"fileRenameMode": "auto_rename",
190+
}
191+
var resp PersonalUploadResp
192+
if _, err = d.personalPost("/file/create", data, &resp); err != nil {
193+
return err
194+
}
195+
196+
// 已存在同名同校验的文件,云端不会重复增加
197+
if resp.Data.Exist {
198+
return nil
199+
}
200+
201+
// 没有返回分片上传地址即命中快传
202+
if resp.Data.PartInfos != nil {
203+
uploadPartInfos := resp.Data.PartInfos
204+
205+
// 获取后续分片的上传地址
206+
for i := 100; i < len(partInfos); i += 100 {
207+
end := i + 100
208+
if end > len(partInfos) {
209+
end = len(partInfos)
210+
}
211+
moredata := base.Json{
212+
"fileId": resp.Data.FileId,
213+
"uploadId": resp.Data.UploadId,
214+
"partInfos": partInfos[i:end],
215+
"commonAccountInfo": base.Json{
216+
"account": d.getAccount(),
217+
"accountType": 1,
218+
},
219+
}
220+
var moreresp PersonalUploadUrlResp
221+
if _, err = d.personalPost("/file/getUploadUrl", moredata, &moreresp); err != nil {
222+
return err
223+
}
224+
uploadPartInfos = append(uploadPartInfos, moreresp.Data.PartInfos...)
225+
}
226+
227+
uploadThread := d.UploadThread
228+
if uploadThread <= 0 {
229+
uploadThread = 3
230+
}
231+
if uploadThread > len(uploadPartInfos) {
232+
uploadThread = len(uploadPartInfos)
233+
}
234+
235+
progress := &parallelProgress{total: size, up: up}
236+
threadG, uploadCtx := errgroup.WithContext(ctx)
237+
threadG.SetLimit(uploadThread)
238+
for _, uploadPartInfo := range uploadPartInfos {
239+
if utils.IsCanceled(uploadCtx) {
240+
break
241+
}
242+
threadG.Go(func() error {
243+
index := uploadPartInfo.PartNumber - 1
244+
if index < 0 || index >= len(partInfos) {
245+
return fmt.Errorf("server returned unknown part number %d", uploadPartInfo.PartNumber)
246+
}
247+
part := partInfos[index]
248+
log.Debugf("[139] uploading part %+v/%+v", index, len(uploadPartInfos))
249+
250+
reader := &parallelProgressReader{
251+
Reader: io.NewSectionReader(tmpF, part.offset, part.PartSize),
252+
progress: progress,
253+
}
254+
req, err := http.NewRequestWithContext(uploadCtx, http.MethodPut, uploadPartInfo.UploadUrl,
255+
driver.NewLimitedUploadStream(uploadCtx, reader))
256+
if err != nil {
257+
return err
258+
}
259+
req.Header.Set("Content-Type", "application/octet-stream")
260+
req.Header.Set("Origin", "https://yun.139.com")
261+
req.Header.Set("Referer", "https://yun.139.com/")
262+
req.ContentLength = part.PartSize
263+
264+
res, err := base.HttpClient.Do(req)
265+
if err != nil {
266+
return err
267+
}
268+
defer res.Body.Close()
269+
_, _ = io.Copy(io.Discard, res.Body)
270+
if res.StatusCode != http.StatusOK {
271+
return fmt.Errorf("part %d: unexpected status code: %d", uploadPartInfo.PartNumber, res.StatusCode)
272+
}
273+
return nil
274+
})
275+
}
276+
if err = threadG.Wait(); err != nil {
277+
return err
278+
}
279+
if err = ctx.Err(); err != nil {
280+
return err
281+
}
282+
283+
data = base.Json{
284+
"contentHash": fullHash,
285+
"contentHashAlgorithm": "SHA256",
286+
"fileId": resp.Data.FileId,
287+
"uploadId": resp.Data.UploadId,
288+
}
289+
if _, err = d.personalPost("/file/complete", data, nil); err != nil {
290+
return err
291+
}
292+
}
293+
294+
return d.resolveUploadRename(ctx, dstDir, stream, resp.Data.FileName)
295+
}
296+
297+
// resolveUploadRename 处理 auto_rename 导致的重名:删除旧文件,再把新文件改回
298+
// 原名。与串行路径中的冲突处理保持一致。
299+
func (d *Yun139) resolveUploadRename(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, serverFileName string) error {
300+
if serverFileName == stream.GetName() {
301+
return nil
302+
}
303+
log.Debugf("[139] conflict detected: %s != %s", serverFileName, stream.GetName())
304+
// 给服务器一定时间处理数据,避免无法刷新文件列表
305+
time.Sleep(time.Millisecond * 500)
306+
files, err := d.List(ctx, dstDir, model.ListArgs{Refresh: true})
307+
if err != nil {
308+
return err
309+
}
310+
// 删除旧文件
311+
for _, file := range files {
312+
if file.GetName() == stream.GetName() {
313+
log.Debugf("[139] conflict: removing old: %s", file.GetName())
314+
// 删除前重命名旧文件,避免仍旧冲突
315+
if err = d.Rename(ctx, file, stream.GetName()+random.String(4)); err != nil {
316+
return err
317+
}
318+
if err = d.Remove(ctx, file); err != nil {
319+
return err
320+
}
321+
break
322+
}
323+
}
324+
// 重命名新文件
325+
for _, file := range files {
326+
if file.GetName() == serverFileName {
327+
log.Debugf("[139] conflict: renaming new: %s => %s", file.GetName(), stream.GetName())
328+
if err = d.Rename(ctx, file, stream.GetName()); err != nil {
329+
return err
330+
}
331+
break
332+
}
333+
}
334+
return nil
335+
}

0 commit comments

Comments
 (0)