feat: keep the materials index in sync and add bm:materials-reindex
- the materials admin syncs after create and update and removes after delete, once the save transaction commits (UseMaterialIndex, set at Boot) - POST and PUT /api/v1/bm/materials sync after the transaction, DELETE removes the document (its statement has no primary key, so no callback) - bm:materials-reindex [--fresh] [--if-configured] ensures the index and syncs every material; it refuses without an engine and runs nightly at 04:15 with --if-configured - TestMaterialSearchAgainstDatabase (Postgres) is written for the final verification phase
This commit is contained in:
@@ -498,6 +498,7 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
h.MaterialIndex.Sync(r.Context(), db, m.ID)
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
@@ -556,6 +557,7 @@ func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
case taken:
|
||||
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
|
||||
default:
|
||||
h.MaterialIndex.Sync(r.Context(), db, m.ID)
|
||||
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
|
||||
}
|
||||
}
|
||||
@@ -659,6 +661,9 @@ func (h *Handlers) DeleteMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
// The delete statement has no primary key on its model, so no callback
|
||||
// removes the document: remove it now that the transaction committed.
|
||||
h.MaterialIndex.Remove(r.Context(), db, uint(id))
|
||||
// The files go with the material: rows in the transaction, blobs after it.
|
||||
if b := h.Bucket; b != nil && len(keys) > 0 {
|
||||
if bucket := b(); bucket != nil {
|
||||
@@ -734,6 +739,8 @@ func (h *Handlers) managedAccessChange(w http.ResponseWriter, r *http.Request, d
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// An access change touches only visibility (and the author), which the
|
||||
// search index does not hold, so it does not sync.
|
||||
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -322,7 +322,8 @@ func (h *Handlers) UpdateMaterialAccess(w http.ResponseWriter, r *http.Request)
|
||||
// applyMaterialAccess writes a visibility and share change onto m inside
|
||||
// tx. A material with no author gets userID as author (the claim rule).
|
||||
// Only is_private and author_user_id are updated; the shares are replaced
|
||||
// when setShares.
|
||||
// when setShares. The search index holds none of these, so no caller syncs
|
||||
// after it.
|
||||
func applyMaterialAccess(tx *gorm.DB, m *models.Material, userID uint, isPrivate *bool, setShares bool, shares []uint) error {
|
||||
if m.AuthorUserID == nil {
|
||||
id := userID
|
||||
|
||||
77
controllers/materials_hooks.go
Normal file
77
controllers/materials_hooks.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pact.FormAfterCreate = materialsAdmin{}
|
||||
_ pact.FormAfterUpdate = materialsAdmin{}
|
||||
_ pact.FormAfterDelete = materialsAdmin{}
|
||||
)
|
||||
|
||||
// MaterialIndex keeps the materials search index current once a write
|
||||
// commits (classes.MaterialIndexer). Its calls never fail the write.
|
||||
type MaterialIndex interface {
|
||||
SyncAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
|
||||
RemoveAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
|
||||
}
|
||||
|
||||
type materialIndexHolder struct{ ix MaterialIndex }
|
||||
|
||||
var materialIndex atomic.Value
|
||||
|
||||
// UseMaterialIndex installs the index the materials admin syncs after a
|
||||
// save or delete; nil turns the syncs off. Boot installs it, because the
|
||||
// generated controller value has no field to carry it.
|
||||
func UseMaterialIndex(ix MaterialIndex) {
|
||||
materialIndex.Store(materialIndexHolder{ix: ix})
|
||||
}
|
||||
|
||||
func installedMaterialIndex() MaterialIndex {
|
||||
h, _ := materialIndex.Load().(materialIndexHolder)
|
||||
return h.ix
|
||||
}
|
||||
|
||||
// FormAfterCreate syncs the new material once the save transaction commits.
|
||||
func (materialsAdmin) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return materialAfterCommit(ctx, model, false)
|
||||
}
|
||||
|
||||
// FormAfterUpdate syncs the material once the save transaction commits.
|
||||
func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
return materialAfterCommit(ctx, model, false)
|
||||
}
|
||||
|
||||
// FormAfterDelete removes the material's document once the delete commits.
|
||||
func (materialsAdmin) FormAfterDelete(ctx context.Context, model any) error {
|
||||
return materialAfterCommit(ctx, model, true)
|
||||
}
|
||||
|
||||
// materialAfterCommit schedules the index work of an admin save on the save
|
||||
// transaction. Only a model that is not a material is an error: the index
|
||||
// never fails a save.
|
||||
func materialAfterCommit(ctx context.Context, model any, remove bool) error {
|
||||
m, ok := model.(*models.Material)
|
||||
if !ok || m == nil {
|
||||
return errors.New("materials admin: form model is not a material")
|
||||
}
|
||||
ix := installedMaterialIndex()
|
||||
if ix == nil || m.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, _ := cabana.TxFromContext(ctx)
|
||||
if remove {
|
||||
ix.RemoveAfterCommit(ctx, tx, m.ID)
|
||||
} else {
|
||||
ix.SyncAfterCommit(ctx, tx, m.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
81
controllers/materials_hooks_test.go
Normal file
81
controllers/materials_hooks_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// recordingIndex records the after-commit calls of the materials admin.
|
||||
type recordingIndex struct {
|
||||
calls []string
|
||||
txs []*gorm.DB
|
||||
}
|
||||
|
||||
func (r *recordingIndex) SyncAfterCommit(_ context.Context, tx *gorm.DB, id uint) {
|
||||
r.calls = append(r.calls, "sync "+itoa(id))
|
||||
r.txs = append(r.txs, tx)
|
||||
}
|
||||
|
||||
func (r *recordingIndex) RemoveAfterCommit(_ context.Context, tx *gorm.DB, id uint) {
|
||||
r.calls = append(r.calls, "remove "+itoa(id))
|
||||
r.txs = append(r.txs, tx)
|
||||
}
|
||||
|
||||
func itoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }
|
||||
|
||||
func TestMaterialsAdminHooks(t *testing.T) {
|
||||
t.Cleanup(func() { UseMaterialIndex(nil) })
|
||||
var c materialsAdmin
|
||||
ctx := context.Background()
|
||||
m := &models.Material{ID: 7}
|
||||
|
||||
// No index installed: nothing to do, and no error.
|
||||
UseMaterialIndex(nil)
|
||||
for name, fn := range map[string]func(context.Context, any) error{"create": c.FormAfterCreate, "update": c.FormAfterUpdate, "delete": c.FormAfterDelete} {
|
||||
if err := fn(ctx, m); err != nil {
|
||||
t.Errorf("%s without an index: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
rec := &recordingIndex{}
|
||||
UseMaterialIndex(rec)
|
||||
if err := c.FormAfterCreate(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.FormAfterDelete(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"sync 7", "sync 7", "remove 7"}; !slices.Equal(rec.calls, want) {
|
||||
t.Errorf("calls = %v, want %v", rec.calls, want)
|
||||
}
|
||||
// Outside a cabana write there is no transaction: the index then runs
|
||||
// at once (lagoon.AfterCommit with a nil handle).
|
||||
for i, tx := range rec.txs {
|
||||
if tx != nil {
|
||||
t.Errorf("call %d got a transaction outside a write", i)
|
||||
}
|
||||
}
|
||||
|
||||
// A model of another type is an error and schedules nothing.
|
||||
rec.calls = nil
|
||||
for name, fn := range map[string]func(context.Context, any) error{"create": c.FormAfterCreate, "update": c.FormAfterUpdate, "delete": c.FormAfterDelete} {
|
||||
if err := fn(ctx, &models.Course{ID: 7}); err == nil {
|
||||
t.Errorf("%s with a course: no error", name)
|
||||
}
|
||||
}
|
||||
var nilMaterial *models.Material
|
||||
if err := c.FormAfterUpdate(ctx, nilMaterial); err == nil {
|
||||
t.Error("update with a nil material: no error")
|
||||
}
|
||||
if len(rec.calls) != 0 {
|
||||
t.Errorf("calls for a wrong model = %v", rec.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user