check / check (push) Canceled after 0s
The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the lock renames run under, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the lock, rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5
148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// downloadWriteTimeout is how long one write of a download may wait
|
|
// for a client that has stopped reading.
|
|
const downloadWriteTimeout = 60 * time.Second
|
|
|
|
// HandleTargetDownload serves a database target's archive as one
|
|
// gzipped JSON file, named for the webhook, the target and the time;
|
|
// see archive.Export.WriteGzipJSON for what it holds. Other
|
|
// target types have no archive and are a 404.
|
|
//
|
|
// A download runs for as long as the client keeps reading: it reads
|
|
// under a context the request limit does not cancel, and gives each
|
|
// write its own deadline in place of the server's write timeout. It
|
|
// stops when a write fails.
|
|
func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.WithoutCancel(r.Context())
|
|
|
|
webhook, target, export, ok := h.listTargetArchive(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set(
|
|
"Content-Disposition",
|
|
`attachment; filename="`+archive.ExportFileName(
|
|
webhook.Name, target.Name, now,
|
|
)+`"`,
|
|
)
|
|
|
|
err := export.WriteGzipJSON(
|
|
ctx,
|
|
downloadWriter{w: w, rc: http.NewResponseController(w)},
|
|
&webhook, target, now,
|
|
)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to export archive",
|
|
"target_id", target.ID,
|
|
"error", err,
|
|
)
|
|
|
|
// The 200 has gone out. Aborting the connection is what
|
|
// tells the client the file is incomplete.
|
|
panic(http.ErrAbortHandler)
|
|
}
|
|
}
|
|
}
|
|
|
|
// downloadWriter writes a download to the client, giving each write
|
|
// downloadWriteTimeout to finish.
|
|
type downloadWriter struct {
|
|
w http.ResponseWriter
|
|
rc *http.ResponseController
|
|
}
|
|
|
|
func (d downloadWriter) Write(b []byte) (int, error) {
|
|
// A writer that has no write deadline, such as a test's recorder,
|
|
// answers http.ErrNotSupported and needs none extended.
|
|
err := d.rc.SetWriteDeadline(time.Now().Add(downloadWriteTimeout))
|
|
if err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
return 0, err
|
|
}
|
|
|
|
return d.w.Write(b)
|
|
}
|
|
|
|
// listTargetArchive lists the archive files of the request's database
|
|
// target for export. It reports false once it has written the response.
|
|
//
|
|
// It holds the archives' lock, which every archive rename runs under,
|
|
// while it reads the stored names and lists the files, so the files it
|
|
// lists are the ones those names give. It lets go before the export is
|
|
// streamed, which takes the lock again for each file only while it
|
|
// finds the file under the names stored then and opens it.
|
|
func (h *Handlers) listTargetArchive(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) (database.Webhook, *database.Target, *archive.Export, bool) {
|
|
h.archives.Lock()
|
|
defer h.archives.Unlock()
|
|
|
|
webhook, target, ok := h.ownedTarget(w, r)
|
|
if !ok {
|
|
return database.Webhook{}, nil, nil, false
|
|
}
|
|
|
|
if target.Type != database.TargetTypeDatabase {
|
|
h.renderError(w, r, http.StatusNotFound)
|
|
|
|
return database.Webhook{}, nil, nil, false
|
|
}
|
|
|
|
export, err := archive.NewExport(
|
|
archive.Path(h.dbMgr, &webhook, target),
|
|
h.archives,
|
|
func() (string, error) {
|
|
return h.storedArchivePath(webhook.ID, target.ID)
|
|
},
|
|
h.log,
|
|
)
|
|
if err != nil {
|
|
h.serverError(w, r, "failed to list archive for export", err)
|
|
|
|
return database.Webhook{}, nil, nil, false
|
|
}
|
|
|
|
return webhook, target, export, true
|
|
}
|
|
|
|
// storedArchivePath returns the path archive.Path gives a
|
|
// database target under the names stored for it and its webhook now.
|
|
// Its caller holds the archives' lock. A webhook or target deleted since is still
|
|
// found, since deleting one leaves its archive files under their names.
|
|
func (h *Handlers) storedArchivePath(
|
|
webhookID, targetID string,
|
|
) (string, error) {
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var target database.Target
|
|
|
|
err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return archive.Path(h.dbMgr, &webhook, &target), nil
|
|
}
|