diff --git a/README.md b/README.md index 2d18612..b1cdafe 100644 --- a/README.md +++ b/README.md @@ -1876,6 +1876,7 @@ tags, so `AutoMigrate` creates them on a fresh database: | `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | | `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | | `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | +| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | | `events` | `created_at` | Retention, which selects expired events by age | GORM's soft delete adds `deleted_at IS NULL` to these queries; retention @@ -1883,9 +1884,9 @@ leaves it out. SQLite keeps no statistics on these tables, and without them it rates the `deleted_at` index, which every live row matches, above an index on a column matched against several values or compared with a range. So every index but the last also covers `deleted_at`. It comes second, so that -retention can use the index without it, except in `events`, where the -statistics compare `created_at` with a range (`>=`) and SQLite narrows by a -range only on the last column it uses. +retention can use the index without it, except in the statistics' `events` +index, where they compare `created_at` with a range (`>=`) and SQLite narrows +by a range only on the last column it uses. #### Common Fields diff --git a/internal/database/event_tier_indexes_test.go b/internal/database/event_tier_indexes_test.go index 5943a86..1e03123 100644 --- a/internal/database/event_tier_indexes_test.go +++ b/internal/database/event_tier_indexes_test.go @@ -93,6 +93,7 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) { deliveries []database.Delivery results []database.DeliveryResult depths []struct{ Depth int } + resubmits []struct{ Total int } removed []database.TargetTotals ) @@ -116,13 +117,29 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) { }).Group("target_id, status").Find(&depths), byStatus) // The event log: each event's deliveries, then their attempts - // (loadEventsWithDeliveries, loadDeliveryResults). + // (loadEventsWithDeliveries, loadDeliveryResults), and how many + // events were resubmitted from each of the page's (resubmitCounts). assertPlanUses(t, db, dry.Where("event_id = ?", ids[0]). Find(&deliveries), byEvent) assertPlanUses(t, db, dry.Where("delivery_id IN ?", ids). Order("attempt_num ASC").Find(&results), "idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)") + // The resubmit count gets a full page of 25 ids: with an index on + // resubmitted_from_id alone, SQLite still uses it for three ids and + // turns to the deleted_at index only from five. + page := make([]string, 25) + for i := range page { + page[i] = uuid.New().String() + } + + assertPlanUses(t, db, dry.Model(&database.Event{}). + Select("resubmitted_from_id, count(*) AS total"). + Where("resubmitted_from_id IN ?", page). + Group("resubmitted_from_id").Find(&resubmits), + "idx_events_resubmitted_from_id "+ + "(resubmitted_from_id=? AND deleted_at=?)") + // Retention (reapExpired, deleteEvents): one batch of expired // events, then their attempts, deliveries and the events. var expired []string diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 8980a53..d990de9 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -19,8 +19,10 @@ type Event struct { // narrows by a < only on the last column it uses. Its final delete // has no deleted_at condition and uses the index on created_at // alone. The other tables keep the unindexed BaseModel created_at. - CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"` - DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"` + // DeletedAt is also the second column of the resubmitted_from_id + // index, for the reason DeliveryResult gives. + CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"` + DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"` @@ -42,7 +44,7 @@ type Event struct { // existed. It is not a foreign key: the source event can be // reaped by retention while its copies remain, and the id is // kept as the record of where the copy came from either way. - ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"` + ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"` // Relations. No model marshals the record it belongs to, so // Webhook and Entrypoint are left out of the JSON.