Search Engine Administration โ Developer Guide
Overview
The Search subgraph (apps/search) powers unified discovery across all Malets using Meilisearch as the search engine. The Administration layer extends the base search functionality with three operational features that ensure index consistency, data quality, and search relevance:
| Component | Purpose | Backend |
|---|---|---|
| SynonymService | Per-vertical synonym dictionary management | Redis + Meilisearch synonyms API |
| ReconciliationService | Automated drift detection between MongoDB and Meilisearch | Redis locking + @nestjs/schedule cron |
| BulkImportService | Full or filtered re-indexing from source databases | Redis progress tracking + batched Meilisearch writes |
All admin operations are gated behind Permission.MANAGE_SEARCH โ only platform administrators and authorized Malet Owners can trigger these actions.
Architecture
graph TD
A["SynonymResolver"] --> B["SynonymService"]
C["ReconciliationResolver"] --> D["ReconciliationService"]
E["BulkImportResolver"] --> F["BulkImportService"]
B --> G["Redis (synonym storage)"]
B --> H["MeilisearchService"]
D --> I["ProductStub / ServiceStub (MongoDB)"]
D --> H
D --> J["Redis (lock + status)"]
F --> I
F --> H
F --> K["Redis (lock + progress)"]
L["@Cron: Daily 3 AM"] --> D
style G fill:#dc2626,color:#fff
style J fill:#dc2626,color:#fff
style K fill:#dc2626,color:#fff
style H fill:#3b82f6,color:#fff
style L fill:#f59e0b,color:#fff
How It Fits Together
The search service already receives real-time updates via TCP events (item_upserted, item_deleted) from source subgraphs like products and services. The admin layer addresses scenarios where real-time sync is insufficient:
- Missed events: If a TCP message is lost during a deployment or network partition, the reconciliation job detects and repairs the drift.
- New verticals: When adding a new entity type (e.g., Malets or Blog Posts), bulk import can backfill the entire index.
- Search quality: Synonyms improve recall โ a Visitor searching for "game drive" will also find results tagged "safari".
Per-Vertical Synonyms
Why Verticals?
Different Malet verticals use domain-specific language. A "session" in Photography means a photoshoot booking, while in Wellness it means a spa treatment. Per-vertical synonyms prevent cross-contamination across these domains.
Supported Verticals
enum SynonymVertical {
GENERAL = 'GENERAL', // Cross-vertical terms (shop โ store โ malet)
RESTAURANT = 'RESTAURANT', // Dining-specific (takeout โ takeaway โ carry out)
TOUR = 'TOUR', // Safari & excursion terms (safari โ game drive)
PHOTOGRAPHY = 'PHOTOGRAPHY', // Photo session terms (portrait โ headshot)
FASHION = 'FASHION', // Apparel terms (clothing โ garments โ wear)
WELLNESS = 'WELLNESS', // Spa & fitness terms (spa โ wellness center)
ENTERTAINMENT = 'ENTERTAINMENT' // Events & tickets (concert โ show โ gig)
}
Storage Model
Synonyms are stored in Redis with one key per vertical:
search:synonyms:GENERAL โ [{"term":"shop","synonyms":["store","malet","boutique"]}, ...]
search:synonyms:RESTAURANT โ [{"term":"restaurant","synonyms":["dining","eatery","bistro"]}, ...]
search:synonyms:TOUR โ [{"term":"safari","synonyms":["game drive","wildlife tour"]}, ...]
Seeded Defaults
On first startup, the SynonymService seeds default synonym groups for every vertical if the Redis key is empty. This ensures search relevance is improved out of the box without manual configuration.
Bidirectional Push
When applySynonyms is called, all vertical synonym groups are merged into a single bidirectional map and pushed to Meilisearch:
// Input: { term: "safari", synonyms: ["game drive", "wildlife tour"] }
// Output pushed to Meilisearch:
{
"safari": ["game drive", "wildlife tour"],
"game drive": ["safari", "wildlife tour"],
"wildlife tour": ["safari", "game drive"]
}
This means a Visitor searching for any of these terms will see results containing the others.
GraphQL API
# Query synonym groups (optionally filtered by vertical)
query {
searchSynonyms(vertical: RESTAURANT) {
term
synonyms
vertical
}
}
# Add or update a synonym group
mutation {
setSynonymGroup(
input: { vertical: TOUR, term: "hiking", synonyms: ["trekking", "walking", "trail"] }
) {
term
synonyms
vertical
}
}
# Delete a synonym group
mutation {
deleteSynonymGroup(input: { vertical: TOUR, term: "hiking" })
}
# Push all Redis synonyms to the Meilisearch index
mutation {
applySynonyms {
success
totalGroups
}
}
Note: Changes via
setSynonymGroupanddeleteSynonymGrouponly update Redis. You must callapplySynonymsto push the changes to the live Meilisearch index.
Search Reconciliation
Problem
Real-time TCP event-driven sync handles 99%+ of updates. But edge cases can cause drift:
- A source service emits
item_upsertedbut the search service is restarting โ event lost - A database migration adds items that bypass the event pipeline
- A Meilisearch index corruption or manual deletion
Solution: Daily Reconciliation Cron
The ReconciliationService runs a scheduled job at 3:00 AM daily that compares the MongoDB source-of-truth against the Meilisearch index:
sequenceDiagram
participant Cron as "@Cron (3 AM)"
participant Recon as "ReconciliationService"
participant Redis as "Redis"
participant DB as "MongoDB (Stubs)"
participant Meili as "Meilisearch"
Cron->>Recon: scheduledReconciliation()
Recon->>Redis: setnx("search:reconciliation:lock", 1h TTL)
alt Lock acquired
Recon->>DB: Fetch all Product IDs
Recon->>DB: Fetch all Service IDs
Recon->>Meili: getAllDocumentIds()
Recon->>Recon: Compute drift (missing + orphaned)
loop Missing items (in DB, not in index)
Recon->>DB: Fetch full item + Malet + Reviews
Recon->>Meili: indexItem(enriched document)
end
loop Orphaned items (in index, not in DB)
Recon->>Meili: deleteItems(orphaned IDs)
end
Recon->>Redis: Store results + lastRunAt
else Lock held
Recon->>Recon: Skip (another run in progress)
end
Recon->>Redis: Release lock
Drift Detection
| Scenario | Action |
|---|---|
| Item in MongoDB but NOT in Meilisearch | Re-index: Fetch full item with Malet enrichment and reviews, then push to index |
| Item in Meilisearch but NOT in MongoDB | Delete: Remove orphaned document from index |
| Item in both | No action: Already in sync |
Concurrency Protection
Only one reconciliation can run at a time thanks to a Redis setnx lock with a 1-hour TTL:
search:reconciliation:lock โ "running" (TTL: 3600s)
If the service crashes mid-reconciliation, the lock auto-expires after 1 hour, allowing the next scheduled run to proceed.
GraphQL API
# Check reconciliation status
query {
reconciliationStatus {
isRunning
lastRunAt
lastResult {
success
added
removed
unchanged
errors
durationMs
}
}
}
# Manually trigger a reconciliation run
mutation {
triggerReconciliation {
success
added
removed
unchanged
errors
durationMs
}
}
Bulk Import
Use Cases
- Initial index population: When deploying a new Meilisearch instance
- Schema migration: After changing index settings that require re-indexing
- Primary key repair: If the Meilisearch index loses its
primaryKey: 'id'setting (see Universal Search Index โ Gotchas) - Per-Malet recovery: When a specific Malet's items are corrupted in the index
- Vertical rollout: When a new item type becomes searchable
Import Modes
| Mode | Mutation | Description |
|---|---|---|
| All | bulkImportAll |
Re-indexes every Product and Service from MongoDB |
| By Type | bulkImportByType |
Re-indexes only Products or only Services |
| By Malet | bulkImportByMalet |
Re-indexes all items belonging to a specific Malet |
Batched Processing
Items are processed in batches of 500 to prevent memory exhaustion and Meilisearch queue overflow:
// Internal flow (simplified)
const items = await fetchItems(); // All products + services
for (let i = 0; i < items.length; i += 500) {
const batch = items.slice(i, i + 500);
const enriched = await enrichBatch(batch); // Malet + review lookup
await meilisearchService.addOrUpdate(enriched);
await redis.set('search:bulk-import:progress', i + 500);
}
Malet Caching
During bulk import, Malet metadata is cached in-memory to avoid repeated database lookups. If 100 Products all belong to the same Malet, the MaletStub is fetched only once.
Progress Tracking
Clients can poll for import progress while a job is running:
query {
bulkImportStatus {
isRunning
currentProgress # e.g. 1500
currentTotal # e.g. 5000
lastRunAt
lastResult {
success
totalProcessed
indexed
failed
durationMs
}
}
}
GraphQL API
# Re-index everything
mutation {
bulkImportAll {
success
totalProcessed
indexed
failed
durationMs
}
}
# Re-index only Products
mutation {
bulkImportByType(input: { type: PRODUCT }) {
success
totalProcessed
indexed
failed
durationMs
}
}
# Re-index all items for a specific Malet
mutation {
bulkImportByMalet(input: { maletId: "malet-xyz-123" }) {
success
totalProcessed
indexed
failed
durationMs
}
}
Redis Key Reference
All search admin keys use the search: namespace:
| Key | Type | TTL | Purpose |
|---|---|---|---|
search:synonyms:<VERTICAL> |
String (JSON) | โ | Synonym groups for a vertical |
search:reconciliation:lock |
String | 1 hour | Prevents concurrent reconciliation |
search:reconciliation:status |
String (JSON) | โ | Last reconciliation result |
search:reconciliation:lastRun |
String (ISO date) | โ | Timestamp of last reconciliation |
search:bulk-import:lock |
String | 2 hours | Prevents concurrent bulk imports |
search:bulk-import:status |
String (JSON) | โ | Last bulk import result |
search:bulk-import:lastRun |
String (ISO date) | โ | Timestamp of last import |
search:bulk-import:progress |
String (number) | Cleared after run | Current items processed |
search:bulk-import:total |
String (number) | Cleared after run | Total items in current run |
Module Structure
apps/search/src/
โโโ synonyms/
โ โโโ dto/
โ โ โโโ synonym.dto.ts # SynonymVertical enum, I/O types
โ โโโ synonym.service.ts # Redis CRUD + Meilisearch push
โ โโโ synonym.service.spec.ts # 11 unit tests
โ โโโ synonym.resolver.ts # GraphQL queries + mutations
โ โโโ synonym.resolver.spec.ts # 4 unit tests
โ โโโ synonym.module.ts # NestJS module
โ
โโโ reconciliation/
โ โโโ dto/
โ โ โโโ reconciliation.dto.ts # Result + Status output types
โ โโโ reconciliation.service.ts # Cron job + drift detection
โ โโโ reconciliation.service.spec.ts # 7 unit tests
โ โโโ reconciliation.resolver.ts # Admin queries + mutations
โ โโโ reconciliation.resolver.spec.ts # 2 unit tests
โ โโโ reconciliation.module.ts # NestJS module (ScheduleModule)
โ
โโโ bulk-import/
โ โโโ dto/
โ โ โโโ bulk-import.dto.ts # Result, Status, Input types
โ โโโ bulk-import.service.ts # Batched import logic
โ โโโ bulk-import.service.spec.ts # 8 unit tests
โ โโโ bulk-import.resolver.ts # Admin queries + mutations
โ โโโ bulk-import.resolver.spec.ts # 4 unit tests
โ โโโ bulk-import.module.ts # NestJS module
โ
โโโ meilisearch/
โโโ meilisearch.service.ts # +5 new methods (synonyms, IDs, bulk delete, stats)
# primaryKey: 'id' enforced on createIndex + addDocuments
Environment Variables
| Variable | Default | Description |
|---|---|---|
MEILISEARCH_HOST |
โ | Required. Meilisearch server URL |
MEILISEARCH_API_KEY |
โ | Required. Meilisearch admin API key |
REDIS_URL |
redis://localhost:6379 |
Redis connection for synonym storage, locks, and status |
SEARCH_SERVICE_PORT |
โ | Required. HTTP port for GraphQL API |
SEARCH_SERVICE_PORT_TCP |
โ | Required. TCP port for real-time sync events |
Testing
Unit Tests
# Run all search unit tests (105 tests across 13 suites)
npm run test -- apps/search --no-coverage
Key coverage areas:
- SynonymService: CRUD operations, seeding, bidirectional merge, Meilisearch push, graceful init failure
- ReconciliationService: Lock acquisition/skip, missing item detection, orphan cleanup, Redis status storage, lock release on error
- BulkImportService: All 3 import modes, lock handling, empty database, progress tracking, status query
- MeilisearchService: Synonym CRUD, document ID enumeration, bulk delete, stats retrieval
E2E Tests
# Run search E2E tests (20 tests across 3 suites)
npx jest --config apps/search/test/jest-e2e.json --detectOpenHandles
Covers: Full synonym lifecycle (seed โ add โ update โ delete โ apply), reconciliation with drift scenarios, and bulk import by type and by Malet.
Cross-Service Integration
Dependency Map
| Search Admin depends on | For |
|---|---|
Redis (@app/common/RedisModule) |
Synonym storage, locks, status tracking |
| Meilisearch | Search index operations (synonyms, documents, stats) |
MongoDB (ProductStub, ServiceStub, MaletStub, ReviewStub) |
Source-of-truth data for reconciliation and bulk import |
Permission Requirement
All admin mutations require Permission.MANAGE_SEARCH, which is part of the platform's action-based permission system in @app/common. This permission should be assigned to platform administrators or authorized Malet Owners who need index management capabilities.
@Mutation(() => ReconciliationResult)
@RequirePermission(Permission.MANAGE_SEARCH)
async triggerReconciliation(): Promise<ReconciliationResult> {
return this.reconciliationService.reconcile();
}
Related
- Soft-Delete & Trash System โ Soft-deleted items emit TCP events to remove from the search index
- Gateway Rate Limiting โ Admin operations benefit from authenticated user rate-limit uplift
- Organizations & Permissions โ
MANAGE_SEARCHpermission used by all admin endpoints - Tag Registry โ Platform-managed curated tags for Malets โ future integration point for search facets and real-time tag sync
- Universal Search Index โ Blog posts, Malets, and platform guides as searchable entities with ContentSource discrimination
- Entertainment & Experiences โ Events, credit wallets, and loyalty from the
experiencessubgraph โ future indexable entity type - Workspaces & The Tower โ Admin dashboard Search Analytics sub-tab consumes faceted search, reconciliation, and synonym data
- Mallnline Algorithm โ How the algorithm utilizes Meilisearch custom ranking for trending scores