Elasticsearch
How HOPE's per-program Elasticsearch indexes work, and the rules to follow when building a feature that reads from or writes to them. To run a reindex on an environment, see Elasticsearch reindex.
The index model
Every ACTIVE program owns a pair of indexes: one for individuals, one for households.
The name the application uses, individuals_<ba-slug>_<program-code>, is not a physical
index. It is an alias pointing at a versioned physical index (..._v1, ..._v2,
...). Mapping changes are rolled out blue-green: a new version is built dark next to the
live one, then both aliases of the pair are swapped in one atomic call.
flowchart TD
APP["application code<br/>search / dedup / signal writes"] --> ALIAS["alias<br/>individuals_afghanistan_x7ie"]
ALIAS --> V2["physical index<br/>individuals_afghanistan_x7ie_v2"]
V1["individuals_afghanistan_x7ie_v1<br/>(previous version, unaliased,<br/>kept as rollback)"] -.-> DROP["es_drop_old_index_versions"]
Because the app only ever sees the alias, a reindex is invisible to it: searches and writes keep working before, during and after the swap.
New programs need nothing: when a program becomes ACTIVE, a signal creates the pair as
_v1 with the alias attached in the same call.
Golden rules
| Rule | Why |
|---|---|
Address indexes only through get_individual_doc(program_id) / get_household_doc(program_id) (hope.apps.household.documents) |
They resolve the per-program alias and carry the queryset, mapping and prepare() logic |
Never hardcode an index name, never append _vN, never attach your own aliases |
The physical name changes on every reindex, and stray names break the version bookkeeping of es_reindex / es_drop_old_index_versions |
| Never create or delete indexes ad hoc | Index lifecycle belongs to index_management.py and the management commands; a bare index squatting on an alias name breaks the next reindex |
Delete documents via remove_elasticsearch_documents_by_matching_ids() |
It resolves the alias and ignores missing documents |
Gate any new ES write path on config.IS_ELASTICSEARCH_ENABLED |
The whole sync machinery is switchable per environment (constance) |
Reading
from hope.apps.household.documents import get_individual_doc
doc = get_individual_doc(str(program_id))
results = doc.search().query("match", full_name=value).execute()
Writing — you probably don't need to
Documents are kept in sync by plain Django signals in hope/apps/household/signals.py
(the django-elasticsearch-dsl registry/autosync machinery is deliberately unused):
| Trigger | Effect |
|---|---|
Individual / Household saved (program ACTIVE, not removed) |
document upserted |
Individual / Household soft-removed or deleted |
document deleted |
Program transitions to ACTIVE |
ensure_program_indexes() creates _v1 + alias if missing and populates it |
All of it is a no-op while IS_ELASTICSEARCH_ENABLED is off. Bulk paths that bypass
save() (bulk_create, update) do not fire the signals: send the documents yourself
through the doc class, or rely on an explicit populate afterwards.
Changing the mapping
ES mappings are immutable, so a mapping change means a new index version and an alias swap. It ships in two independent steps, the code deploy and the fleet reindex, with a window in between where the aliases still point at indexes built from the old mapping. During that window:
- signal writes push the full document into the old index, so a brand-new field gets created there by ES dynamic mapping with a guessed type;
- any query that depends on the new field must therefore sit behind a feature flag (default OFF), so the old query path keeps serving until every program is reindexed.
Add fields, never change them
- Adding a field is the supported flow below.
- Changing an existing field (type, analyzer, similarity,
index_prefixes, ...) is a breaking change: until a program is reindexed, its live index still serves the old definition, and queries relying on the new one return wrong results or errors. - Renaming a field is an add plus a later removal: the old field stays (and keeps being written) until every consumer is migrated and a reindex drops it.
Step by step
-
Define the flag in
src/hope/config/fragments/constance.py(CONSTANCE_CONFIG), prefixedES_USE_, defaultFalse. Constance needs no migration and can be flipped at runtime from the admin panel."ES_USE_FULL_NAME_NGRAMS": ( False, "Search/dedup queries use the new full_name ngram field (requires a fleet reindex)", bool, ), -
Extend the mapping: add the field in
documents.py, with aprepare_*()method where the value is derived. - Feed the populate: if the field reads a relation, extend
select_related/Prefetchin the per-programget_queryset.prepare()runs once per row, so an un-prefetched relation is one extra query per row: minutes vs. hours on production volumes. Filter the prefetch querysets exactly like the related managers (Document.objects/IndividualIdentity.objects, i.e. MERGED and not removed), or the prefetch cache changes which rows get indexed. - Feed the delta: if a change to a related object must re-index the document, mirror
it in
get_instances_from_relatedandes_populate_delta._program_delta. -
Write the query path behind the flag, leaving the old path byte-for-byte intact:
from constance import config if config.ES_USE_FULL_NAME_NGRAMS: search = search.query("match", full_name__ngram=value) else: search = search.query("match", full_name=value) -
Test (see Testing).
- Merge and deploy: the flag is OFF, so nothing observable changes yet.
- Reindex, flip the flag, clean up: follow Elasticsearch reindex. Afterwards, delete the flag together with the old query path.
Never flip the flag before the fleet reindex finishes
A flag flipped early produces silently wrong search and deduplication results for every not-yet-reindexed program. Nothing errors, which makes it the worst failure mode.
Analyzer or settings changes are invisible to the reindex skip
es_reindex skips programs whose index carries a hash of the current mappings. A
change to analyzers or index settings (es_analyzers.py, index_settings) leaves the
mapping identical, so everything would be skipped. Reindex such a deploy with
es_reindex --all --force.
What never to run
Danger
- The admin Rebuild Index button deletes the LIVE index first: search and deduplication run against an empty index until the populate finishes. It is a recovery tool, not a routine one.
search_index --rebuild(from django-elasticsearch-dsl) is a silent no-op in HOPE, because the registry is empty. Do not "fix" that by registering documents in it.
Testing
- ES is disabled by default in unit tests (
IS_ELASTICSEARCH_ENABLEDoff,ELASTICSEARCH_DSL_AUTOSYNC = False). Use themock_elasticsearchfixture when the code under test merely touches ES, anddjango_elasticsearch_setupwhen the test needs a live index. - Tests that exercise the sync paths need
@override_config(IS_ELASTICSEARCH_ENABLED=True).
Introduced with the blue-green reindex tooling: PR #6293 ⧉.