check / check (push) Waiting to run
Each database target on the webhook page has a Download button that streams its archive as gzipped JSON, archive-WEBHOOKNAME-TARGETNAME-TIME.json.gz, with names made safe by delivery.ArchiveFileName's function. The export reads one consistent snapshot through one cursor in a read-only transaction, so archive writes carry on, and holds the rename lock only while it reads the stored names and opens the file. It extends its write deadline as it writes, so a large archive downloads for as long as the client reads; a failure after the response has started aborts the connection so the browser marks the download failed. The request limit is now the service's own middleware, which no longer writes a 504 over a response already started. Model: opus-5-5
122 lines
3.4 KiB
Go
122 lines
3.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// 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 delivery.ArchiveExport.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.openTargetArchive(ctx, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = export.Close() }()
|
|
|
|
now := time.Now()
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set(
|
|
"Content-Disposition",
|
|
`attachment; filename="`+delivery.ArchiveExportFileName(
|
|
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)
|
|
}
|
|
|
|
// openTargetArchive opens the archive of the request's database target
|
|
// for export, with its reads under ctx. It reports false once it has
|
|
// written the response.
|
|
//
|
|
// It holds renameMu, which every archive rename runs under, while it
|
|
// reads the stored names and opens the file, so the file it opens is
|
|
// the one those names give. It lets go before the export is streamed:
|
|
// once the file is open, a rename does not affect the export.
|
|
func (h *Handlers) openTargetArchive(
|
|
ctx context.Context,
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
|
h.renameMu.Lock()
|
|
defer h.renameMu.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 := delivery.OpenArchiveExport(
|
|
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
|
|
)
|
|
if err != nil {
|
|
h.serverError(w, r, "failed to open archive for export", err)
|
|
|
|
return database.Webhook{}, nil, nil, false
|
|
}
|
|
|
|
return webhook, target, export, true
|
|
}
|