Skip to content

Commit 74276fa

Browse files
knoppiksalexanderkiel
authored andcommitted
Add History Download Command
1 parent 3116344 commit 74276fa

18 files changed

+2276
-694
lines changed

.github/scripts/chaos-editor.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/bin/bash -e
2+
3+
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
4+
. "$SCRIPT_DIR/util.sh"
5+
6+
BASE="http://localhost:8080/fhir"
7+
DURATION=${1:-30s}
8+
VUS=${2:-8}
9+
10+
NUM_BEFORE="$(curl -sSf "${BASE}/_history?_summary=count" | jq -r .total)"
11+
12+
echo "Running k6 script to randomly edit resources for $DURATION"
13+
14+
# Run the k6 script with the specified parameters
15+
k6 run "$SCRIPT_DIR/k6/chaos-editor.ts" \
16+
--env BASE_URL="$BASE" \
17+
--env DURATION="$DURATION" \
18+
--env VUS="$VUS"
19+
20+
NUM_AFTER="$(curl -sSf "${BASE}/_history?_summary=count" | jq -r .total)"
21+
22+
if [ "$NUM_BEFORE" -lt "$NUM_AFTER" ]; then
23+
echo "✅ history size increased"
24+
else
25+
echo "🆘 history size did not increase, nothing has been edited"
26+
exit 1
27+
fi
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash -e
2+
3+
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
4+
. "$SCRIPT_DIR/util.sh"
5+
6+
BASE="http://localhost:8080/fhir"
7+
FILE_NAME_PREFIX="$(uuidgen)"
8+
9+
./blazectl --server "$BASE" download-history -o "$FILE_NAME_PREFIX-history.ndjson" ${@}
10+
11+
NUM_UNIQUE_ENTRIES="$(jq -r '[.resourceType, .id, .meta.versionId] | @csv' "$FILE_NAME_PREFIX-history.ndjson" | sort -u | wc -l)"
12+
NUM_ENTRIES="$(jq -r '[.resourceType, .id, .meta.versionId] | @csv' "$FILE_NAME_PREFIX-history.ndjson" | wc -l)"
13+
14+
rm "$FILE_NAME_PREFIX-history.ndjson"
15+
if [ "$NUM_ENTRIES" = "$NUM_UNIQUE_ENTRIES" ]; then
16+
echo "✅ all resource versions are unique"
17+
else
18+
echo "🆘 there are at least some non-unique resources"
19+
exit 1
20+
fi

.github/scripts/k6/chaos-editor.ts

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import {check, sleep} from "k6";
2+
import {randomIntBetween, randomItem} from "https://jslib.k6.io/k6-utils/1.2.0/index.js";
3+
import http from "k6/http";
4+
import {Options} from "k6/options";
5+
6+
// Type definitions
7+
type ResourceType = 'Patient' | 'Observation' | 'Condition' | 'Medication';
8+
9+
interface EditableField {
10+
name: string;
11+
edit: (resource: any) => string | null;
12+
}
13+
14+
interface EditableFieldsByResourceType {
15+
[key: ResourceType]: EditableField[];
16+
}
17+
18+
interface FhirResource {
19+
id: string;
20+
meta?: {
21+
versionId?: string;
22+
};
23+
24+
[key: string]: any;
25+
}
26+
27+
interface FhirBundle {
28+
entry?: Array<{
29+
resource: FhirResource;
30+
}>;
31+
32+
[key: string]: any;
33+
}
34+
35+
interface TestParams {
36+
baseUrl: string;
37+
duration: string;
38+
}
39+
40+
interface ResourcesMap {
41+
[key: ResourceType]: Array<string>;
42+
}
43+
44+
// Configuration for editable fields by resource type
45+
const editableFieldsByResourceType: EditableFieldsByResourceType = {
46+
Patient: [
47+
{
48+
name: 'name.given',
49+
edit: (resource: any): string | null => {
50+
if (resource.name?.length > 0) {
51+
const nameIndex = randomIntBetween(0, resource.name.length - 1);
52+
if (resource.name[nameIndex].given?.length > 0) {
53+
const givenIndex = randomIntBetween(0, resource.name[nameIndex].given.length - 1);
54+
resource.name[nameIndex].given[givenIndex] = `Modified-${Date.now()}`;
55+
return `Changed name.given to ${resource.name[nameIndex].given[givenIndex]}`;
56+
}
57+
}
58+
return null;
59+
}
60+
},
61+
{
62+
name: 'gender',
63+
edit: (resource: any): string => {
64+
const genders = ['male', 'female', 'other', 'unknown'];
65+
const currentGender = resource.gender;
66+
// Filter out the current gender to ensure we pick a different one
67+
const availableGenders = genders.filter(g => g !== currentGender);
68+
resource.gender = randomItem(availableGenders);
69+
return `Changed gender from ${currentGender} to ${resource.gender}`;
70+
}
71+
},
72+
{
73+
name: 'birthDate',
74+
edit: (resource: any): string => {
75+
// Generate a random date in the past
76+
const year = randomIntBetween(1920, 2010);
77+
const month = randomIntBetween(1, 12).toString().padStart(2, '0');
78+
const day = randomIntBetween(1, 28).toString().padStart(2, '0');
79+
const newDate = `${year}-${month}-${day}`;
80+
const oldDate = resource.birthDate;
81+
resource.birthDate = newDate;
82+
return `Changed birthDate from ${oldDate} to ${newDate}`;
83+
}
84+
}
85+
],
86+
Observation: [
87+
{
88+
name: 'status',
89+
edit: (resource: any): string => {
90+
const statuses = ['registered', 'preliminary', 'final', 'amended', 'corrected', 'cancelled', 'entered-in-error', 'unknown'];
91+
const currentStatus = resource.status;
92+
const availableStatuses = statuses.filter(s => s !== currentStatus);
93+
resource.status = randomItem(availableStatuses);
94+
return `Changed status from ${currentStatus} to ${resource.status}`;
95+
}
96+
},
97+
{
98+
name: 'valueQuantity',
99+
edit: (resource: any): string | null => {
100+
if (resource.valueQuantity) {
101+
const oldValue = resource.valueQuantity.value;
102+
// Modify the value by +/- 10%
103+
const change = oldValue * (randomIntBetween(-10, 10) / 100);
104+
resource.valueQuantity.value = Number((oldValue + change).toFixed(2));
105+
return `Changed valueQuantity.value from ${oldValue} to ${resource.valueQuantity.value}`;
106+
}
107+
return null;
108+
}
109+
}
110+
],
111+
Condition: [
112+
{
113+
name: 'clinicalStatus',
114+
edit: (resource: any): string => {
115+
const statuses = [
116+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: 'active'}]},
117+
{
118+
coding: [{
119+
system: 'http://terminology.hl7.org/CodeSystem/condition-clinical',
120+
code: 'recurrence'
121+
}]
122+
},
123+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: 'relapse'}]},
124+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: 'inactive'}]},
125+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: 'remission'}]},
126+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-clinical', code: 'resolved'}]}
127+
];
128+
129+
const currentCode = resource.clinicalStatus?.coding?.[0]?.code;
130+
const availableStatuses = statuses.filter(s => s.coding[0].code !== currentCode);
131+
const newStatus = randomItem(availableStatuses);
132+
133+
resource.clinicalStatus = newStatus;
134+
return `Changed clinicalStatus from ${currentCode} to ${newStatus.coding[0].code}`;
135+
}
136+
},
137+
{
138+
name: 'severity',
139+
edit: (resource: any): string => {
140+
const severities = [
141+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-severity', code: 'mild'}]},
142+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-severity', code: 'moderate'}]},
143+
{coding: [{system: 'http://terminology.hl7.org/CodeSystem/condition-severity', code: 'severe'}]}
144+
];
145+
146+
const currentCode = resource.severity?.coding?.[0]?.code;
147+
const availableSeverities = severities.filter(s => s.coding[0].code !== currentCode);
148+
const newSeverity = randomItem(availableSeverities);
149+
150+
resource.severity = newSeverity;
151+
return `Changed severity from ${currentCode} to ${newSeverity.coding[0].code}`;
152+
}
153+
}
154+
],
155+
Medication: [
156+
{
157+
name: 'status',
158+
edit: (resource: any): string => {
159+
const statuses = ['active', 'inactive', 'entered-in-error'];
160+
const currentStatus = resource.status;
161+
const availableStatuses = statuses.filter(s => s !== currentStatus);
162+
resource.status = randomItem(availableStatuses);
163+
return `Changed status from ${currentStatus} to ${resource.status}`;
164+
}
165+
}
166+
]
167+
};
168+
169+
// Default parameters
170+
const params: TestParams = {
171+
baseUrl: __ENV.BASE_URL || 'http://localhost:8080/fhir',
172+
duration: __ENV.DURATION || '30s', // The default duration is 30 seconds
173+
vus: __ENV.VUS || 8, // The default duration is 30 seconds
174+
};
175+
176+
// k6 options
177+
export let options: Options = {
178+
vus: params.vus,
179+
duration: params.duration,
180+
};
181+
182+
// Setup function to download resources for all types once
183+
const MEDIA_TYPE_FHIR = 'application/fhir+json';
184+
185+
export function setup(): ResourcesMap {
186+
console.log('Setting up resources cache...');
187+
const resourcesMap: ResourcesMap = {};
188+
const supportedResourceTypes = Object.keys(editableFieldsByResourceType) as ResourceType[];
189+
190+
for (const resourceType of supportedResourceTypes) {
191+
let bundle;
192+
try {
193+
bundle = searchResources(resourceType);
194+
} catch (e) {
195+
console.error(e instanceof Error ? e.message : String(e));
196+
sleep(1);
197+
continue;
198+
}
199+
200+
if (!bundle.entry || bundle.entry.length === 0) {
201+
console.warn(`No ${resourceType} resources found`);
202+
continue;
203+
}
204+
205+
resourcesMap[resourceType] = bundle.entry.map(e => (e.resource.id));
206+
console.log(`Cached ${resourcesMap[resourceType].length} ${resourceType} resources`);
207+
}
208+
209+
return resourcesMap;
210+
}
211+
212+
function searchResources(resourceType: ResourceType): FhirBundle {
213+
console.log(`Downloading resources for type: ${resourceType}`);
214+
const searchUrl = `${params.baseUrl}/${resourceType}?_count=1000`;
215+
216+
const searchResponse = http.get(searchUrl, {
217+
headers: {
218+
'Accept': MEDIA_TYPE_FHIR
219+
}
220+
});
221+
222+
if (searchResponse.status !== 200) {
223+
throw Error(`Failed to search for ${resourceType} resources: ${searchResponse.status}`);
224+
}
225+
226+
try {
227+
return searchResponse.json();
228+
} catch (e) {
229+
throw Error(`Failed to parse ${resourceType} search response: ${e instanceof Error ? e.message : String(e)}`);
230+
}
231+
}
232+
233+
export default function (data: ResourcesMap): void {
234+
// Get a list of all resource types we can edit that have cached resources
235+
const availableResourceTypes = Object.keys(data).filter(
236+
type => data[type].length > 0
237+
) as ResourceType[];
238+
239+
if (availableResourceTypes.length === 0) {
240+
console.warn('No resources available in any resource type. Exiting.');
241+
sleep(1);
242+
return;
243+
}
244+
245+
// Randomly select a resource from cached resources
246+
const resourceType = randomItem(availableResourceTypes);
247+
const resourceId = randomItem(data[resourceType]);
248+
249+
let resource;
250+
try {
251+
resource = fetchResource(resourceType, resourceId)
252+
} catch (e) {
253+
console.warn(e instanceof Error ? e.message : String(e));
254+
sleep(1);
255+
return;
256+
}
257+
258+
// Get the editable fields for this resource type
259+
const editableFields = editableFieldsByResourceType[resourceType];
260+
261+
// Select a random field to edit
262+
const field = randomItem(editableFields);
263+
console.log(`Attempting to edit '${resourceType}/${resource.id}.${field.name}`);
264+
265+
const editResult = field.edit(resource);
266+
if (editResult) {
267+
console.log(`Successfully edited resource: ${editResult}`);
268+
} else {
269+
console.warn(`Field ${field.name} could not be edited`);
270+
sleep(1);
271+
return;
272+
}
273+
274+
const updateResponse = http.put(`${params.baseUrl}/${resourceType}/${resourceId}`, JSON.stringify(resource), {
275+
headers: {
276+
'Accept': MEDIA_TYPE_FHIR,
277+
'Content-Type': MEDIA_TYPE_FHIR,
278+
...(resource.meta?.versionId && {'If-Match': `W/"${resource.meta.versionId}"`})
279+
}
280+
});
281+
282+
if (!check(updateResponse, {
283+
'Resource updated successfully': (r) => r.status === 200,
284+
})) {
285+
console.error(`Failed to update resource: ${updateResponse.status} ${updateResponse.body}`);
286+
sleep(1);
287+
return;
288+
}
289+
290+
console.log(`Resource ${resourceType}/${resourceId} successfully updated`);
291+
292+
sleep(0.250);
293+
}
294+
295+
function fetchResource(resourceType: ResourceType, resourceId: string): FhirResource {
296+
const url = `${params.baseUrl}/${resourceType}/${resourceId}`;
297+
298+
const response = http.get(url, {
299+
headers: {
300+
'Accept': MEDIA_TYPE_FHIR
301+
}
302+
});
303+
304+
if (response.status !== 200) {
305+
throw Error(`Failed to fetch resource ${resourceType}/${resourceId}: ${response.status}`) ;
306+
}
307+
308+
try {
309+
return response.json();
310+
} catch (e) {
311+
throw Error(`Failed to parse ${resourceType} search response: ${e instanceof Error ? e.message : String(e)}`) ;
312+
}
313+
}

.github/workflows/build.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,5 +273,20 @@ jobs:
273273
- name: Condition Code Stratifier
274274
run: .github/scripts/evaluate-measure-blazectl-stratifier.sh stratifier-condition-code 51599
275275

276+
- name: Install k6
277+
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
278+
279+
- name: Chaotically Edit Resources
280+
run: .github/scripts/chaos-editor.sh
281+
282+
- name: Download System History
283+
run: .github/scripts/download-history.sh
284+
285+
- name: Download Patients History
286+
run: .github/scripts/download-history.sh Patient
287+
288+
- name: Download Single Patient History
289+
run: .github/scripts/download-history.sh Patient $(.github/scripts/patient-ids.sh "999-89-9294")
290+
276291
- name: Docker Stats
277292
run: docker stats --no-stream

0 commit comments

Comments
 (0)