-
Notifications
You must be signed in to change notification settings - Fork 68
ENG-371 Fix hydration + Adj API ECS autoscale #3940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c8a122c
refactor(core): rename OS fhir search and query files
leite08 1d34a4c
refactor: remove an unused folder
leite08 e026777
refactor: move search-lexical into search-consolidated file
leite08 4436f77
fix(core): hydrateMissingReferences doesn't load unnecessary resources
leite08 8066c39
build(infra): update ECS autoscale from 10 to 15% CPU
leite08 9a532c4
refactor(core): add fn to convert OS result to Resource
leite08 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Submodule metriport
deleted from
4168d1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
306 changes: 306 additions & 0 deletions
306
.../core/src/command/consolidated/search/fhir-resource/__tests__/search-consolidated.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,306 @@ | ||
import { faker } from "@faker-js/faker"; | ||
import { Encounter, Resource } from "@medplum/fhirtypes"; | ||
import { MarkRequired } from "ts-essentials"; | ||
import { getIdFromReference } from "../../../../../external/fhir/shared/references"; | ||
import { makeLocation } from "../../../../../external/fhir/__tests__/location"; | ||
import { makePatient, PatientWithId } from "../../../../../external/fhir/__tests__/patient"; | ||
import { makeReference } from "../../../../../external/fhir/__tests__/reference"; | ||
import { FhirSearchResult } from "../../../../../external/opensearch/index-based-on-fhir"; | ||
import { OpenSearchFhirSearcher } from "../../../../../external/opensearch/lexical/fhir-searcher"; | ||
import { getEntryId as getEntryIdFromOpensearch } from "../../../../../external/opensearch/shared/id"; | ||
import { makeCondition } from "../../../../../fhir-to-cda/cda-templates/components/__tests__/make-condition"; | ||
import { | ||
makeEncounter as makeEncounterImported, | ||
makePractitioner, | ||
} from "../../../../../fhir-to-cda/cda-templates/components/__tests__/make-encounter"; | ||
import { hydrateMissingReferences } from "../search-consolidated"; | ||
|
||
describe("search-consolidated", () => { | ||
describe("hydrateMissingReferences", () => { | ||
let cxId: string; | ||
let patient: PatientWithId; | ||
let patientId: string; | ||
let getByIds_mock: jest.SpyInstance; | ||
let toEntryId: (resource: Resource | string) => string; | ||
let toGetByIdsResultEntry: (resource: Resource | string) => FhirSearchResult; | ||
|
||
beforeEach(() => { | ||
cxId = faker.string.uuid(); | ||
patient = makePatient(); | ||
patientId = patient.id; | ||
getByIds_mock = jest | ||
.spyOn(OpenSearchFhirSearcher.prototype, "getByIds") | ||
.mockResolvedValue([]); | ||
toEntryId = makeToEntryId(cxId, patientId); | ||
toGetByIdsResultEntry = makeToGetByIdsResultEntry(cxId, patientId, toEntryId); | ||
}); | ||
afterEach(() => { | ||
getByIds_mock.mockRestore(); | ||
}); | ||
|
||
it(`returns original array when nothing to hydrate`, async () => { | ||
const resources = [makeCondition(undefined, patient.id)]; | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(resources); | ||
expect(getByIds_mock).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it(`hydrates missing first level references`, async () => { | ||
const missingEncounter = makeEncounter(undefined, patientId); | ||
const resources = [ | ||
patient, | ||
makeCondition({ encounter: makeReference(missingEncounter) }, patient.id), | ||
]; | ||
const firstLevelReferenceIds = [missingEncounter].map(toEntryId); | ||
const getByIdsResponse = [missingEncounter].map(toGetByIdsResultEntry); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsResponse); | ||
const hydratedResources = [...resources, missingEncounter]; | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(expect.arrayContaining(hydratedResources)); | ||
expect(getByIds_mock).toHaveBeenCalledWith({ | ||
cxId, | ||
patientId, | ||
ids: firstLevelReferenceIds, | ||
}); | ||
}); | ||
|
||
it(`hydrates missing first and second levels`, async () => { | ||
const missingLocation = makeLocation({ patient }); | ||
const missingPractitioner = makePractitioner(undefined); | ||
const missingEncounter = makeEncounter( | ||
{ | ||
location: [makeReference(missingLocation)], | ||
participant: [{ individual: makeReference(missingPractitioner) }], | ||
}, | ||
patientId | ||
); | ||
|
||
const resources = [ | ||
patient, | ||
makeCondition({ encounter: makeReference(missingEncounter) }, patient.id), | ||
]; | ||
const firstLevelMissingRefs = [missingEncounter]; | ||
const secondLevelMissingRefs = [missingPractitioner, missingLocation]; | ||
const firstLevelReferenceIds = firstLevelMissingRefs.map(toEntryId); | ||
const secondLevelReferenceIds = secondLevelMissingRefs.map(toEntryId); | ||
const getByIdsFirstResponse = firstLevelMissingRefs.map(toGetByIdsResultEntry); | ||
const getByIdsSecondResponse = secondLevelMissingRefs.map(toGetByIdsResultEntry); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsFirstResponse); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsSecondResponse); | ||
const hydratedResources = [ | ||
...resources, | ||
missingEncounter, | ||
missingPractitioner, | ||
missingLocation, | ||
]; | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(expect.arrayContaining(hydratedResources)); | ||
expect(getByIds_mock).toHaveBeenNthCalledWith(1, { | ||
cxId, | ||
patientId, | ||
ids: firstLevelReferenceIds, | ||
}); | ||
expect(getByIds_mock).toHaveBeenNthCalledWith(2, { | ||
cxId, | ||
patientId, | ||
ids: secondLevelReferenceIds, | ||
}); | ||
}); | ||
|
||
it(`respects maximum hydration attempts`, async () => { | ||
const missingEncounter = makeEncounter(undefined, patientId); | ||
const resources = [makeCondition({ encounter: makeReference(missingEncounter) }, patient.id)]; | ||
|
||
const res = await hydrateMissingReferences({ | ||
cxId, | ||
patientId, | ||
resources, | ||
iteration: 5, // Start at max attempts | ||
}); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(resources); | ||
expect(getByIds_mock).not.toHaveBeenCalled(); // Should only try once due to max attempts | ||
}); | ||
|
||
it(`handles missing resources in OpenSearch`, async () => { | ||
const missingEncounter = makeEncounter(undefined, patientId); | ||
const resources = [makeCondition({ encounter: makeReference(missingEncounter) }, patient.id)]; | ||
const firstLevelReferenceIds = [missingEncounter].map(toEntryId); | ||
getByIds_mock.mockResolvedValueOnce([]); // Simulate resource not found | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(resources); // Should return original resources | ||
expect(getByIds_mock).toHaveBeenCalledWith({ | ||
cxId, | ||
patientId, | ||
ids: firstLevelReferenceIds, | ||
}); | ||
}); | ||
|
||
it(`ignores patient references`, async () => { | ||
const missingEncounter = makeEncounter(undefined, patientId); | ||
const condition = makeCondition({ encounter: makeReference(missingEncounter) }, patient.id); | ||
if (!condition.subject) throw new Error("Condition subject is required"); | ||
const patientIdFromCondition = getIdFromReference(condition.subject); | ||
if (patientIdFromCondition !== patientId) { | ||
throw new Error("Patient ID not matched on Condition"); | ||
} | ||
const resources = [condition]; | ||
const firstLevelReferenceIds = [missingEncounter].map(toEntryId); | ||
const getByIdsResponse = [missingEncounter].map(toGetByIdsResultEntry); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsResponse); | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual(expect.arrayContaining([...resources, missingEncounter])); | ||
expect(getByIds_mock).toHaveBeenCalledWith({ | ||
cxId, | ||
patientId, | ||
ids: firstLevelReferenceIds, | ||
}); | ||
}); | ||
|
||
it(`handles circular references`, async () => { | ||
const missingLocation = makeLocation({ patient }); | ||
const missingEncounter = makeEncounter( | ||
{ location: [makeReference(missingLocation)] }, | ||
patientId | ||
); | ||
|
||
// Create circular reference: Location -> Encounter -> Location | ||
const locationWithEncounter = { | ||
...missingLocation, | ||
extension: [ | ||
{ | ||
url: "http://example.com/encounter", | ||
valueReference: makeReference(missingEncounter), | ||
}, | ||
], | ||
}; | ||
|
||
const resources = [makeCondition({ encounter: makeReference(missingEncounter) }, patient.id)]; | ||
const firstLevelMissingRefs = [missingEncounter]; | ||
const secondLevelMissingRefs = [locationWithEncounter]; | ||
const getByIdsFirstResponse = firstLevelMissingRefs.map(toGetByIdsResultEntry); | ||
const getByIdsSecondResponse = secondLevelMissingRefs.map(toGetByIdsResultEntry); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsFirstResponse); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsSecondResponse); | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual( | ||
expect.arrayContaining([...resources, missingEncounter, locationWithEncounter]) | ||
); | ||
expect(getByIds_mock).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it(`handles multiple references to the same resource`, async () => { | ||
const missingLocation = makeLocation({ patient }); | ||
const missingPractitioner = makePractitioner(undefined); | ||
const encounterSeedParams = { | ||
location: [makeReference(missingLocation)], | ||
participant: [{ individual: makeReference(missingPractitioner) }], | ||
}; | ||
const missingEncounter1 = makeEncounter(encounterSeedParams, patientId); | ||
const missingEncounter2 = makeEncounter(encounterSeedParams, patientId); | ||
|
||
const resources = [ | ||
makeCondition({ encounter: makeReference(missingEncounter1) }, patient.id), | ||
makeCondition({ encounter: makeReference(missingEncounter2) }, patient.id), | ||
]; | ||
const firstLevelMissingRefs = [missingEncounter1, missingEncounter2]; | ||
const secondLevelMissingRefs = [missingLocation, missingPractitioner]; | ||
const secondLevelReferenceIds = secondLevelMissingRefs.map(toEntryId); | ||
const getByIdsFirstResponse = firstLevelMissingRefs.map(toGetByIdsResultEntry); | ||
const getByIdsSecondResponse = secondLevelMissingRefs.map(toGetByIdsResultEntry); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsFirstResponse); | ||
getByIds_mock.mockResolvedValueOnce(getByIdsSecondResponse); | ||
|
||
const res = await hydrateMissingReferences({ cxId, patientId, resources }); | ||
|
||
expect(res).toBeTruthy(); | ||
expect(res).toEqual( | ||
expect.arrayContaining([ | ||
...resources, | ||
missingEncounter1, | ||
missingEncounter2, | ||
missingLocation, | ||
missingPractitioner, | ||
]) | ||
); | ||
expect(getByIds_mock).toHaveBeenCalledTimes(2); | ||
// Verify we don't try to fetch the same resource multiple times | ||
expect(getByIds_mock).toHaveBeenNthCalledWith(2, { | ||
cxId, | ||
patientId, | ||
ids: expect.arrayContaining(secondLevelReferenceIds), | ||
}); | ||
}); | ||
}); | ||
}); | ||
|
||
function makeToEntryId(cxId: string, patientId: string) { | ||
return (resource: Resource | string) => { | ||
if (typeof resource === "string") { | ||
if (resource.startsWith("urn:uuid:")) return resource.slice(9); | ||
const refId = resource.split("/").pop(); | ||
if (!refId) throw new Error(`Invalid reference: ${resource}`); | ||
return refId; | ||
} | ||
if (!resource.id) throw new Error(`Resource ID is required`); | ||
return getEntryIdFromOpensearch(cxId, patientId, resource.id); | ||
}; | ||
} | ||
|
||
function makeToGetByIdsResultEntry( | ||
cxId: string, | ||
patientId: string, | ||
toEntryId: (resource: Resource | string) => string | ||
) { | ||
return (resource: Resource | string): FhirSearchResult => { | ||
if (typeof resource === "string") { | ||
return { | ||
cxId, | ||
patientId, | ||
entryId: toEntryId(resource), | ||
resourceType: "UNKNOWN", | ||
resourceId: resource, | ||
rawContent: JSON.stringify({ resourceType: "UNKNOWN", resourceId: resource }), | ||
}; | ||
} | ||
if (!resource.id) throw new Error(`Resource ID is required`); | ||
return { | ||
cxId, | ||
patientId, | ||
entryId: toEntryId(resource), | ||
resourceType: resource.resourceType, | ||
resourceId: resource.id, | ||
rawContent: JSON.stringify(resource), | ||
}; | ||
}; | ||
} | ||
|
||
function makeEncounter( | ||
params: Partial<Encounter> | undefined, | ||
patientId: string | ||
): MarkRequired<Encounter, "id"> { | ||
const encounter = makeEncounterImported(params, { patient: patientId }); | ||
if (!params?.participant) encounter.participant = []; | ||
if (!params?.location) encounter.location = []; | ||
if (!encounter.id) throw new Error("Encounter ID is required"); | ||
return { ...encounter, id: encounter.id }; | ||
} |
2 changes: 1 addition & 1 deletion
2
packages/core/src/command/consolidated/search/fhir-resource/fhir-config.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 1 addition & 1 deletion
2
packages/core/src/command/consolidated/search/fhir-resource/ingest-if-needed.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.