Files
tiennm99 b459b1f5e9 fix: re-read each message for a live file reference before downloading it
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.
2026-09-08 22:47:52 +07:00

177 lines
5.2 KiB
Go

package pipeline
import (
"sync"
"github.com/iyear/tdl/core/downloader"
"github.com/tiennm99dev/telegram-exporter/internal/tgsource"
)
// Outcome is what happened to one item.
type Outcome struct {
Item tgsource.Item
Err error // nil when the file downloaded and was renamed into place
}
// Stats is a snapshot of a run's progress.
type Stats struct {
Started int
Done int
Failed int
BytesDone int64
BytesTotal int64
}
// progress collects per-item results and feeds an optional live reporter.
//
// The downloader calls these from its worker goroutines, so everything here is
// mutex-guarded. OnDone fires once per item whether it succeeded or not, which
// is what makes it the right place to finish the file — the downloader itself
// never closes or renames what To() handed it.
type progress struct {
mu sync.Mutex
stats Stats
outcomes []Outcome
inFlight map[int]int64 // message id -> bytes written so far
// maxStreak is how many failures in a row end the pass; zero disables the
// breaker. streak counts them, and onTrip is called once when the limit is
// reached. The download leg needs this for the same reason the upload leg
// does: when the source stops serving files every item fails in
// milliseconds, and without a breaker a pass burns the entire remaining
// todo list on transfers that cannot succeed.
maxStreak int
streak int
tripped bool
onTrip func()
finish func(*elem, error) error
events Events
}
func newProgress(finish func(*elem, error) error, events Events) *progress {
if events == nil {
events = nopEvents{}
}
return &progress{
inFlight: make(map[int]int64),
finish: finish,
events: events,
}
}
func (p *progress) OnAdd(e downloader.Elem) {
el := e.(*elem)
p.mu.Lock()
p.stats.Started++
p.stats.BytesTotal += el.item.Size()
stats := p.stats
p.mu.Unlock()
p.events.DownloadStart(el.item)
p.events.Stats(stats)
}
func (p *progress) OnDownload(e downloader.Elem, state downloader.ProgressState) {
el := e.(*elem)
p.mu.Lock()
// State carries the running total for this item, not a delta, so the
// aggregate is adjusted by the difference since the last callback.
prev := p.inFlight[el.item.MessageID]
p.inFlight[el.item.MessageID] = state.Downloaded
p.stats.BytesDone += state.Downloaded - prev
stats := p.stats
p.mu.Unlock()
p.events.DownloadBytes(el.item, state.Downloaded)
p.events.Stats(stats)
}
func (p *progress) OnDone(e downloader.Elem, err error) {
el := e.(*elem)
// Closing and renaming happens here because this is the only callback that
// runs exactly once per item and knows whether it succeeded.
if ferr := p.finish(el, err); ferr != nil && err == nil {
err = ferr
}
p.mu.Lock()
delete(p.inFlight, el.item.MessageID)
if err != nil {
p.stats.Failed++
p.streak++
} else {
p.stats.Done++
p.streak = 0
}
p.outcomes = append(p.outcomes, Outcome{Item: el.item, Err: err})
stats := p.stats
// Decided under the lock and acted on outside it: onTrip stops the
// iterator, and holding this mutex across it would put the callback's
// locking order inside this one's.
trip := p.maxStreak > 0 && p.streak >= p.maxStreak && !p.tripped
if trip {
p.tripped = true
}
p.mu.Unlock()
if trip && p.onTrip != nil {
p.onTrip()
}
p.events.DownloadDone(el.item, err)
p.events.Stats(stats)
}
// refused records an item that failed before any transfer could start.
//
// It exists so that "the message could not be re-read" counts as the same kind
// of event as "the transfer died", because operationally it is: both mean
// Telegram is not serving this file right now, and both are usually the same
// outage. An item that never reaches a worker never reaches OnAdd or OnDone, so
// without this it would move no counter, feed no streak, produce no Outcome for
// Run to retry, and leave the live report on a still frame while the pass walked
// the rest of the list.
//
// Everything OnDone does for a failure it does here, minus the file: the item is
// counted as started and failed, its bytes join the total the report is working
// towards, the streak advances so a dead source still trips the breaker, and the
// Outcome is what makes Run try the item again on the next pass.
func (p *progress) refused(it tgsource.Item, err error) {
p.mu.Lock()
p.stats.Started++
p.stats.BytesTotal += it.Size()
p.stats.Failed++
p.streak++
p.outcomes = append(p.outcomes, Outcome{Item: it, Err: err})
stats := p.stats
// Decided under the lock and acted on outside it, as in OnDone.
trip := p.maxStreak > 0 && p.streak >= p.maxStreak && !p.tripped
if trip {
p.tripped = true
}
p.mu.Unlock()
if trip && p.onTrip != nil {
p.onTrip()
}
// Reported as a start and an immediate end rather than as an end alone: a
// reporter tracking which files are in flight is entitled to see every item
// it is told about finish, and one it never saw start would be a stray.
p.events.DownloadStart(it)
p.events.DownloadDone(it, err)
p.events.Stats(stats)
}
// brokeCircuit reports whether the failure streak ended this pass.
func (p *progress) brokeCircuit() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.tripped
}
func (p *progress) results() ([]Outcome, Stats) {
p.mu.Lock()
defer p.mu.Unlock()
return p.outcomes, p.stats
}