From a42a4249d43ea3c11c5b0e29b3b6636b308e4efb Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 2 Dec 2025 19:42:41 +0100 Subject: [PATCH 01/15] Update container names for grafana and prometheus --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 49834b5..3f5a7bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,7 @@ services: - backend prometheus: + container_name: prometheus image: prom/prometheus:latest volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml @@ -100,6 +101,7 @@ services: - backend grafana: + container_name: grafana image: grafana/grafana:latest environment: GF_SECURITY_ADMIN_USER: admin From 4df08b88dfc6957234310e7c7701527bafcb18e4 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Sun, 7 Dec 2025 18:51:50 +0100 Subject: [PATCH 02/15] Update yml files: - Add pushgateway in docker-compose.yml and update prometheus command section - Add remote_write in prometheus.yml file and pushgateway for proper Grafana metrics display --- docker-compose.yml | 9 +++++++++ monitoring/prometheus.yml | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3f5a7bb..e9b5461 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,9 @@ services: prometheus: container_name: prometheus image: prom/prometheus:latest + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--web.enable-remote-write-receiver' volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml ports: @@ -115,6 +118,12 @@ services: volumes: - grafana-data:/var/lib/grafana + pushgateway: + container_name: pushgateway + image: prom/pushgateway:latest + ports: + - "9091:9091" + volumes: pgdata: kafka-data: diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index c000335..735cf42 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -1,8 +1,15 @@ global: scrape_interval: 5s +remote_write: + - url: http://prometheus:9090/api/v1/write + scrape_configs: - job_name: 'springboot-app' metrics_path: '/actuator/prometheus' static_configs: - - targets: ['backend:8080'] \ No newline at end of file + - targets: ['backend:8080'] + + - job_name: 'k6' + static_configs: + - targets: ['pushgateway:9091'] From 4d3577faa844c1d777fb59c90d3366b9754b9e9e Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Mon, 8 Dec 2025 18:57:08 +0100 Subject: [PATCH 03/15] Update tests to include short JSON summary on finish --- .../delete_course_by_id/load_delete_course_by_id.js | 7 +++++++ .../delete_course_by_id/spike_delete_course_by_id.js | 7 +++++++ .../delete_course_by_id/stress_delete_course_by_id.js | 7 +++++++ .../get_all_courses/load_get_all_courses.js | 7 +++++++ .../get_all_courses/spike_get_all_courses.js | 7 +++++++ .../get_all_courses/stress_get_all_courses.js | 7 +++++++ .../get_course_by_id/load_get_course_by_id.js | 7 +++++++ .../get_course_by_id/spike_get_course_by_id.js | 7 +++++++ .../get_course_by_id/stress_get_course_by_id.js | 7 +++++++ .../get_course_by_name/load_get_course_by_name.js | 7 +++++++ .../get_course_by_name/spike_get_course_by_name.js | 7 +++++++ .../get_course_by_name/stress_get_course_by_name.js | 7 +++++++ .../load_get_course_lessons_by_id.js | 7 +++++++ .../spike_get_course_lessons_by_id.js | 7 +++++++ .../stress_get_course_lessons_by_id.js | 7 +++++++ .../post_all_courses/load_post_all_courses.js | 7 +++++++ .../post_all_courses/spike_post_all_courses.js | 7 +++++++ .../post_all_courses/stress_post_all_courses.js | 7 +++++++ 18 files changed, 126 insertions(+) diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js index 2e674ea..060d93f 100644 --- a/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js +++ b/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js @@ -16,3 +16,10 @@ export default function () { check(res, { 'DELETE load': (r) => [200, 204, 404].includes(r.status) }); sleep(Math.random() * 5 + 2) } + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; +} \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js index 4ee9836..47db2f0 100644 --- a/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js +++ b/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js @@ -17,4 +17,11 @@ export default function () { const res = http.del(`${BASE_URL}/${courseId}`, null, { headers: defaultHeaders }); check(res, { 'DELETE spike': (r) => [200, 204, 404].includes(r.status) }); sleep(Math.random() * 5 + 2) +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js index 10fb7c5..7e18c8d 100644 --- a/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js +++ b/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js @@ -12,4 +12,11 @@ export default function () { const res = http.del(`${BASE_URL}/${courseId}`, null, { headers: defaultHeaders }); check(res, { 'DELETE stress': (r) => [200, 204, 404].includes(r.status) }); sleep(Math.random() * 5 + 2); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js index effdcb4..10da7a0 100644 --- a/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js +++ b/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js @@ -19,3 +19,10 @@ export default function () { check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') }); sleep(Math.random() * 2 + 1); } + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; +} \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js index 806018a..d3797d0 100644 --- a/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js +++ b/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js @@ -19,3 +19,10 @@ export default function () { const res = http.get(BASE_URL, { headers: defaultHeaders }); check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') }); } + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; +} \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js index 3fd8b9f..190a06b 100644 --- a/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js +++ b/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js @@ -22,3 +22,10 @@ export default function () { const res = http.get(BASE_URL, { headers: defaultHeaders }); check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') }); } + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; +} \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js index bba9cf9..185be0a 100644 --- a/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js @@ -19,4 +19,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1'; export default function () { const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js index 4fa0e57..73a69f0 100644 --- a/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js @@ -15,4 +15,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1'; export default function () { const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js index a2baebf..ff3ebaa 100644 --- a/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js @@ -23,4 +23,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1'; export default function () { const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js index 240a532..c56c964 100644 --- a/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js +++ b/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js @@ -17,3 +17,10 @@ export default function () { check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) }); sleep(0.5); } + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; +} \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js index 07692d0..f0efdad 100644 --- a/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js +++ b/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js @@ -20,4 +20,11 @@ export default function () { const res = http.get(`${BASE_URL}/name/${courseName}`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js index 04bed14..18e058f 100644 --- a/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js +++ b/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js @@ -23,4 +23,11 @@ export default function () { const res = http.get(`${BASE_URL}/name/${courseName}`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js index e8eee45..9ad07d5 100644 --- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js @@ -15,4 +15,11 @@ export default function () { const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) }); sleep(0.5); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js index 697635f..21eb91b 100644 --- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js @@ -18,4 +18,11 @@ export let options = { export default function () { const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js index 374ffad..20649b6 100644 --- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js +++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js @@ -22,4 +22,11 @@ export default function () { const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders }); check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js index a0892cf..6a03427 100644 --- a/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js +++ b/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js @@ -18,4 +18,11 @@ export default function () { const res = http.post(BASE_URL, { headers: defaultHeaders }); check(res, { 'POST /api/courses status 200': (r) => checkResponse(r, 200, 'POST /api/courses') }); sleep(Math.random() * 2 + 1); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_load_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js index d9b5d4f..c22512b 100644 --- a/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js +++ b/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js @@ -31,4 +31,11 @@ export default function () { 'POST /api/courses → status 201': (r) => checkResponse(r, 201, 'POST /api/courses'), }); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_spike_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file diff --git a/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js index 19efa26..0388eed 100644 --- a/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js +++ b/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js @@ -39,4 +39,11 @@ export default function () { checkResponse(r, [200, 201], 'POST /api/courses'), }); sleep(Math.random() * 5 + 2); +} + +export function handleSummary(data) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return { + [`./tests/performance/results/summary_stress_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2), + }; } \ No newline at end of file From 2a4ffcdf22ca5bcf6728061d8a5c99e9d1de5333 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Mon, 8 Dec 2025 18:58:02 +0100 Subject: [PATCH 04/15] Add initial performance summaries --- ...rses_by_name_2025-12-08T17-45-06-298Z.json | 212 ++++++++++++++++++ ..._all_courses_2025-12-08T17-52-37-938Z.json | 210 +++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json create mode 100644 tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json diff --git a/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json b/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json new file mode 100644 index 0000000..a2a1243 --- /dev/null +++ b/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json @@ -0,0 +1,212 @@ +{ + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 50271.384 + }, + "metrics": { + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.2379822510822513, + "min": 0.007, + "med": 0.076, + "max": 16.035, + "p(90)": 0.20810000000000003, + "p(95)": 0.3476499999999997 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 2605680, + "rate": 51832.27101923432 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 118.755, + "p(90)": 57.7513, + "p(95)": 64.247, + "avg": 40.99053030303033, + "min": 9.778, + "med": 40.321 + } + }, + "checks": { + "contains": "default", + "values": { + "rate": 1, + "passes": 2310, + "fails": 0 + }, + "type": "rate" + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "rate": 45.950594875207734, + "count": 2310 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 2310, + "rate": 45.950594875207734 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 566.4199189, + "avg": 542.7420781367962, + "min": 510.738083, + "med": 541.99, + "max": 618.941333, + "p(90)": 559.6669331 + } + }, + "http_req_failed": { + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 2310 + }, + "type": "rate" + }, + "http_req_blocked": { + "contains": "time", + "values": { + "avg": 0.028522077922078178, + "min": 0.001, + "med": 0.006, + "max": 3.266, + "p(90)": 0.015, + "p(95)": 0.025 + }, + "type": "trend" + }, + "http_req_sending": { + "values": { + "max": 8.574, + "p(90)": 0.05110000000000005, + "p(95)": 0.0775499999999999, + "avg": 0.04051471861471894, + "min": 0.004, + "med": 0.019 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 41.269027272727314, + "min": 9.816, + "med": 40.524, + "max": 118.789, + "p(90)": 58.074400000000004, + "p(95)": 64.67054999999999 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 1275120, + "rate": 25364.72837111467 + } + }, + "vus_max": { + "contains": "default", + "values": { + "value": 50, + "min": 50, + "max": 50 + }, + "type": "gauge" + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 12, + "min": 1, + "max": 49 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 2.895, + "p(90)": 0, + "p(95)": 0, + "avg": 0.016248484848484852 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "avg": 41.269027272727314, + "min": 9.816, + "med": 40.524, + "max": 118.789, + "p(90)": 58.074400000000004, + "p(95)": 64.67054999999999 + } + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "id": "4900f98b03ff58bb73677daf299fc44a", + "passes": 2310, + "fails": 0, + "name": "GET /api/courses/name/{name} returns 200", + "path": "::GET /api/courses/name/{name} returns 200" + } + ] + } +} \ No newline at end of file diff --git a/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json b/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json new file mode 100644 index 0000000..715d20a --- /dev/null +++ b/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json @@ -0,0 +1,210 @@ +{ + "root_group": { + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "id": "f02acc4eeef4e7a41d7801aaf3fd8a9d", + "passes": 0, + "fails": 2616135, + "name": "GET /api/courses status 200", + "path": "::GET /api/courses status 200" + } + ], + "name": "", + "path": "" + }, + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 85001.406 + }, + "metrics": { + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 2000, + "min": 2000, + "max": 2000 + } + }, + "iterations": { + "contains": "default", + "values": { + "count": 2616135, + "rate": 30777.54972664805 + }, + "type": "counter" + }, + "http_req_sending": { + "values": { + "avg": 0.05843801600354774, + "min": 0.001, + "med": 0.003, + "max": 133.096, + "p(90)": 0.008, + "p(95)": 0.016 + }, + "type": "trend", + "contains": "time" + }, + "data_received": { + "contains": "data", + "values": { + "count": 1444585301, + "rate": 16994840.073586546 + }, + "type": "counter" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 2616135 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 26.15504649736695, + "min": 0.198542, + "med": 21.873667, + "max": 461.378875, + "p(90)": 53.584950000000006, + "p(95)": 64.65959559999999 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.474802442916353, + "min": 0.18, + "med": 13.522, + "max": 460.526, + "p(90)": 40.894, + "p(95)": 51.54 + }, + "thresholds": { + "p(95)<1000": { + "ok": true + } + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0 + } + }, + "http_reqs": { + "contains": "default", + "values": { + "count": 2616135, + "rate": 30777.54972664805 + }, + "type": "counter" + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0.003, + "avg": 0.1207288645241876, + "min": 0, + "med": 0.001, + "max": 124.99, + "p(90)": 0.002 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.11568039264028825, + "min": 0, + "med": 0, + "max": 124.938, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.10442196255082742, + "min": 0.003, + "med": 0.007, + "max": 167.395, + "p(90)": 0.017, + "p(95)": 0.034 + } + }, + "data_sent": { + "values": { + "rate": 33824527.14958621, + "count": 2875132365 + }, + "type": "counter", + "contains": "data" + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.31194246436071, + "min": 0.171, + "med": 13.431, + "max": 460.468, + "p(90)": 40.676, + "p(95)": 51.21 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 2616135, + "fails": 0, + "rate": 1 + }, + "thresholds": { + "rate<0.2": { + "ok": false + } + } + }, + "vus": { + "contains": "default", + "values": { + "value": 18, + "min": 18, + "max": 1997 + }, + "type": "gauge" + } + } +} \ No newline at end of file From 58586bcd3ad39aba736910142c3bc250112b7ec6 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Mon, 8 Dec 2025 19:06:36 +0100 Subject: [PATCH 05/15] Update microservice-moderation submodule to latest commit --- microservice-moderation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microservice-moderation b/microservice-moderation index 2ff6319..e354deb 160000 --- a/microservice-moderation +++ b/microservice-moderation @@ -1 +1 @@ -Subproject commit 2ff63199ace1728bd19cea3c47174c8a2e51fcb5 +Subproject commit e354deb44a2771cc3d0a97da408e77502499978b From 9057881ce239ea706c2774452c2cce019388b572 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 9 Dec 2025 17:31:09 +0100 Subject: [PATCH 06/15] Add redis and cache dependency in pom.xml and update dtos --- backend/pom.xml | 8 ++++++++ .../java/programming/tutorial/dto/CourseDTO.java | 12 ++++++++---- .../main/java/programming/tutorial/dto/UserDTO.java | 6 +++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index faa27b2..a1e6ad3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -151,6 +151,14 @@ org.springframework spring-web + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + diff --git a/backend/src/main/java/programming/tutorial/dto/CourseDTO.java b/backend/src/main/java/programming/tutorial/dto/CourseDTO.java index ca3da71..c889419 100644 --- a/backend/src/main/java/programming/tutorial/dto/CourseDTO.java +++ b/backend/src/main/java/programming/tutorial/dto/CourseDTO.java @@ -2,15 +2,19 @@ import programming.tutorial.domain.User; -public class CourseDTO { +import java.io.Serial; +import java.io.Serializable; +public class CourseDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; private Integer courseId; private String courseName; private int courseLength; private String description; private String category; private String creatorId; - private User creator; + private UserDTO creator; private boolean systemCourse; public CourseDTO() { @@ -85,11 +89,11 @@ public void setCreatorId(String creatorId) { this.creatorId = creatorId; } - public User getCreator() { + public UserDTO getCreator() { return creator; } - public void setCreator(User creator) { + public void setCreator(UserDTO creator) { this.creator = creator; } diff --git a/backend/src/main/java/programming/tutorial/dto/UserDTO.java b/backend/src/main/java/programming/tutorial/dto/UserDTO.java index 5932824..86f9724 100644 --- a/backend/src/main/java/programming/tutorial/dto/UserDTO.java +++ b/backend/src/main/java/programming/tutorial/dto/UserDTO.java @@ -3,9 +3,13 @@ import programming.tutorial.domain.Role; import programming.tutorial.domain.Tier; +import java.io.Serial; +import java.io.Serializable; import java.time.LocalDateTime; -public class UserDTO { +public class UserDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; public Integer id; public String name; public String surname; From 79b85f4330042a12388e4df9040431771ab944ba Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 9 Dec 2025 17:31:53 +0100 Subject: [PATCH 07/15] Update Course service and add Cache service --- .../tutorial/BackendApplication.java | 2 + .../services/impl/CacheServiceJpa.java | 24 ++++++++ .../services/impl/CourseServiceJpa.java | 61 ++++++++++++++++--- 3 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java diff --git a/backend/src/main/java/programming/tutorial/BackendApplication.java b/backend/src/main/java/programming/tutorial/BackendApplication.java index 2459e8a..0b51fa9 100644 --- a/backend/src/main/java/programming/tutorial/BackendApplication.java +++ b/backend/src/main/java/programming/tutorial/BackendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java new file mode 100644 index 0000000..07dd7cf --- /dev/null +++ b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java @@ -0,0 +1,24 @@ +package programming.tutorial.services.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +@Service +public class CacheServiceJpa { + + @Autowired + private CacheManager cacheManager; + + public void evictAllCoursesCache() { + if (cacheManager.getCache("all_courses") != null) { + cacheManager.getCache("all_courses").clear(); + } + } + + public void evictCoursesByUser(String auth0UserId) { + if (cacheManager.getCache("courses_by_user") != null) { + cacheManager.getCache("courses_by_user").evict(auth0UserId); + } + } +} diff --git a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java index fe48a7c..c12622b 100644 --- a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java +++ b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java @@ -1,6 +1,9 @@ package programming.tutorial.services.impl; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import programming.tutorial.dao.CourseRepository; import programming.tutorial.dao.LessonRepository; @@ -9,6 +12,7 @@ import programming.tutorial.dto.CourseDTO; import programming.tutorial.dto.CourseWithLessonsDTO; import programming.tutorial.dto.LessonDTO; +import programming.tutorial.dto.UserDTO; import programming.tutorial.services.CourseService; import java.util.*; @@ -25,18 +29,28 @@ public class CourseServiceJpa implements CourseService { @Autowired private LessonRepository lessonRepository; + @Autowired + private CacheServiceJpa cacheService; @Override + @Cacheable(value = "course_by_name", key = "#name") public Optional findByName(CourseDTO courseDTO) { return Optional.ofNullable(courseRepository.findByCourseName(courseDTO.getCourseName())); } @Override + @Cacheable(value = "course_by_id", key = "#courseDTO.courseId") public Optional findById(CourseDTO courseDTO) { return courseRepository.findById(courseDTO.getCourseId()); } @Override + @Caching(evict = { + @CacheEvict(value = "all_courses", allEntries = true), + @CacheEvict(value = "course_by_id", key = "#courseDTO.courseId"), + @CacheEvict(value = "course_by_name", key = "#courseDTO.courseName"), + @CacheEvict(value = "courses_by_user", key = "#courseDTO.creator.auth0UserId") + }) public Course saveCourse(CourseDTO courseDTO) { Course course = new Course(); course.setId(courseDTO.getCourseId()); @@ -48,23 +62,54 @@ public Course saveCourse(CourseDTO courseDTO) { } @Override + @CacheEvict(value = "all_courses", allEntries = true) public void deleteCourse(Integer courseId) { courseRepository.deleteById(courseId); } + private UserDTO mapUser(User user) { + if (user == null) return null; + UserDTO dto = new UserDTO(); + dto.setId(user.getId()); + dto.setName(user.getName()); + dto.setSurname(user.getSurname()); + dto.setUsername(user.getUsername()); + dto.setAuth0UserId(user.getAuth0UserId()); + dto.setRole(user.getRole()); + dto.setActive(user.isActive()); + dto.setDateCreated(user.getDateCreated()); + dto.setTier(user.getTier()); + return dto; + } + @Override + @Cacheable(value = "all_courses") public List getAllCourses() { - List courses = courseRepository.findAll(); - System.out.println("Retrieved courses from database:"); - for (Course course : courses) { - System.out.println("Course ID: " + course.getId() + ", Name: " + course.getCourseName()); - } - return courses.stream() - .map(course -> new CourseDTO(course.getId(), course.getCourseName(), course.getLength(), course.getDescription(), course.getCategory())) + System.out.println(">>> DB QUERY EXECUTED — NO CACHE HIT <<<"); + cacheService.evictAllCoursesCache(); + return courseRepository.findAll().stream() + .map(course -> { + CourseDTO dto = new CourseDTO( + course.getId(), + course.getCourseName(), + course.getLength(), + course.getDescription(), + course.getCategory(), + course.getCreator() != null ? course.getCreator().getId() : null, + course.isSystemCourse() + ); + dto.setCreator(mapUser(course.getCreator())); + return dto; + }) .collect(Collectors.toList()); } @Override + @Caching(evict = { + @CacheEvict(value = "all_courses", allEntries = true), + @CacheEvict(value = "courses_by_user", key = "#dto.auth0UserId"), + @CacheEvict(value = "lessons_for_course", key = "#result.id") + }) public Course createCourseWithLessons(CourseWithLessonsDTO dto) { User creator = userRepository.findByAuth0UserId(dto.getAuth0UserId()) .orElseThrow(() -> new IllegalArgumentException("User not found for Auth0 ID: " + dto.getAuth0UserId())); @@ -143,6 +188,7 @@ private static int getRequestedLessonCount(CourseWithLessonsDTO dto, User creato } @Override + @Cacheable(value = "courses_by_user", key = "#auth0UserId") public List getCoursesByUserAuth0Id(String auth0UserId) { User user = userRepository.findByAuth0UserId(auth0UserId) .orElseThrow(() -> new RuntimeException("User not found")); @@ -162,6 +208,7 @@ public boolean isCourseOwner(String userId, Integer courseId) { } @Override + @Cacheable(value = "lessons_for_course", key = "#courseId") public List getLessonsForCourse(Integer courseId) { List lessons = lessonRepository.findByCourseIdOrderByIdAsc(courseId); return lessons.stream() From 5b90f60701ae4c7c347f937cc6fd0edb27b684f7 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 9 Dec 2025 17:32:16 +0100 Subject: [PATCH 08/15] Add cache config, controller and monitoring classes --- .../tutorial/config/CacheConfig.java | 25 +++++++++++++++++++ .../tutorial/controller/CacheController.java | 23 +++++++++++++++++ .../tutorial/monitoring/CacheMetrics.java | 25 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 backend/src/main/java/programming/tutorial/config/CacheConfig.java create mode 100644 backend/src/main/java/programming/tutorial/controller/CacheController.java create mode 100644 backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java diff --git a/backend/src/main/java/programming/tutorial/config/CacheConfig.java b/backend/src/main/java/programming/tutorial/config/CacheConfig.java new file mode 100644 index 0000000..8f817d4 --- /dev/null +++ b/backend/src/main/java/programming/tutorial/config/CacheConfig.java @@ -0,0 +1,25 @@ +package programming.tutorial.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory("localhost", 6379); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } +} diff --git a/backend/src/main/java/programming/tutorial/controller/CacheController.java b/backend/src/main/java/programming/tutorial/controller/CacheController.java new file mode 100644 index 0000000..3ed4e33 --- /dev/null +++ b/backend/src/main/java/programming/tutorial/controller/CacheController.java @@ -0,0 +1,23 @@ +package programming.tutorial.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import programming.tutorial.services.impl.CacheServiceJpa; + +@RestController +@RequestMapping("/admin/cache") +public class CacheController { + + @Autowired + private CacheServiceJpa cacheService; + + @DeleteMapping("/courses") + public ResponseEntity clearAllCoursesCache() { + cacheService.evictAllCoursesCache(); + System.out.println("Called"); + return ResponseEntity.ok("All course caches cleared!"); + } +} diff --git a/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java b/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java new file mode 100644 index 0000000..19b4ab6 --- /dev/null +++ b/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java @@ -0,0 +1,25 @@ +package programming.tutorial.monitoring; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class CacheMetrics { + + private final Counter cacheHit; + private final Counter cacheMiss; + + public CacheMetrics(MeterRegistry registry) { + this.cacheHit = Counter.builder("redis_cache_hit_total") + .description("Cache hits") + .register(registry); + + this.cacheMiss = Counter.builder("redis_cache_miss_total") + .description("Cache misses") + .register(registry); + } + + public void hit() { cacheHit.increment(); } + public void miss() { cacheMiss.increment(); } +} From e66491c57520c1eaf65b667b28cac1ebbddfba06 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 9 Dec 2025 17:32:48 +0100 Subject: [PATCH 09/15] Add new performance spike test --- ..._all_courses_2025-12-09T15-40-48-531Z.json | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json diff --git a/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json b/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json new file mode 100644 index 0000000..3a89f63 --- /dev/null +++ b/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json @@ -0,0 +1,222 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "GET /api/courses status 200", + "path": "::GET /api/courses status 200", + "id": "f02acc4eeef4e7a41d7801aaf3fd8a9d", + "passes": 88957, + "fails": 0 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "testRunDurationMs": 17001.441, + "isStdOutTTY": true, + "isStdErrTTY": true + }, + "metrics": { + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "rate": 5232.321189715624, + "count": 88957 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0.001, + "max": 77.459, + "p(90)": 0.002, + "p(95)": 0.004, + "avg": 0.08780094877315953 + } + }, + "iterations": { + "values": { + "rate": 5232.321189715624, + "count": 88957 + }, + "type": "counter", + "contains": "default" + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "fails": 88957, + "rate": 0, + "passes": 0 + }, + "thresholds": { + "rate<0.2": { + "ok": true + } + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 283.06402668955275, + "min": 0.892459, + "med": 296.839375, + "max": 912.909875, + "p(90)": 387.19262480000003, + "p(95)": 425.50211659999997 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "min": 2000, + "max": 2000, + "value": 2000 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 281.9709629034241, + "min": 0.818, + "med": 296.393, + "max": 912.853, + "p(90)": 384.4694, + "p(95)": 422.2887999999999 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.8390701237676371, + "min": 0.005, + "med": 0.014, + "max": 105.463, + "p(90)": 0.157, + "p(95)": 0.288 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.08498199129916698, + "min": 0, + "med": 0, + "max": 77.434, + "p(90)": 0, + "p(95)": 0 + } + }, + "vus": { + "values": { + "value": 105, + "min": 1, + "max": 2000 + }, + "type": "gauge", + "contains": "default" + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 97763743, + "rate": 5750320.987497471 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 106036934, + "rate": 6236938.033664323 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "med": 296.696, + "max": 912.867, + "p(90)": 387.0536, + "p(95)": 425.3459999999999, + "avg": 282.8300397270564, + "min": 0.862 + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0 + } + }, + "http_req_sending": { + "contains": "time", + "values": { + "p(90)": 0.008, + "p(95)": 0.015, + "avg": 0.02000669986619526, + "min": 0.002, + "med": 0.003, + "max": 107.079 + }, + "type": "trend" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "passes": 88957, + "fails": 0, + "rate": 1 + } + }, + "http_req_duration": { + "contains": "time", + "values": { + "avg": 282.8300397270564, + "min": 0.862, + "med": 296.696, + "max": 912.867, + "p(90)": 387.0536, + "p(95)": 425.3459999999999 + }, + "thresholds": { + "p(95)<1500": { + "ok": true + } + }, + "type": "trend" + } + } +} \ No newline at end of file From 652a3e1e908a91bfd4038b464479668a77c4df13 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Sat, 13 Dec 2025 22:42:38 +0100 Subject: [PATCH 10/15] Update controllers in backend: - Add new "/all" endpoint to clear all caches in CacheController - Add new "/uncached" endpoint in CourseController to run uncached version of getAllCourses method --- .../programming/tutorial/controller/CacheController.java | 6 ++++++ .../programming/tutorial/controller/CourseController.java | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/backend/src/main/java/programming/tutorial/controller/CacheController.java b/backend/src/main/java/programming/tutorial/controller/CacheController.java index 3ed4e33..ea9fdbc 100644 --- a/backend/src/main/java/programming/tutorial/controller/CacheController.java +++ b/backend/src/main/java/programming/tutorial/controller/CacheController.java @@ -20,4 +20,10 @@ public ResponseEntity clearAllCoursesCache() { System.out.println("Called"); return ResponseEntity.ok("All course caches cleared!"); } + + @DeleteMapping("/all") + public ResponseEntity clearAllCaches() { + cacheService.evictAllCaches(); + return ResponseEntity.ok("All caches cleared"); + } } diff --git a/backend/src/main/java/programming/tutorial/controller/CourseController.java b/backend/src/main/java/programming/tutorial/controller/CourseController.java index c9b569b..d56f96e 100644 --- a/backend/src/main/java/programming/tutorial/controller/CourseController.java +++ b/backend/src/main/java/programming/tutorial/controller/CourseController.java @@ -66,6 +66,10 @@ public List getAllCourses() { } return courses; } + @GetMapping("/uncached") + public List getAllCoursesUncached() { + return courseService.getAllCoursesUncached(); + } @PostMapping("/create-with-lessons") public ResponseEntity createCourseWithLessons(@RequestBody CourseWithLessonsDTO dto) { From 311b57d90d90490c3f4ff5745a45117494d5d529 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Sat, 13 Dec 2025 22:44:31 +0100 Subject: [PATCH 11/15] Update services: - Add new uncached getAllCourses method in CourseService - Add evitAllCaches method implementation in CacheService --- .../tutorial/services/CourseService.java | 2 + .../services/impl/CacheServiceJpa.java | 21 ++++++-- .../services/impl/CourseServiceJpa.java | 53 ++++++++++++++++++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/programming/tutorial/services/CourseService.java b/backend/src/main/java/programming/tutorial/services/CourseService.java index dbfb1ca..9f8aef4 100644 --- a/backend/src/main/java/programming/tutorial/services/CourseService.java +++ b/backend/src/main/java/programming/tutorial/services/CourseService.java @@ -27,4 +27,6 @@ public interface CourseService { boolean isCourseOwner(String userId, Integer courseId); List getLessonsForCourse(Integer courseId); + + List getAllCoursesUncached(); } diff --git a/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java index 07dd7cf..b64a341 100644 --- a/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java +++ b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java @@ -11,14 +11,25 @@ public class CacheServiceJpa { private CacheManager cacheManager; public void evictAllCoursesCache() { - if (cacheManager.getCache("all_courses") != null) { - cacheManager.getCache("all_courses").clear(); + var cache = cacheManager.getCache("all_courses"); + if (cache != null) { + cache.clear(); } } public void evictCoursesByUser(String auth0UserId) { - if (cacheManager.getCache("courses_by_user") != null) { - cacheManager.getCache("courses_by_user").evict(auth0UserId); + var cache = cacheManager.getCache("courses_by_user"); + if (cache != null) { + cache.evict(auth0UserId); } } -} + + public void evictAllCaches() { + cacheManager.getCacheNames().forEach(name -> { + var cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + } + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java index c12622b..0e7c566 100644 --- a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java +++ b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java @@ -1,5 +1,6 @@ package programming.tutorial.services.impl; +import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -13,7 +14,9 @@ import programming.tutorial.dto.CourseWithLessonsDTO; import programming.tutorial.dto.LessonDTO; import programming.tutorial.dto.UserDTO; +import programming.tutorial.monitoring.CacheMetrics; import programming.tutorial.services.CourseService; +import io.micrometer.core.instrument.Timer; import java.util.*; import java.util.stream.Collectors; @@ -31,6 +34,11 @@ public class CourseServiceJpa implements CourseService { private LessonRepository lessonRepository; @Autowired private CacheServiceJpa cacheService; + @Autowired + private CacheMetrics cacheMetrics; + @Autowired + private MeterRegistry meterRegistry; + @Override @Cacheable(value = "course_by_name", key = "#name") @@ -85,9 +93,14 @@ private UserDTO mapUser(User user) { @Override @Cacheable(value = "all_courses") public List getAllCourses() { + Timer.Sample sample = Timer.start(meterRegistry); + long start = System.currentTimeMillis(); + cacheMetrics.miss(); + /* First run will print this if cache isn't evicted previously + * Other runs will not print this unless cache is invalidated + * */ System.out.println(">>> DB QUERY EXECUTED — NO CACHE HIT <<<"); - cacheService.evictAllCoursesCache(); - return courseRepository.findAll().stream() + List courses = courseRepository.findAll().stream() .map(course -> { CourseDTO dto = new CourseDTO( course.getId(), @@ -102,8 +115,44 @@ public List getAllCourses() { return dto; }) .collect(Collectors.toList()); + + sample.stop(Timer.builder("app.cache.getAllCourses.time") + .description("Execution time for getAllCourses") + .register(meterRegistry)); + + long duration = System.currentTimeMillis() - start; + System.out.println("getAllCourses execution time: " + duration + " ms"); + return courses; + } + + /** + * Method to check how caching improves/behaves versus regular method. + * This bypasses the cache entirely and records execution time for comparison. + */ + public List getAllCoursesUncached() { + Timer.Sample sample = Timer.start(meterRegistry); + long start = System.currentTimeMillis(); + System.out.println("Uncached getAllCourses method called"); + List courses = courseRepository.findAll().stream() + .map(course -> new CourseDTO( + course.getId(), + course.getCourseName(), + course.getLength(), + course.getDescription(), + course.getCategory() + )) + .collect(Collectors.toList()); + + sample.stop(Timer.builder("app.cache.getAllCourses_uncached.time") + .description("Execution time for getAllCourses WITHOUT cache") + .register(meterRegistry)); + + long duration = System.currentTimeMillis() - start; + System.out.println("getAllCoursesUncached execution time: " + duration + " ms"); + return courses; } + @Override @Caching(evict = { @CacheEvict(value = "all_courses", allEntries = true), From 0cc23f249f3bc5e4117cc06f4be3686fa5ff7791 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Sat, 13 Dec 2025 22:45:54 +0100 Subject: [PATCH 12/15] Update application.properties with redis cache ttl --- backend/src/main/resources/application.properties | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 67d6ac2..b289f38 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -24,7 +24,12 @@ management.metrics.export.prometheus.enabled=true management.endpoints.web.exposure.include=* management.endpoints.web.base-path=/actuator management.endpoint.health.show-details=always +management.metrics.enable-all=true +management.endpoint.metrics.enabled=true +logging.level.org.springframework.cache=DEBUG +spring.cache.type=redis +spring.cache.redis.time-to-live=600000 #Mock data for payment # email: fudansfudans@gmail.com From 80ffb230dcc99234fbb4ea3d3f4fc8aeb3e3f5a4 Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 16 Dec 2025 14:17:20 +0100 Subject: [PATCH 13/15] Fix error 500 when redis container was down --- .../tutorial/config/CacheConfig.java | 33 ++++++++++++------- .../src/main/resources/application.properties | 3 +- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/programming/tutorial/config/CacheConfig.java b/backend/src/main/java/programming/tutorial/config/CacheConfig.java index 8f817d4..91e9971 100644 --- a/backend/src/main/java/programming/tutorial/config/CacheConfig.java +++ b/backend/src/main/java/programming/tutorial/config/CacheConfig.java @@ -1,25 +1,36 @@ package programming.tutorial.config; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import java.time.Duration; @Configuration @EnableCaching public class CacheConfig { @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory("localhost", 6379); - } + public CacheManager cacheManager() { + try { + LettuceConnectionFactory redisConnectionFactory = new LettuceConnectionFactory("localhost", 6379); + redisConnectionFactory.afterPropertiesSet(); + redisConnectionFactory.getConnection().ping(); - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - return template; + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)); + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(config) + .build(); + } catch (Exception e) { + System.out.println("Redis not available. Using in-memory cache."); + return new ConcurrentMapCacheManager(); + } } -} +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b289f38..2e1ca8a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -28,7 +28,8 @@ management.metrics.enable-all=true management.endpoint.metrics.enabled=true logging.level.org.springframework.cache=DEBUG -spring.cache.type=redis +#spring.cache.type=redis +spring.cache.type=simple spring.cache.redis.time-to-live=600000 #Mock data for payment From 5852d6e27539bbf3e9a779fc146d7eaeda4b1fad Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 16 Dec 2025 14:48:34 +0100 Subject: [PATCH 14/15] Remove timer from getAllCourses method --- .../tutorial/services/impl/CourseServiceJpa.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java index 0e7c566..794ccbc 100644 --- a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java +++ b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java @@ -39,7 +39,6 @@ public class CourseServiceJpa implements CourseService { @Autowired private MeterRegistry meterRegistry; - @Override @Cacheable(value = "course_by_name", key = "#name") public Optional findByName(CourseDTO courseDTO) { @@ -93,9 +92,6 @@ private UserDTO mapUser(User user) { @Override @Cacheable(value = "all_courses") public List getAllCourses() { - Timer.Sample sample = Timer.start(meterRegistry); - long start = System.currentTimeMillis(); - cacheMetrics.miss(); /* First run will print this if cache isn't evicted previously * Other runs will not print this unless cache is invalidated * */ @@ -116,12 +112,6 @@ public List getAllCourses() { }) .collect(Collectors.toList()); - sample.stop(Timer.builder("app.cache.getAllCourses.time") - .description("Execution time for getAllCourses") - .register(meterRegistry)); - - long duration = System.currentTimeMillis() - start; - System.out.println("getAllCourses execution time: " + duration + " ms"); return courses; } From a80f9e8f1833af1fc40baa5ccce50c57c6a7650c Mon Sep 17 00:00:00 2001 From: ismiljanic Date: Tue, 16 Dec 2025 14:49:13 +0100 Subject: [PATCH 15/15] Add and update tests for UserProgressController and CourseService --- .../UserProgressControllerTest.java | 87 ++++++++++++++++++- .../tutorial/services/CourseServiceTest.java | 4 +- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java b/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java index 7a6fbd4..db1ca23 100644 --- a/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java +++ b/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java @@ -1,7 +1,9 @@ package programming.tutorial.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -24,10 +26,13 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @WebMvcTest(UserProgressController.class) @@ -36,7 +41,6 @@ class UserProgressControllerTest { @Autowired private MockMvc mockMvc; - @MockBean private UserProgressService userProgressService; @MockBean @@ -47,6 +51,8 @@ class UserProgressControllerTest { private UserService userService; @Mock private Authentication authentication; + @InjectMocks + private UserProgressController progressController; private final String auth0Id = "auth0|123"; private final Integer courseId = 1; @@ -59,6 +65,81 @@ void setup() { when(userService.getUserId(auth0Id)).thenReturn(userId); } + @Test + void updateProgress_userNotEnrolledAndNotOwner_returnsForbidden() throws Exception { + UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest(); + request.courseId = 1; + request.lessonId = 1; + + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("sub", auth0Id) + .build(); + + when(authentication.getPrincipal()).thenReturn(jwt); + when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(false); + when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(false); + + mockMvc.perform(post("/api/progress/update") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request)) + .principal(authentication)) + .andExpect(status().isForbidden()) + .andExpect(content().string("")); + + verify(userProgressService).isUserEnrolled(anyString(), eq(request.courseId)); + verify(courseService).isCourseOwner(anyString(), eq(request.courseId)); + verifyNoMoreInteractions(userProgressService, courseService); + } + + @Test + void updateProgress_userEnrolled_returnsOkWithMessage() throws Exception { + UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest(); + request.courseId = 2; + request.lessonId = 2; + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("sub", auth0Id) + .build(); + + when(authentication.getPrincipal()).thenReturn(jwt); + when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(true); + when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(false); + when(userProgressService.updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId))) + .thenReturn("Progress updated"); + + mockMvc.perform(post("/api/progress/update") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request)) + .principal(authentication)) + .andDo(print()) + .andExpect(status().isOk()); + + verify(userProgressService).updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId)); + } + + @Test + void updateProgress_userIsOwner_returnsOkWithMessage() throws Exception { + UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest(); + request.courseId = 3; + request.lessonId = 3; + + Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").claim("sub", auth0Id).build(); + when(authentication.getPrincipal()).thenReturn(jwt); + when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(false); + when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(true); + when(userProgressService.updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId))) + .thenReturn("Progress updated"); + + mockMvc.perform(post("/api/progress/update") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request)) + .principal(authentication)) + .andExpect(status().isOk()); + } + @Test void getCurrentLessonNumber_shouldReturnLessonNumberIfExists() throws Exception { LessonDTO lessonDTO = new LessonDTO(); diff --git a/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java b/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java index 7cd6fa3..44203a8 100644 --- a/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java +++ b/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java @@ -17,11 +17,11 @@ import programming.tutorial.dto.CourseWithLessonsDTO; import programming.tutorial.dto.LessonDTO; import programming.tutorial.services.impl.CourseServiceJpa; - +import io.micrometer.core.instrument.Timer; import java.util.*; +import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class CourseServiceTest {