Skip to content

Search Service ​

search-service owns rebuildable search indexes, source-service sync checkpoints, and replay receipts/version tombstones for copied projection events. It stores copied index data only; canonical question, exam, course, and document state stays in the owner services.

Public search-like routes remain legacy-proxied until backfill/event pipelines, gateway adapters, and browser parity evidence exist. Native /v1/* routes are for service tests, migration tooling, service-to-service indexing, and future gateway adapters only.

Current legacy catalog routes are not search-service routes. The legacy snapshot contains /api/public/catalog* under PublicCatalogController, and docs/architecture/legacy-api-map.md maps those public catalog paths to course-service. Search-service may index copied COURSE documents for discovery, but it must not claim /api/public/catalog* or add synthetic /api/search gateway compatibility without an explicit legacy route and owner mapping.

Boundary ​

Owns:

  • Indexed copies for QUESTION, EXAM, COURSE, and DOCUMENT.
  • Full-text search over title, summary, content, tags, and taxonomy. Gateway adapters may send legacy-style q; native callers may send query.
  • Filters for organization, owner, entity type, status, visibility, tags, and taxonomy.
  • Native search scope enforcement: organizationId or X-Organization-Id is required unless the caller explicitly sends allOrganizations=true with an ADMIN actor role.
  • Sync checkpoints per source service and entity type.

Does not own:

  • Canonical question, exam, course, document, profile, wallet, payment, or audit data.
  • Business writes or source-of-truth status transitions.
  • Gateway response compatibility for public routes until a route-table cutover task explicitly adds it.

Native Routes ​

MethodPathPurpose
POST/v1/index/documentsUpsert one rebuildable search document.
POST/v1/index/documents/bulkUpsert up to 500 search documents.
DELETE/v1/index/documents/{entityType}/{entityId}Delete one indexed document by optional organizationId.
POST/v1/searchQuery indexed copies with filters and pagination.
POST/v1/rebuild/question-bank/questionsPull one QUESTION projection page from question-bank-service and index copied documents.
POST/v1/rebuild/course-service/coursesPull one COURSE projection page from course-service and index copied documents.
POST/v1/rebuild/exam-service/examsPull one EXAM projection page from exam-service and index copied documents.
POST/v1/rebuild/document-service/documentsPull one DOCUMENT projection page from document-service and index copied documents.
PUT/v1/sync-stateUpsert source-service sync checkpoint state.
GET/v1/sync-state/{sourceService}/{entityType}Read one sync checkpoint.

Search Scope ​

POST /v1/search rejects unscoped requests before storage lookup. Callers must send organizationId in the JSON body or X-Organization-Id in headers. Global search requires allOrganizations=true plus userRole=ADMIN or X-User-Role: ADMIN; otherwise the service returns SEARCH_FORBIDDEN.

This is only a search-service scope guard. Owner services and gateway/BFF contracts still own canonical visibility and authorization decisions before a public search/catalog route can be promoted.

Route Promotion Gate ​

Before adding a gateway route-table rehearsal for search-service, the task must cite the exact legacy route, controller, and owner mapping. A valid rehearsal is non-default first, proves gateway scope/auth guards, preserves rollback to legacy, and includes browser/runtime parity evidence before any default route promotion.

Current route proof is the pending guard in docs/qa/search-route-rehearsals.md:

bash
make test-search-routes

Question Bank Rebuild ​

POST /v1/rebuild/question-bank/questions is an internal job/migration trigger. It requires QUESTION_BANK_SERVICE_BASE_URL on search-service and calls question-bank-service GET /v1/questions/search-projections. The trigger indexes one page, writes sync-state for question-bank-service/QUESTION, and returns hasMore=true with sync-state status PARTIAL while additional pages remain.

Requests may pass page, limit, organizationId, status, includeArchived, and allOrganizations. X-Organization-Id, X-User-Id, and X-User-Role are forwarded when body fields are omitted. Search-service rejects unscoped rebuilds unless allOrganizations=true is paired with ADMIN actor role; when organizationId is present, the rebuild remains org-scoped. When status is omitted, archived questions are included so rebuilds preserve archive/delete state.

For an initial rebuild, start search-service with the matching owner-service base URL configured, then use the migration helper to repeat the one-page trigger until the owner route returns no more pages. The helper accepts --source question-bank, --source course, --source exam, or --source document and validates the returned owner sourceService plus entityType.

bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source question-bank

This remains an internal native workflow. Public search/catalog routes still need gateway adapters, event/subscriber freshness evidence, and browser parity before any cutover.

Course Rebuild ​

POST /v1/rebuild/course-service/courses is the matching one-page trigger for course-service GET /v1/courses/search-projections. It requires COURSE_SERVICE_BASE_URL on search-service and writes sync-state for course-service/COURSE.

Requests use the same rebuild body fields and scope rules as the question trigger. The body or headers may pass organization and actor scope; status=PUBLISHED can narrow a targeted rebuild, while omitting status preserves archived courses.

bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source course

Exam Rebuild ​

POST /v1/rebuild/exam-service/exams pulls one projection page from exam-service GET /v1/exams/search-projections. It requires EXAM_SERVICE_BASE_URL on search-service and writes sync-state for exam-service/EXAM.

Requests use the same rebuild body fields and scope rules as the question and course triggers. organizationId, userId, and userRole can come from the body or forwarded headers. allOrganizations=true is forwarded as allOrgs=1 only after search-service verifies an ADMIN actor; exam-service only honors that bypass when no explicit organizationId is sent.

Exam projection documents are copied search data only. Runtime secrets remain owned by exam-service: the projection includes requiresAccessPassword and link counts, but does not include access password hashes or access-link codes.

bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source exam

Document Rebuild ​

POST /v1/rebuild/document-service/documents pulls one projection page from document-service GET /v1/documents/search-projections. It requires DOCUMENT_SERVICE_BASE_URL on search-service and writes sync-state for document-service/DOCUMENT.

Requests use the same rebuild body fields and scope rules as the other owner triggers. status=APPROVED can narrow public/catalog-style backfills, while omitting status lets document-service include review/archive states according to includeArchived.

bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source document

Projection Freshness Worker ​

For continuous copied-index freshness after the initial rebuild, start search-service with SEARCH_REBUILD_WORKER_ENABLED=1. The worker is internal only: it does not expose new gateway routes and it uses the same RebuildFromProjection path as the manual /v1/rebuild/* triggers.

Worker defaults:

  • SEARCH_REBUILD_WORKER_SOURCES=question-bank,course,exam,document
  • SEARCH_REBUILD_WORKER_INTERVAL=5m
  • SEARCH_REBUILD_WORKER_MAX_PAGES=1
  • SEARCH_REBUILD_WORKER_MAX_CONCURRENT_SOURCES=2
  • SEARCH_REBUILD_WORKER_LIMIT=100
  • SEARCH_REBUILD_WORKER_ALL_ORGANIZATIONS=1
  • SEARCH_REBUILD_WORKER_USER_ID=search-service-worker
  • SEARCH_REBUILD_WORKER_USER_ROLE=ADMIN
  • SEARCH_REBUILD_WORKER_INCLUDE_ARCHIVED=1

Each tick reads the current sync-state cursor for each configured source, pulls up to the configured page cap, and lets the normal rebuild path write PARTIAL or SYNCED. Owner-source failures are logged and recorded as sync-state ERROR, then the worker continues to the next source so search-service availability is not tied to an owner-service outage. Up to two independent owner sources run concurrently by default; set SEARCH_REBUILD_WORKER_MAX_CONCURRENT_SOURCES=1 when an environment needs a strictly serial rebuild load profile.

Each owner result includes latencyMs covering sync-state lookup, owner fetch, copied-index mutation, and sync-state write. The worker emits that field with the source/entity/page/indexed/cursor fields in structured logs. Use those observations before lowering the interval or raising concurrency; they are not a production end-to-end search latency SLO.

SEARCH_PROJECTION_PULL_WORKER_POSTGRES_CONFIRM=disposable-postgres make test-search-projection-pull-worker-postgres is the local restart/resume proof: it starts the real search-service process, writes one page per owner to a fresh Postgres store, restarts the process, then verifies each persisted cursor/nextPage=2 checkpoint resumes at page two and returns to SYNCED/nextPage=1 through native search and sync-state reads. It does not add a public search or catalog route.

TestProjectionFreshnessWorkerFromEnvRebuildsOwnerFixturesConcurrently holds four disposable HTTP owner-fixture responses until all configured worker calls are in flight. It verifies the internal ADMIN scope and request options, copied index rows, and SYNCED checkpoints without exposing a Gateway route.

Projection Change Event Contract ​

contracts/events/search.projection.changed.v1.schema.json defines an internal owner-to-search contract for one copied QUESTION, COURSE, EXAM, or DOCUMENT change. Search-service accepts it through the internal use case only; there is no HTTP endpoint or Gateway route.

The event envelope id is stored as a (source,id) receipt. A positive, strictly increasing sourceVersion is required for each (source,organizationId,entityType,entityId) tuple. Search-service writes the receipt, version/tombstone, copied-index UPSERT or DELETE, and freshness state in one transaction. Duplicate events do nothing; same-or-lower versions are recorded stale so an old UPSERT cannot restore a newer delete. Event processing does not overwrite the cursor used by pull rebuilds.

Only these owner/entity pairs are valid: question-bank-service/QUESTION, course-service/COURSE, exam-service/EXAM, and document-service/DOCUMENT. An opt-in subscriber starts only when SEARCH_PROJECTION_EVENT_TRANSPORT=nats is configured. It consumes JetStream stream SEARCH_PROJECTIONS on subject search.projection.changed.v1 with durable ACK/NAK handling. The default is disabled and Question Bank is the only wired owner publisher; course, exam, and document remain pull-rebuild sources. The opt-in pull worker remains the production reconciliation and rollback fallback until all owner outboxes and transport replay proof are complete.

Legacy Evidence ​

The first generic contract is based on stable legacy and native surfaces:

  • Question search uses q, taxonomy/type/source/status, org/owner scope, pagination, and newest-first defaults.
  • Course/catalog search uses q, status, subject/grade, visibility, and public catalog filters.
  • Admin exam/document search uses q, status/workflow/creator/owner, visibility, subject/grade, page, and limit filters.

Those are represented as copied index fields only. Owner services still decide what is visible and canonical.

Verification ​

bash
GOTOOLCHAIN=go1.25.11 go test ./services/search-service/... -count=1
bash
GOTOOLCHAIN=go1.25.11 go test -race ./services/search-service/internal/http \
  -run TestProjectionFreshnessWorkerFromEnvRebuildsOwnerFixturesConcurrently -count=1 -v

Optional Postgres repository integration:

bash
SEARCH_SERVICE_POSTGRES_TEST_DATABASE_URL='postgres://hoctapaz:hoctapaz@localhost:5433/hoctapaz_platform?sslmode=disable' \
  GOTOOLCHAIN=go1.25.11 go test ./services/search-service/internal/repository \
  -run TestPostgresSearchIndexAndSyncIntegration -count=1 -v

Projection-event Postgres proof uses a disposable database only:

bash
SEARCH_PROJECTION_EVENTS_POSTGRES_CONFIRM=disposable-postgres \
  make test-search-projection-events-postgres

Disposable broker proof for the Question Bank owner path:

bash
SEARCH_PROJECTION_BROKER_SMOKE_CONFIRM=disposable-runtime \
  make test-search-projection-broker-live

This is not a default-route or Gateway promotion.

Go-platform documentation is generated from repository Markdown.