Carry the event's receipt time into every delivery (closes #257)
All checks were successful
check / check (push) Successful in 3m4s

This commit was merged in pull request #297.
This commit is contained in:
2026-08-24 06:44:24 +02:00
parent b2c9acdaa6
commit 5976a4a98f
3 changed files with 473 additions and 13 deletions

View File

@@ -440,7 +440,7 @@ func (e *Engine) processNewTask(
event := buildEventFromTask(task)
event, err = e.resolveEventBody(
event, err = e.hydrateEvent(
webhookDB, event, task,
)
if err != nil {
@@ -512,7 +512,7 @@ func (e *Engine) processRetryTask(
event := buildEventFromTask(task)
event, err = e.resolveEventBody(
event, err = e.hydrateEvent(
webhookDB, event, task,
)
if err != nil {
@@ -1547,6 +1547,11 @@ func truncate(s string, maxLen int) string {
// --- Helper functions ---
// buildEventFromTask reconstructs the event a Task describes, as far
// as the Task itself goes. The fields it cannot fill — the body when
// it was too large to inline, and the receipt time, which no Task
// carries — come from the stored row in hydrateEvent, which every
// caller of this function runs next.
func buildEventFromTask(task *Task) database.Event {
event := database.Event{
EntrypointID: task.EntrypointID,
@@ -1574,29 +1579,67 @@ func buildTargetFromTask(task *Task) database.Target {
return target
}
func (e *Engine) resolveEventBody(
// hydrateEvent fills in the event fields a Task does not carry, by
// reading the stored event row.
//
// CreatedAt is the event's receipt time and lives only in that row.
// The Slack target renders it into every message it sends, so an
// unhydrated event puts the zero time in front of a human on every
// notification the product delivers. See
// https://git.eeqj.de/sneak/webhooker/issues/257.
//
// The body comes from the same row when the Task did not inline it,
// which is the case for a body at or above MaxInlineBodySize.
//
// A read failure is fatal to the delivery only when the body depended
// on it. When the Task inlined the body, the delivery has everything
// it needs to be sent and goes ahead with the timestamp unset: the row
// can be gone under a retention reap while a queued delivery still
// holds its body, and dropping a deliverable event to protect one
// metadata field would be a worse failure than the one it prevents.
func (e *Engine) hydrateEvent(
webhookDB *gorm.DB,
event database.Event,
task *Task,
) (database.Event, error) {
if task.Body != nil {
columns := []string{"created_at"}
if task.Body == nil {
columns = append(columns, "body")
}
var dbEvent database.Event
err := webhookDB.Select(columns).
First(&dbEvent, "id = ?", task.EventID).Error
if err != nil {
if task.Body == nil {
return event, fmt.Errorf(
"fetching event body: %w", err,
)
}
e.log.Warn(
"could not read the stored event; delivering "+
"the inlined body without its receipt time",
"event_id", task.EventID,
"delivery_id", task.DeliveryID,
"error", err,
)
event.Body = *task.Body
return event, nil
}
var dbEvent database.Event
event.CreatedAt = dbEvent.CreatedAt
err := webhookDB.Select("body").
First(&dbEvent, "id = ?", task.EventID).Error
if err != nil {
return event, fmt.Errorf(
"fetching event body: %w", err,
)
if task.Body != nil {
event.Body = *task.Body
} else {
event.Body = dbEvent.Body
}
event.Body = dbEvent.Body
return event, nil
}