Skip to content

[16.0][PERF] ni_observation: filter latest-observation views by id first - #176

Merged
piruin merged 2 commits into
16.0from
16.0-perf-latest-observation
Oct 23, 2026
Merged

piruin merged 2 commits into
16.0from
16.0-perf-latest-observation

Conversation

@piruin

@piruin piruin commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #175 · Related #174

Summary (TH)

เปลี่ยน view ni.patient.observation และ ni.encounter.observation จาก DISTINCT ON เป็น NOT EXISTS เพื่อให้ PostgreSQL กรองด้วย id หรือ survey_response_id ได้ก่อนเลือกแถวล่าสุด ไม่ต้องสร้าง view ใหม่จากทั้งตาราง ni_observation ทุกครั้งที่อ่าน และเพิ่ม index ให้ ni_observation.survey_response_id

Changes

Module Change
ni_observation Both views pick the latest valued row per (patient_id, type_id) / (encounter_id, type_id) with a NOT EXISTS anti-join: highest occurrence, ties broken by highest id, the same order as the old ORDER BY. SELECT o.* keeps the column set. New tests in test_latest_observation.py. README documents the views and the index dependency.
ni_questionnaire survey_response_id on ni.observation.abstract gets index=True. ni.observation is the only stored model that inherits it, so the index is named ni_observation_survey_response_id_index, the name already on production.

Decisions to review

  • The encounter view drops the NULL-encounter group (o.encounter_id IS NOT NULL). DISTINCT ON returned one row per type for all observations without an encounter. Every caller (encounter_observation_ids, observation_latest_ids, _compute_observation_latest) filters by encounter_id, ni_encounter_ob_line_action has no menu or reference, and nirun-dop-16.0 does not use the view.
  • Rows without type_id or occurrence are left out of both views. Both fields are nullable in the schema, and the anti-join cannot group NULLs the way DISTINCT ON does: every NULL row would come back. The report model already declares type_id required=True. No NOT NULL constraint was added.

How tested

  • -i ni_observation,ni_questionnaire --test-enable: 0 failed, 0 errors of 149 tests. -i ni_careplan --test-enable (searches the patient view): 0 failed. ni_careplan_problem is not installed on the local DB, so it was not tested.
  • New cases in both views: an older occurrence with a higher id loses, a newer row without a value is ignored, another patient/encounter does not hide the latest, search by id returns only the latest row, and an observation without an encounter is not in the encounter view. The tied-occurrence case is covered by the existing test_same_occurrence_picks_the_last_record.
  • Old DISTINCT ON vs new view on a local DB (~3.8k rows): no new rows in either view. Patient view: 1 row fewer (id 4405, no type_id). Encounter view: 18 rows fewer, all without an encounter.
  • EXPLAIN (with enable_seqscan = off, the table is small): a read by id uses ni_observation_pkey, a search by survey_response_id uses ni_observation_survey_response_id_index, and both anti-join through ni_observation__{patient,encounter}__ob_type__idx. The only Sort orders the result rows; there is no full scan or sort of ni_observation.
  • -u ni_questionnaire on a DB that already has ni_observation_survey_response_id_index keeps the same index (same OID).
  • pre-commit clean on the changed files.

After merge (production)

  • ni_observation__patient__ob_type__idx on production still has the old (patient_id, type_id) definition. tools.create_index() skips existing names, so rebuild it as (patient_id, type_id, occurrence DESC, id) with CREATE INDEX CONCURRENTLY, outside the module upgrade, to get index-only anti-joins. The encounter-side index was already rebuilt on 2026-10-23.

PostgreSQL pushes a WHERE clause into a DISTINCT ON subquery only when
it uses the DISTINCT ON columns. The ORM reads these views by id and
searches them by survey_response_id, so every call rebuilt the view
from the whole ni_observation table (up to ~100 s per read on
production). The NOT EXISTS form lets the planner start from the
primary key and anti-join through the (key, type_id, occurrence DESC,
id) indexes.

The anti-join cannot group NULLs the way DISTINCT ON does, so rows
without type_id or occurrence are left out, and the encounter view
leaves out observations without an encounter. Every caller filters
the encounter view by encounter_id, and the report model already
requires type_id.

Fixes #175
The latest-observation views are searched by survey_response_id about
a thousand times per tuning window. The index name matches the one
already created on production, so the upgrade leaves it in place.

Fixes #175
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] ni_observation: let latest-observation views filter by id before picking the latest row

1 participant