feat(tdler): resumable chunked download with block bitmap

This commit is contained in:
krau
2026-08-25 15:05:49 +08:00
parent 784888f44c
commit 54dc4caafe
4 changed files with 557 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
package tdler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"sync"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"golang.org/x/sync/errgroup"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
const maxChunkRetries = 20
// resumeBitmap records which partSize-aligned blocks of a download have been
// durably written, so an interrupted download can continue after a restart.
// The bitmap file is rewritten atomically after every completed block.
type resumeBitmap struct {
PartSize int `json:"part_size"`
Size int64 `json:"size"`
Blocks []uint64 `json:"blocks"`
mu sync.Mutex
}
func newResumeBitmap(size int64) *resumeBitmap {
b := &resumeBitmap{PartSize: tglimit.MaxPartSize, Size: size}
b.ensureBlocks()
return b
}
func (b *resumeBitmap) ensureBlocks() {
if need := (b.blockCount() + 63) / 64; len(b.Blocks) < need {
b.Blocks = make([]uint64, need)
}
}
func (b *resumeBitmap) blockCount() int {
return int((b.Size + int64(b.PartSize) - 1) / int64(b.PartSize))
}
func (b *resumeBitmap) isDone(block int) bool {
return b.Blocks[block/64]&(1<<uint(block%64)) != 0
}
func (b *resumeBitmap) markDone(block int) {
b.Blocks[block/64] |= 1 << uint(block%64)
}
func (b *resumeBitmap) complete() bool {
for block := 0; block < b.blockCount(); block++ {
if !b.isDone(block) {
return false
}
}
return true
}
func (b *resumeBitmap) missingBlocks() []int {
missing := make([]int, 0, b.blockCount())
for block := 0; block < b.blockCount(); block++ {
if !b.isDone(block) {
missing = append(missing, block)
}
}
return missing
}
func loadResumeBitmap(path string) (*resumeBitmap, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read resume bitmap: %w", err)
}
var b resumeBitmap
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("parse resume bitmap: %w", err)
}
b.ensureBlocks()
return &b, nil
}
func (b *resumeBitmap) save(path string) error {
b.mu.Lock()
defer b.mu.Unlock()
return b.saveLocked(path)
}
func (b *resumeBitmap) saveLocked(path string) error {
data, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("marshal resume bitmap: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("write resume bitmap: %w", err)
}
return os.Rename(tmp, path)
}
func (b *resumeBitmap) markAndSave(block int, path string) error {
b.mu.Lock()
defer b.mu.Unlock()
b.markDone(block)
return b.saveLocked(path)
}
func isRetryableTimeout(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil {
return false
}
if tgerr.Is(err, tg.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
// fetchChunk downloads one partSize-aligned chunk, retrying flood waits and
// transient timeouts like gotd's downloader does.
func fetchChunk(ctx context.Context, file tfile.TGFile, offset int64, limit int) ([]byte, error) {
req := &tg.UploadGetFileRequest{
Location: file.Location(),
Offset: offset,
Limit: limit,
}
timeoutRetries := 0
for {
res, err := file.Dler().UploadGetFile(ctx, req)
if err == nil {
switch r := res.(type) {
case *tg.UploadFile:
return r.Bytes, nil
case *tg.UploadFileCDNRedirect:
return nil, fmt.Errorf("CDN redirect is not supported (dc %d)", r.DCID)
default:
return nil, fmt.Errorf("unexpected upload.getFile response %T", res)
}
}
if flood, ferr := tgerr.FloodWait(ctx, err); ferr != nil {
if flood {
// FloodWait already slept; retry.
continue
}
if isRetryableTimeout(ctx, ferr) {
timeoutRetries++
if timeoutRetries >= maxChunkRetries {
return nil, fmt.Errorf("get chunk at %d: retry limit reached: %w", offset, ferr)
}
continue
}
return nil, fmt.Errorf("get chunk at %d: %w", offset, ferr)
}
}
}
// DownloadResumable downloads file to w in partSize chunks, skipping blocks
// already recorded as complete in bitmapPath and persisting every completed
// block so an interrupted download can resume. A missing or incompatible
// bitmap starts a full download. Requires a known, non-zero file size.
func DownloadResumable(
ctx context.Context,
file tfile.TGFile,
w io.WriterAt,
threads int,
bitmapPath string,
) error {
if file.Size() <= 0 {
return fmt.Errorf("resumable download requires a known size")
}
bm, err := loadResumeBitmap(bitmapPath)
if err != nil {
return err
}
if bm == nil || bm.PartSize != tglimit.MaxPartSize || bm.Size != file.Size() {
bm = newResumeBitmap(file.Size())
if err := bm.save(bitmapPath); err != nil {
return err
}
}
missing := bm.missingBlocks()
if len(missing) == 0 {
return nil
}
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(threads)
for _, block := range missing {
block := block
eg.Go(func() error {
offset := int64(block) * int64(bm.PartSize)
data, err := fetchChunk(gctx, file, offset, bm.PartSize)
if err != nil {
return err
}
if len(data) == 0 {
return fmt.Errorf("file ended early at offset %d (expected size %d)", offset, bm.Size)
}
if _, err := w.WriteAt(data, offset); err != nil {
return fmt.Errorf("write chunk at offset %d: %w", offset, err)
}
return bm.markAndSave(block, bitmapPath)
})
}
if err := eg.Wait(); err != nil {
return err
}
if !bm.complete() {
return fmt.Errorf("download finished with missing blocks")
}
return nil
}
// RemoveResumeState deletes the bitmap file of a completed download.
func RemoveResumeState(bitmapPath string) error {
if err := os.Remove(bitmapPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove resume bitmap: %w", err)
}
if err := os.Remove(bitmapPath + ".tmp"); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove resume bitmap temp: %w", err)
}
return nil
}
// ResumeStatePath returns the bitmap path for a download cache file.
func ResumeStatePath(cachePath string) string {
return cachePath + ".bitmap"
}
+160
View File
@@ -0,0 +1,160 @@
package tdler
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// failAfterClient serves the first failAfter chunks, then returns err.
type failAfterClient struct {
*serverLikeClient
failAfter int
calls int
err error
}
func (c *failAfterClient) UploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) {
c.calls++
if c.calls > c.failAfter {
return nil, c.err
}
return c.serverLikeClient.UploadGetFile(ctx, req)
}
func TestDownloadResumableFull(t *testing.T) {
data := make([]byte, 3*1024*1024+123)
for i := range data {
data[i] = byte(i % 251)
}
client := &serverLikeClient{data: data}
file := tfile.NewTGFile(&tg.InputDocumentFileLocation{ID: 1, AccessHash: 2}, client, int64(len(data)), "test.bin")
dir := t.TempDir()
bitmapPath := filepath.Join(dir, "test.bin.bitmap")
w := &memWriterAt{b: make([]byte, len(data))}
if err := DownloadResumable(context.Background(), file, w, 4, bitmapPath); err != nil {
t.Fatalf("download failed: %v", err)
}
if !bytesEqual(w.b, data) {
t.Fatalf("downloaded data mismatch")
}
bm, err := loadResumeBitmap(bitmapPath)
if err != nil {
t.Fatalf("load bitmap: %v", err)
}
if bm == nil || !bm.complete() {
t.Fatalf("bitmap not complete after full download")
}
}
func TestDownloadResumableInterrupted(t *testing.T) {
data := make([]byte, 5*1024*1024) // exactly 5 blocks
for i := range data {
data[i] = byte(i % 251)
}
dir := t.TempDir()
bitmapPath := filepath.Join(dir, "test.bin.bitmap")
w := &memWriterAt{b: make([]byte, len(data))}
// First run: 3 blocks complete, 4th request fails.
flaky := &failAfterClient{
serverLikeClient: &serverLikeClient{data: data},
failAfter: 3,
err: tgerr.New(500, "INTERNAL_SERVER_ERROR"),
}
file := tfile.NewTGFile(&tg.InputDocumentFileLocation{ID: 1, AccessHash: 2}, flaky, int64(len(data)), "test.bin")
err := DownloadResumable(context.Background(), file, w, 1, bitmapPath)
if err == nil {
t.Fatalf("expected first run to fail")
}
bm, err := loadResumeBitmap(bitmapPath)
if err != nil {
t.Fatalf("load bitmap after interruption: %v", err)
}
if bm == nil {
t.Fatalf("bitmap missing after interruption")
}
if got := bm.blockCount() - len(bm.missingBlocks()); got != 3 {
t.Fatalf("expected 3 completed blocks, got %d", got)
}
// Second run: only the missing blocks are requested.
healthy := &serverLikeClient{data: data}
file = tfile.NewTGFile(&tg.InputDocumentFileLocation{ID: 1, AccessHash: 2}, healthy, int64(len(data)), "test.bin")
if err := DownloadResumable(context.Background(), file, w, 1, bitmapPath); err != nil {
t.Fatalf("resume failed: %v", err)
}
if !bytesEqual(w.b, data) {
t.Fatalf("resumed data mismatch")
}
if healthy.maxOffset >= int64(len(data)) {
t.Fatalf("resume requested offset %d at or past EOF", healthy.maxOffset)
}
if bm, err = loadResumeBitmap(bitmapPath); err != nil || bm == nil || !bm.complete() {
t.Fatalf("bitmap not complete after resume: %v", err)
}
}
func TestDownloadResumableBitmapResetOnSizeChange(t *testing.T) {
data := make([]byte, 2*1024*1024)
for i := range data {
data[i] = byte(i % 251)
}
dir := t.TempDir()
bitmapPath := filepath.Join(dir, "test.bin.bitmap")
w := &memWriterAt{b: make([]byte, len(data))}
// Record a bitmap claiming the old, larger file is fully downloaded.
stale := newResumeBitmap(int64(4 * 1024 * 1024))
if err := stale.save(bitmapPath); err != nil {
t.Fatalf("save stale bitmap: %v", err)
}
client := &serverLikeClient{data: data}
file := tfile.NewTGFile(&tg.InputDocumentFileLocation{ID: 1, AccessHash: 2}, client, int64(len(data)), "test.bin")
if err := DownloadResumable(context.Background(), file, w, 1, bitmapPath); err != nil {
t.Fatalf("download with stale bitmap failed: %v", err)
}
if !bytesEqual(w.b, data) {
t.Fatalf("data mismatch with stale bitmap")
}
}
func TestRemoveResumeState(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "x.bitmap")
if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := RemoveResumeState(path); err != nil {
t.Fatalf("RemoveResumeState: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("bitmap still exists: %v", err)
}
// Removing again must be a no-op.
if err := RemoveResumeState(path); err != nil {
t.Fatalf("RemoveResumeState second call: %v", err)
}
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+88
View File
@@ -0,0 +1,88 @@
package batchtfile
import (
"context"
"fmt"
"os"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
// downloadToCache fetches elem.File into the element cache path. It resumes
// from a partial .part download tracked by a resume bitmap, and reuses a
// complete cache file (e.g. when the previous run was interrupted during
// upload).
func (t *Task) downloadToCache(ctx context.Context, elem *TaskElement) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
if stat, err := os.Stat(elem.localPath); err == nil && stat.Size() == elem.File.Size() {
logger.Info("Cache file already complete, skipping download")
return nil
}
onProgress := t.downloadCallback(ctx, elem)
if elem.File.Size() <= 0 {
// Unknown size (e.g. photos) cannot be resumed; use the plain downloader.
localFile, err := fsutil.CreateFile(elem.localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer localFile.Close()
wrAt := ioutil.NewProgressWriterAt(localFile, onProgress)
if _, err := tdler.NewDownloader(elem.File).Parallel(ctx, wrAt); err != nil {
return err
}
return nil
}
partPath := elem.localPath + ".part"
localFile, err := fsutil.CreateFile(partPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
wrAt := ioutil.NewProgressWriterAt(localFile, onProgress)
err = tdler.DownloadResumable(
ctx, elem.File, wrAt,
dlutil.BestThreads(elem.File.Size(), config.C().Threads),
tdler.ResumeStatePath(partPath),
)
closeErr := localFile.Close()
if err != nil {
return err
}
if closeErr != nil {
return fmt.Errorf("failed to close cache file: %w", closeErr)
}
stat, err := os.Stat(partPath)
if err != nil {
return fmt.Errorf("failed to stat downloaded file: %w", err)
}
if stat.Size() != elem.File.Size() {
return fmt.Errorf("downloaded size %d does not match expected %d", stat.Size(), elem.File.Size())
}
if err := os.Rename(partPath, elem.localPath); err != nil {
return fmt.Errorf("failed to finalize download: %w", err)
}
if err := tdler.RemoveResumeState(tdler.ResumeStatePath(partPath)); err != nil {
return fmt.Errorf("failed to remove resume state: %w", err)
}
return nil
}
func (t *Task) downloadCallback(ctx context.Context, elem *TaskElement) func(int) {
return func(n int) {
t.recordItemDownload(elem.ID, int64(n), time.Now())
downloaded := t.downloaded.Add(int64(n))
t.notifyProgress(ctx)
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalSize,
DownloadedBytes: downloaded,
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package tfile
import (
"context"
"fmt"
"github.com/charmbracelet/log"
"os"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
)
// download fetches the file into the cache path. It resumes from a partial
// .part download tracked by a resume bitmap, and reuses a complete cache
// file (e.g. when the previous run was interrupted during upload).
func (t *Task) download(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
if stat, err := os.Stat(t.localPath); err == nil && stat.Size() == t.File.Size() {
logger.Info("Cache file already complete, skipping download")
return nil
}
if t.File.Size() <= 0 {
// Unknown size (e.g. photos) cannot be resumed; use the plain downloader.
localFile, err := fsutil.CreateFile(t.localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer localFile.Close()
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
if _, err := tdler.NewDownloader(t.File).Parallel(ctx, wrAt); err != nil {
return err
}
logger.Info("File downloaded successfully")
return nil
}
partPath := t.localPath + ".part"
localFile, err := fsutil.CreateFile(partPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
err = tdler.DownloadResumable(
ctx, t.File, wrAt,
dlutil.BestThreads(t.File.Size(), config.C().Threads),
tdler.ResumeStatePath(partPath),
)
closeErr := localFile.Close()
if err != nil {
return err
}
if closeErr != nil {
return fmt.Errorf("failed to close cache file: %w", closeErr)
}
stat, err := os.Stat(partPath)
if err != nil {
return fmt.Errorf("failed to stat downloaded file: %w", err)
}
if stat.Size() != t.File.Size() {
return fmt.Errorf("downloaded size %d does not match expected %d", stat.Size(), t.File.Size())
}
if err := os.Rename(partPath, t.localPath); err != nil {
return fmt.Errorf("failed to finalize download: %w", err)
}
if err := tdler.RemoveResumeState(tdler.ResumeStatePath(partPath)); err != nil {
return fmt.Errorf("failed to remove resume state: %w", err)
}
logger.Info("File downloaded successfully")
return nil
}