feat(search): queue index updates durably
This commit is contained in:
@@ -17,9 +17,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
## Current Baseline
|
||||
|
||||
- Plan freeze checkpoint: 2026-06-05 docs-only tracker landing after `13cb750 feat(captain): align task payload persistence`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(automation): queue macro and csat jobs`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): queue index updates durably`.
|
||||
- Latest documentation checkpoint before this freeze: `3263ed9 docs: land copilot task execution plan`; this document now carries the active follow-up plan directly.
|
||||
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends through idempotent durable jobs. Next active implementation slice is delayed automation actions, CSAT WhatsApp/Twilio templates, and Meilisearch/SLA job fan-out.
|
||||
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends through idempotent durable jobs. Next active implementation slice is delayed automation actions, CSAT WhatsApp/Twilio templates, SLA scans, and remaining job fan-out.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
@@ -78,7 +78,7 @@ Open work after the current checkpoint:
|
||||
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
|
||||
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
|
||||
| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke is the next broad verification gate |
|
||||
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.4/P5.5/P5.6 durable worker, event dispatch, automation delivery, macro, and CSAT cores are in Review; delayed automation/search/SLA/Captain job integration remains open |
|
||||
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6 durable worker, event dispatch, search indexing, automation delivery, macro, and CSAT cores are in Review; delayed automation/SLA/Captain job integration remains open |
|
||||
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
|
||||
| Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent |
|
||||
|
||||
@@ -101,6 +101,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(search): queue index updates durably` | Advances P5.3 with a durable `SearchIndexer` wrapper. Service-layer create/update/delete hooks now enqueue `search:index` jobs for conversations, messages, contacts, companies, and articles when a WorkerPool is configured; worker replay reloads the current account-scoped record before calling the real Meilisearch-backed `SearchService`, turns missing index records into delete jobs, and leaves search reads pointed at the live search service. | `go test ./internal/service -run 'DurableSearchIndexer\|SearchIndexHooks' -count=1`; `go test ./internal/service ./internal/search ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.3 section. | Move P5.3 to Review; continue SLA scans, delayed automation scheduled items, and provider/template jobs. |
|
||||
| `feat(automation): queue macro and csat jobs` | Advances P5.5/P5.6 with durable macro fan-out and resolve-triggered CSAT sends. `MacroService.ExecuteForDisplayIDs` now enqueues `automation:macro_execution` jobs when a WorkerPool is configured, resolving display IDs at perform time like Chatwoot `MacrosExecutionJob`; `CsatSurveyListener` now enqueues idempotent `csat:survey_send` jobs for resolved conversations while keeping message-updated response building inline. | `go test ./internal/automation ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.5/P5.6 section. | Move macro fan-out and generic CSAT send to Review; continue delayed automation actions and WhatsApp/Twilio CSAT template jobs. |
|
||||
| `feat(automation): queue external action deliveries` | Advances P5.4 with durable automation external side effects. `send_webhook_event` and `send_email_transcript` now enqueue `automation:webhook_delivery` and `automation:transcript_delivery` jobs when a WorkerPool is configured; automation rules and macros can carry the WorkerPool through `ActionService`, and app bootstrap starts the durable worker plus dispatcher event jobs. | `go test ./internal/automation ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.4 section. | Move P5.4 to Review; continue P5.5 delayed automation/macro fan-out and P5.6 CSAT channel-template jobs. |
|
||||
| `feat(dispatch): queue async events durably` | Advances P5.2 with durable async event dispatch. `channel.Dispatcher.DispatchAsync` now enqueues `event:dispatch_async` jobs when a WorkerPool is configured, and `dispatch.EventDispatcher` now enqueues per-listener `event:listener_dispatch` jobs for async listener replay while preserving immediate sync listener behavior. | `go test ./internal/channel ./internal/dispatch ./internal/worker -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.2 section. | Move P5.2 to Review; continue P5.4/P5.5 automation webhook/transcript and CSAT durable job integration. |
|
||||
@@ -1608,7 +1609,7 @@ Tracking table:
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P5.1 | Implement durable worker core and job model. | `reference/chatwoot/app/jobs/application_job.rb`, `mutex_application_job.rb` | `internal/worker/worker.go`, `internal/model/background_job.go`, `migrations/000026_add_background_jobs.*.sql` | Job table, worker persistence API, enqueue API, worker loop, retry/backoff, scheduled jobs, mutex/idempotency keys, dead-letter state, and restart pickup tests exist. | Review by `feat(worker): add durable background jobs` |
|
||||
| P5.2 | Route async dispatcher events through durable jobs. | `event_dispatcher_job.rb`, Chatwoot async dispatcher listeners | `internal/dispatch/dispatcher.go`, `internal/channel/dispatcher.go` | Heavy listeners can enqueue durable jobs without changing sync listener behavior; tests cover sync vs async routing and replay. | Review by `feat(dispatch): queue async events durably` |
|
||||
| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Todo |
|
||||
| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Review by `feat(search): queue index updates durably` |
|
||||
| P5.4 | Queue automation webhook and transcript delivery. | `webhook_job.rb`, automation action execution services | `internal/automation/action_delivery.go`, `internal/automation/action_service.go` | Existing timeout/retry fakeable delivery is invoked by durable jobs; logs preserve attempt metadata and idempotency. | Review by `feat(automation): queue external action deliveries` |
|
||||
| P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time, macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Doing: macro fan-out Review by `feat(automation): queue macro and csat jobs`; delayed actions Todo |
|
||||
| P5.6 | Queue CSAT survey sends and channel-specific templates. | CSAT listener/services, WhatsApp/Twilio template services/jobs | `internal/automation/csat_survey_listener.go`, `internal/csat/listener.go`, channel send services | Resolve-triggered CSAT send is durable; WhatsApp/Twilio template delivery and failure states are fakeable and observable. | Doing: generic survey send Review by `feat(automation): queue macro and csat jobs`; WhatsApp/Twilio templates Todo |
|
||||
@@ -1652,6 +1653,24 @@ env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache
|
||||
git diff --check
|
||||
```
|
||||
|
||||
P5.3 current checkpoint:
|
||||
|
||||
- `DurableSearchIndexer` now wraps the Meilisearch-backed `SearchService` write side. Conversation, message, contact, company, and article create/update/delete hooks enqueue `search:index` jobs on the `search` queue when a `WorkerPool` is configured.
|
||||
- Worker replay reloads each account-scoped record before indexing so Meilisearch receives current database state instead of stale request-time payloads.
|
||||
- Missing records during an index replay are converted into delegate delete calls, which keeps delayed create/update jobs from resurrecting documents after a database delete.
|
||||
- Search read paths remain Meilisearch-first through `searchService`; contact and company services still use the live `SearchService` reader instead of the durable wrapper.
|
||||
- No-worker construction still falls back to synchronous indexing for focused tests and development paths that do not start the durable worker.
|
||||
- Remaining P5.3 work is limited to explicit reindex fan-out/CLI scheduling and optional live Meilisearch environment gates; normal service-layer writes are now durable.
|
||||
|
||||
P5.3 verification:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'DurableSearchIndexer\|SearchIndexHooks' -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service ./internal/search ./internal/worker ./internal/app -count=1
|
||||
env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
P5.4 current checkpoint:
|
||||
|
||||
- `ActionService` can now be constructed with a `WorkerPool`. When present, `send_webhook_event` enqueues `automation:webhook_delivery` jobs and `send_email_transcript` enqueues one `automation:transcript_delivery` job per parsed recipient, matching Chatwoot's async `WebhookJob`/mailer boundary instead of depending on the request path.
|
||||
|
||||
@@ -641,13 +641,14 @@ func Bootstrap(env string) (*App, error) {
|
||||
return nil, fmt.Errorf("search engine init failed: %w", err)
|
||||
}
|
||||
searchService := search.NewSearchServiceWithEngine(searchEngine, searchRepo)
|
||||
conversationService.SetSearchIndexer(searchService)
|
||||
messageService.SetSearchIndexer(searchService)
|
||||
contactService.SetSearchIndexer(searchService)
|
||||
searchIndexer := service.NewDurableSearchIndexer(db, workerPool, searchService)
|
||||
conversationService.SetSearchIndexer(searchIndexer)
|
||||
messageService.SetSearchIndexer(searchIndexer)
|
||||
contactService.SetSearchIndexer(searchIndexer)
|
||||
contactService.SetSearchReader(searchService)
|
||||
companyService.SetSearchIndexer(searchService)
|
||||
companyService.SetSearchIndexer(searchIndexer)
|
||||
companyService.SetSearchReader(searchService)
|
||||
articleService.SetSearchIndexer(searchService)
|
||||
articleService.SetSearchIndexer(searchIndexer)
|
||||
|
||||
// Custom attribute definition + custom filter + custom attribute value services
|
||||
customAttributeDefinitionService := service.NewCustomAttributeDefinitionService(customAttributeDefinitionRepo)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const TaskTypeSearchIndex = "search:index"
|
||||
|
||||
type searchIndexJob struct {
|
||||
Operation string `json:"operation"`
|
||||
Entity string `json:"entity"`
|
||||
AccountID uint `json:"account_id"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// DurableSearchIndexer defers Meilisearch write-side synchronization to the
|
||||
// durable worker while keeping read paths pointed at the real SearchService.
|
||||
type DurableSearchIndexer struct {
|
||||
db *gorm.DB
|
||||
worker *worker.WorkerPool
|
||||
delegate SearchIndexer
|
||||
}
|
||||
|
||||
func NewDurableSearchIndexer(db *gorm.DB, wp *worker.WorkerPool, delegate SearchIndexer) *DurableSearchIndexer {
|
||||
indexer := &DurableSearchIndexer{db: db, worker: wp, delegate: delegate}
|
||||
if wp != nil {
|
||||
wp.Register(TaskTypeSearchIndex, indexer.perform)
|
||||
}
|
||||
return indexer
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "conversation", conversation.AccountID, conversation.ID, func() error {
|
||||
return i.delegate.IndexConversation(ctx, conversation)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) DeleteConversation(ctx context.Context, accountID uint, id uint) error {
|
||||
return i.enqueueOrDelete(ctx, "conversation", accountID, id, func() error { return i.delegate.DeleteConversation(ctx, accountID, id) })
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "message", message.AccountID, message.ID, func() error {
|
||||
return i.delegate.IndexMessage(ctx, message)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) DeleteMessage(ctx context.Context, accountID uint, id uint) error {
|
||||
return i.enqueueOrDelete(ctx, "message", accountID, id, func() error { return i.delegate.DeleteMessage(ctx, accountID, id) })
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error {
|
||||
if contact == nil {
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "contact", contact.AccountID, contact.ID, func() error {
|
||||
return i.delegate.IndexContact(ctx, contact)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) DeleteContact(ctx context.Context, accountID uint, id uint) error {
|
||||
return i.enqueueOrDelete(ctx, "contact", accountID, id, func() error { return i.delegate.DeleteContact(ctx, accountID, id) })
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) IndexCompany(ctx context.Context, company *model.Company) error {
|
||||
if company == nil {
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "company", company.AccountID, company.ID, func() error {
|
||||
return i.delegate.IndexCompany(ctx, company)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) DeleteCompany(ctx context.Context, accountID uint, id uint) error {
|
||||
return i.enqueueOrDelete(ctx, "company", accountID, id, func() error { return i.delegate.DeleteCompany(ctx, accountID, id) })
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error {
|
||||
if article == nil {
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "article", article.AccountID, article.ID, func() error {
|
||||
return i.delegate.IndexArticle(ctx, article)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) DeleteArticle(ctx context.Context, accountID uint, id uint) error {
|
||||
return i.enqueueOrDelete(ctx, "article", accountID, id, func() error { return i.delegate.DeleteArticle(ctx, accountID, id) })
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) enqueueOrIndex(ctx context.Context, entity string, accountID, id uint, fallback func() error) error {
|
||||
return i.enqueueOrRun(ctx, searchIndexJob{Operation: "index", Entity: entity, AccountID: accountID, ID: id}, fallback)
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) enqueueOrDelete(ctx context.Context, entity string, accountID, id uint, fallback func() error) error {
|
||||
return i.enqueueOrRun(ctx, searchIndexJob{Operation: "delete", Entity: entity, AccountID: accountID, ID: id}, fallback)
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) enqueueOrRun(ctx context.Context, payload searchIndexJob, fallback func() error) error {
|
||||
if i.delegate == nil {
|
||||
return nil
|
||||
}
|
||||
if i.worker == nil {
|
||||
return fallback()
|
||||
}
|
||||
_, err := i.worker.Enqueue(ctx, TaskTypeSearchIndex, payload, worker.WithQueue("search"), worker.WithMaxAttempts(3))
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) perform(ctx context.Context, job *model.BackgroundJob) error {
|
||||
if i.delegate == nil {
|
||||
return nil
|
||||
}
|
||||
var payload searchIndexJob
|
||||
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal search index job: %w", err)
|
||||
}
|
||||
if payload.ID == 0 || payload.Entity == "" || payload.Operation == "" {
|
||||
return fmt.Errorf("invalid search index job payload: %#v", payload)
|
||||
}
|
||||
switch payload.Operation {
|
||||
case "index":
|
||||
return i.performIndex(ctx, payload)
|
||||
case "delete":
|
||||
return i.performDelete(ctx, payload)
|
||||
default:
|
||||
return fmt.Errorf("unsupported search index operation %q", payload.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchIndexJob) error {
|
||||
switch payload.Entity {
|
||||
case "conversation":
|
||||
var item model.Conversation
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexConversation(ctx, &item)
|
||||
case "message":
|
||||
var item model.Message
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexMessage(ctx, &item)
|
||||
case "contact":
|
||||
var item model.Contact
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexContact(ctx, &item)
|
||||
case "company":
|
||||
var item model.Company
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexCompany(ctx, &item)
|
||||
case "article":
|
||||
var item model.Article
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexArticle(ctx, &item)
|
||||
default:
|
||||
return fmt.Errorf("unsupported search index entity %q", payload.Entity)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) load(ctx context.Context, payload searchIndexJob, dest any) error {
|
||||
err := i.db.WithContext(ctx).Where("id = ? AND account_id = ?", payload.ID, payload.AccountID).First(dest).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return i.performDelete(ctx, payload)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) performDelete(ctx context.Context, payload searchIndexJob) error {
|
||||
switch payload.Entity {
|
||||
case "conversation":
|
||||
return i.delegate.DeleteConversation(ctx, payload.AccountID, payload.ID)
|
||||
case "message":
|
||||
return i.delegate.DeleteMessage(ctx, payload.AccountID, payload.ID)
|
||||
case "contact":
|
||||
return i.delegate.DeleteContact(ctx, payload.AccountID, payload.ID)
|
||||
case "company":
|
||||
return i.delegate.DeleteCompany(ctx, payload.AccountID, payload.ID)
|
||||
case "article":
|
||||
return i.delegate.DeleteArticle(ctx, payload.AccountID, payload.ID)
|
||||
default:
|
||||
return fmt.Errorf("unsupported search index entity %q", payload.Entity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
)
|
||||
|
||||
type recordingDurableSearchIndexer struct {
|
||||
indexedContacts []uint
|
||||
deletedContacts []uint
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) DeleteConversation(ctx context.Context, accountID uint, id uint) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) DeleteMessage(ctx context.Context, accountID uint, id uint) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error {
|
||||
r.indexedContacts = append(r.indexedContacts, contact.ID)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) DeleteContact(ctx context.Context, accountID uint, id uint) error {
|
||||
r.deletedContacts = append(r.deletedContacts, id)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexCompany(ctx context.Context, company *model.Company) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) DeleteCompany(ctx context.Context, accountID uint, id uint) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) DeleteArticle(ctx context.Context, accountID uint, id uint) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func TestDurableSearchIndexerQueuesAndReplaysContactIndex(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
account := createTestAccount(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Index Me", Email: "index@example.com"}
|
||||
if err := db.Create(contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
delegate := &recordingDurableSearchIndexer{}
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 0, 0, 0, time.UTC) }))
|
||||
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
||||
|
||||
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
||||
t.Fatalf("queue contact index: %v", err)
|
||||
}
|
||||
if len(delegate.indexedContacts) != 0 {
|
||||
t.Fatalf("contact indexed synchronously before worker replay")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ? AND status = ?", TaskTypeSearchIndex, "search", model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count search jobs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one queued search job, got %d", count)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process search job: processed=%v err=%v", processed, err)
|
||||
}
|
||||
if len(delegate.indexedContacts) != 1 || delegate.indexedContacts[0] != contact.ID {
|
||||
t.Fatalf("expected durable contact index replay, got %#v", delegate.indexedContacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSearchIndexerRetriesDelegateFailure(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
account := createTestAccount(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Retry Me"}
|
||||
if err := db.Create(contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
delegate := &recordingDurableSearchIndexer{err: errors.New("meili unavailable")}
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 15, 0, 0, time.UTC) }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute }))
|
||||
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
||||
|
||||
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
||||
t.Fatalf("queue contact index: %v", err)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err == nil || !processed {
|
||||
t.Fatalf("expected retryable worker error, processed=%v err=%v", processed, err)
|
||||
}
|
||||
var job model.BackgroundJob
|
||||
if err := db.Where("job_type = ?", TaskTypeSearchIndex).First(&job).Error; err != nil {
|
||||
t.Fatalf("load search job: %v", err)
|
||||
}
|
||||
if job.Status != model.BackgroundJobStatusRetrying || job.LastError == "" {
|
||||
t.Fatalf("expected retrying search job with error, got status=%s last_error=%q", job.Status, job.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSearchIndexerDeletesWhenIndexedRecordDisappears(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
account := createTestAccount(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Delete Before Replay"}
|
||||
if err := db.Create(contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
delegate := &recordingDurableSearchIndexer{}
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 30, 0, 0, time.UTC) }))
|
||||
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
||||
|
||||
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
||||
t.Fatalf("queue contact index: %v", err)
|
||||
}
|
||||
if err := db.Delete(contact).Error; err != nil {
|
||||
t.Fatalf("delete contact before replay: %v", err)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process search job: processed=%v err=%v", processed, err)
|
||||
}
|
||||
if len(delegate.deletedContacts) != 1 || delegate.deletedContacts[0] != contact.ID {
|
||||
t.Fatalf("expected missing record to delete search document, got %#v", delegate.deletedContacts)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
|
||||
&model.ContactExport{},
|
||||
&model.DataImport{},
|
||||
&model.Notification{},
|
||||
&model.BackgroundJob{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to auto-migrate models: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user