mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-16 10:15:24 +00:00
The walk collects every item's file reference up front, and Telegram expires those. The lifetime is undocumented; a run was seen to outlast an hour and fail before two, which is less than a large chat spends downloading. So every fetch past that point returned FILE_REFERENCE_EXPIRED, five in a row tripped the breaker, and the run abandoned the rest of its todo list. Telegram's contract is to cache a reference together with the source it came from and re-read that source when it expires, so each message is re-read for a live reference immediately before its own download. That is one round trip per transfer and needs no guess at how long a reference lives. It runs after the staging acquire rather than before: that acquire is the run's brake and blocks for as long as the destination is slow, which on a stalled remote is long enough to expire a fresh reference all over again. A re-read that describes a different file — different name or different length — is refused, because archiving it under the name this run recorded would store the wrong bytes. That case, a deleted message, and one that no longer holds a file are all skipped: nothing can be archived for them, and one must not strand the rest of the chat. Any other re-read failure is recorded as a failed download instead, because that is what it is. The connection that serves the re-read is the one that serves the file, so these arrive exactly when transfers are failing too. An item that never reaches a worker never reaches OnAdd or OnDone, so on the skip path it would move no counter, feed no streak and produce no outcome to retry — a source outage would walk the whole list one dead round trip at a time and still exit as merely incomplete. tutil.GetSingleMessage is not reused for the re-read: it reports a deleted message three different ways, two of them wrapping a nil error, and only one is distinguishable — so deletions would be misclassified as transient and spend the breaker's streak.
281 lines
10 KiB
Go
281 lines
10 KiB
Go
// Package pipeline drives the download half of an archive run.
|
|
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
|
|
"github.com/gotd/td/tg"
|
|
|
|
"github.com/iyear/tdl/core/downloader"
|
|
|
|
"github.com/tiennm99dev/telegram-exporter/internal/naming"
|
|
"github.com/tiennm99dev/telegram-exporter/internal/tgsource"
|
|
)
|
|
|
|
// partSuffix marks a download that is still in flight.
|
|
//
|
|
// Deliberately not tdl's ".tmp": nothing here excludes by extension any more,
|
|
// because upload is triggered by a download returning rather than by a filter
|
|
// over a directory. A distinct suffix just keeps a staging directory shared with
|
|
// a legacy tdl run unambiguous during the cutover.
|
|
//
|
|
// It is defined in naming because naming.Safe's length limit has to leave room
|
|
// for it — a name that fits but whose part file does not would pass the check
|
|
// and then fail to open, stalling the walk on that message forever.
|
|
const partSuffix = naming.PartSuffix
|
|
|
|
// elem adapts one media item to the downloader's element interface.
|
|
type elem struct {
|
|
item tgsource.Item
|
|
file *os.File
|
|
takeout bool
|
|
|
|
// cause is why the transfer failed, when the downloader logged a reason.
|
|
// It is the only route that reason has to reach finish, because core's
|
|
// Download logs it and returns nil; see captureCauses. Written by the log
|
|
// call on the download worker's goroutine and read by that worker's OnDone,
|
|
// so it needs no lock.
|
|
cause error
|
|
}
|
|
|
|
func (e *elem) File() downloader.File { return mediaFile{e.item} }
|
|
func (e *elem) To() io.WriterAt { return e.file }
|
|
func (e *elem) AsTakeout() bool { return e.takeout }
|
|
|
|
// mediaFile exposes what the downloader needs to locate the bytes. All three
|
|
// values come straight from tmedia, so nothing is looked up a second time.
|
|
type mediaFile struct{ item tgsource.Item }
|
|
|
|
func (f mediaFile) Location() tg.InputFileLocationClass { return f.item.Media.InputFileLoc }
|
|
func (f mediaFile) Size() int64 { return f.item.Media.Size }
|
|
func (f mediaFile) DC() int { return f.item.Media.DC }
|
|
|
|
// partPath and finalPath are where an item is written and where it lands.
|
|
func partPath(staging string, it tgsource.Item) string {
|
|
return filepath.Join(staging, it.Name+partSuffix)
|
|
}
|
|
func finalPath(staging string, it tgsource.Item) string {
|
|
return filepath.Join(staging, it.Name)
|
|
}
|
|
|
|
// elemIter turns the item sequence into the pull iterator the downloader wants,
|
|
// opening each destination file as it goes.
|
|
//
|
|
// The downloader consumes Next/Value/Err; Walk produces an iter.Seq2. iter.Pull2
|
|
// bridges them without this code owning a goroutine or a channel, which is why
|
|
// Walk returns a sequence in the first place.
|
|
type elemIter struct {
|
|
next func() (tgsource.Item, error, bool)
|
|
stopPull func()
|
|
staging string
|
|
takeout bool
|
|
|
|
// refresh re-mints an item's file reference just before its download; see
|
|
// reminted. Unset leaves the walk's own reference in place.
|
|
refresh tgsource.Refresh
|
|
// refused records an item that failed before any transfer could start, so it
|
|
// counts as a failure rather than disappearing. Unset means such an item is
|
|
// only skipped.
|
|
refused func(tgsource.Item, error)
|
|
|
|
// acquire reserves staging space for the next item. Blocking here is what
|
|
// makes backpressure work: core's Download calls Next from its dispatch
|
|
// loop (downloader.go:38), so a blocked Next stops new downloads starting
|
|
// without stopping the uploads that free the space.
|
|
acquire func(context.Context, int64) error
|
|
// release hands a reservation back when the item never reaches a download.
|
|
release func(int64)
|
|
|
|
current *elem
|
|
|
|
// failure holds why iteration stopped, and Err deliberately does not return
|
|
// it. core's Download skips wg.Wait entirely when Iter.Err is non-nil
|
|
// (downloader.go:65-68), abandoning workers that are still running — which
|
|
// would let this package tear down its upload channel underneath them. So
|
|
// Next reports "no more items" and the caller reads failure() afterwards,
|
|
// guaranteeing every worker has finished first.
|
|
failure error
|
|
// stopped ends iteration without an error, for a caller that has decided the
|
|
// run cannot usefully continue. Supplied by the caller so it can be set from
|
|
// another goroutine without racing on the iterator itself.
|
|
stopped *atomic.Bool
|
|
|
|
// skipped collects items refused before any download was attempted. They do
|
|
// not stop the run: one message with a hostile filename must not be able to
|
|
// strand every message behind it, which is what ending iteration would mean.
|
|
skipped []error
|
|
|
|
// opened records every file handle. finish closes each one on the normal
|
|
// path, so this is not what keeps descriptors from leaking; it is the
|
|
// backstop for items that were opened but never reached finish, which is
|
|
// what an aborted iteration leaves behind.
|
|
opened []*os.File
|
|
}
|
|
|
|
func newElemIter(seq iter.Seq2[tgsource.Item, error], staging string, takeout bool) *elemIter {
|
|
next, stopPull := iter.Pull2(seq)
|
|
return &elemIter{
|
|
next: next,
|
|
stopPull: stopPull,
|
|
staging: staging,
|
|
takeout: takeout,
|
|
stopped: new(atomic.Bool),
|
|
}
|
|
}
|
|
|
|
func (i *elemIter) Next(ctx context.Context) bool {
|
|
for {
|
|
if i.failure != nil || i.stopped.Load() {
|
|
return false
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
i.failure = err
|
|
return false
|
|
}
|
|
|
|
item, err, ok := i.next()
|
|
if !ok {
|
|
return false
|
|
}
|
|
if err != nil {
|
|
i.failure = err
|
|
return false
|
|
}
|
|
|
|
// A name that cannot be written is refused here rather than left to
|
|
// os.Create, and refusing it skips the item rather than ending the walk.
|
|
// selectTodo already filters these out on the CLI path, so reaching this
|
|
// is either a second caller or a gap there; in both cases one unwritable
|
|
// name must not strand the rest of the chat behind it.
|
|
if err := naming.Safe(item.Name); err != nil {
|
|
i.skip(item, err)
|
|
continue
|
|
}
|
|
|
|
if i.acquire != nil {
|
|
if err := i.acquire(ctx, item.Size()); err != nil {
|
|
i.failure = err
|
|
return false
|
|
}
|
|
}
|
|
|
|
// The refreshed item replaces the original only on success. A failed
|
|
// refresh reports a zero Item, and handing that to the reservation or to
|
|
// the failure record would release nothing and blame message 0.
|
|
fresh, err := i.reminted(ctx, item)
|
|
if err == nil {
|
|
item = fresh
|
|
} else {
|
|
// Nothing was staged and nothing will be, so the reservation goes
|
|
// back before anything else; otherwise the cap ends the run
|
|
// over-committed by every item that failed here.
|
|
i.giveBack(item)
|
|
if ctx.Err() != nil {
|
|
i.failure = err
|
|
return false
|
|
}
|
|
// A message that is gone is skipped, for the same reason an
|
|
// unwritable name is: it is a permanent, per-message fact, and one
|
|
// of them must not strand every message behind it. Nothing is
|
|
// archived for it, so the run's closing verify still reports it as
|
|
// outstanding.
|
|
//
|
|
// Anything else is a failure and is recorded as one. Routing it
|
|
// there rather than onto the skip path is what keeps the breaker,
|
|
// the run's retry passes and the progress report working: a re-read
|
|
// fails because the connection did, which is exactly when every
|
|
// download is failing too, and a skip is invisible to all three — a
|
|
// source outage would otherwise walk the whole todo list one dead
|
|
// round trip at a time and still exit as merely incomplete.
|
|
if errors.Is(err, tgsource.ErrGone) {
|
|
i.skip(item, err)
|
|
} else if i.refused != nil {
|
|
i.refused(item, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
f, err := os.OpenFile(partPath(i.staging, item), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
// The reservation is handed back here because this item will never
|
|
// reach a download, so no OnDone will ever release it for us.
|
|
i.giveBack(item)
|
|
i.failure = fmt.Errorf("open destination for message %d: %w", item.MessageID, err)
|
|
return false
|
|
}
|
|
i.opened = append(i.opened, f)
|
|
|
|
i.current = &elem{item: item, file: f, takeout: i.takeout}
|
|
return true
|
|
}
|
|
}
|
|
|
|
// reminted replaces an item's file reference with one Telegram has just issued.
|
|
//
|
|
// The reference in Media.InputFileLoc was minted when the walk saw the message,
|
|
// and Telegram expires those. The lifetime is undocumented and was observed to
|
|
// outlast an hour but not two, which is less than a run over a large chat spends
|
|
// downloading — so by the time the dispatch loop reaches an item near the end of
|
|
// the list its reference is dead, every remaining fetch returns
|
|
// FILE_REFERENCE_EXPIRED, and the breaker reads that as a dead source and
|
|
// abandons everything still to come.
|
|
//
|
|
// It is called as late as Next can leave it, and in particular after the budget
|
|
// acquire rather than before. core's downloader reads Elem.File().Location()
|
|
// once per attempt, inside the worker (downloader.go:89), so the only wait left
|
|
// between here and there is for a free worker slot, which one download bounds.
|
|
// Re-minting before the acquire would reintroduce the very failure this exists
|
|
// to prevent: that acquire is the run's brake and blocks for as long as the
|
|
// destination is slow, which on a stalled remote is long enough to expire a
|
|
// fresh token all over again.
|
|
//
|
|
// One round trip per transfer, and no guess at how long a reference lives.
|
|
// Retries get it for free: Run feeds a failed item straight back through
|
|
// Download, so a reference that died mid-transfer — a multi-gigabyte file can
|
|
// outlive its own token — is re-minted on the next pass rather than replayed.
|
|
func (i *elemIter) reminted(ctx context.Context, item tgsource.Item) (tgsource.Item, error) {
|
|
if i.refresh == nil {
|
|
return item, nil
|
|
}
|
|
return i.refresh(ctx, item)
|
|
}
|
|
|
|
// skip records an item no run will ever fetch, without ending this one.
|
|
func (i *elemIter) skip(item tgsource.Item, err error) {
|
|
if len(i.skipped) < maxRecordedErrors {
|
|
i.skipped = append(i.skipped, fmt.Errorf("message %d: %w", item.MessageID, err))
|
|
}
|
|
}
|
|
|
|
// giveBack returns an item's staging reservation.
|
|
func (i *elemIter) giveBack(item tgsource.Item) {
|
|
if i.release != nil {
|
|
i.release(item.Size())
|
|
}
|
|
}
|
|
|
|
func (i *elemIter) Value() downloader.Elem { return i.current }
|
|
|
|
// Err always reports nil so core's Download reaches wg.Wait and joins its
|
|
// workers. See the failure field.
|
|
func (i *elemIter) Err() error { return nil }
|
|
|
|
// Close releases the pull iterator and every file the walk opened.
|
|
func (i *elemIter) Close() error {
|
|
i.stopPull()
|
|
var firstErr error
|
|
for _, f := range i.opened {
|
|
if err := f.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|