check / check (push) Waiting to run
Each database target on the webhook page links to /hook/ID/targets/TARGETID/download, which streams the target's archive as archive-WEBHOOK-TARGET-YYYYMMDDTHHMMSSZ.json.gz: the webhook, the target, exported_at, and archived_events, one object per row keyed by column, a body that is not valid UTF-8 in base64 with body_encoding. The export reads the rows through one cursor on its own connection inside a read-only transaction: one snapshot, and no write lock. A download runs for as long as the client keeps reading, and one that fails after it has started aborts the connection. The handler holds the rename lock only while it reads the names and opens the file. A missing archive exports empty and is not created. 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
|
|
}
|