master
cc 1,535 lines 54.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 #include "ml_private.h"
4
5 #include <array>
6
7 #include "ad_charts.h"
8 #include "database/sqlite/vendored/sqlite3.h"
9 #include "streaming/stream-control.h"
10
11 #define WORKER_TRAIN_QUEUE_POP 0
12 #define WORKER_TRAIN_ACQUIRE_DIMENSION 1
13 #define WORKER_TRAIN_QUERY 2
14 #define WORKER_TRAIN_KMEANS 3
15 #define WORKER_TRAIN_UPDATE_MODELS 4
16 #define WORKER_TRAIN_RELEASE_DIMENSION 5
17 #define WORKER_TRAIN_UPDATE_HOST 6
18 #define WORKER_TRAIN_FLUSH_MODELS 7
19
20 sqlite3 *ml_db = NULL;
21 static netdata_mutex_t db_mutex;
22
23 static void __attribute__((constructor)) init_mutex(void) {
24 netdata_mutex_init(&db_mutex);
25 }
26
27 static void __attribute__((destructor)) destroy_mutex(void) {
28 netdata_mutex_destroy(&db_mutex);
29 }
30
31 namespace {
32
33 // Guard against silent truncation when time_t is narrower than sqlite3_int64
34 // (e.g. 32-bit builds). if constexpr ensures the dead branches are elided at
35 // compile time on 64-bit platforms, avoiding constant-expression warnings.
36 static inline bool ml_sqlite_int64_fits_time_t(sqlite3_int64 value)
37 {
38 if constexpr (!std::numeric_limits<time_t>::is_signed ||
39 std::numeric_limits<time_t>::digits < std::numeric_limits<sqlite3_int64>::digits) {
40 // coverity[CONSTANT_EXPRESSION_RESULT] - on 64-bit, Coverity still analyzes
41 // this dead branch; reachable only on 32-bit or unsigned time_t builds.
42 if (value < (sqlite3_int64) std::numeric_limits<time_t>::min())
43 return false;
44 }
45 if constexpr (std::numeric_limits<time_t>::digits < std::numeric_limits<sqlite3_int64>::digits) {
46 // coverity[CONSTANT_EXPRESSION_RESULT] - dead on same-width 64-bit time_t;
47 // reachable only when time_t is narrower than sqlite3_int64, but Coverity
48 // still analyzes this discarded if constexpr branch.
49 if (value > (sqlite3_int64) std::numeric_limits<time_t>::max())
50 return false;
51 }
52 return true;
53 }
54
55 }
56
57 static inline size_t ml_dimension_smoothing_window(const ml_dimension_t *dim)
58 {
59 unsigned chart_update_every = dim->rd->rrdset->update_every;
60 if (chart_update_every > nd_profile.update_every)
61 return 1;
62
63 // max_samples_to_smooth == 0 is normalized to an effective smoothing window
64 // of 1 for feature extraction.
65 return std::max<size_t>(Cfg.max_samples_to_smooth, 1);
66 }
67
68 typedef struct {
69 // First/last entry of the dimension in DB when generating the response
70 time_t first_entry_on_response;
71 time_t last_entry_on_response;
72
73 // After/Before timestamps of our DB query
74 time_t query_after_t;
75 time_t query_before_t;
76
77 // Actual after/before returned by the DB query ops
78 time_t db_after_t;
79 time_t db_before_t;
80
81 // Number of doubles returned by the DB query
82 size_t collected_values;
83
84 // Number of values we return to the caller
85 size_t total_values;
86 } ml_training_response_t;
87
88 static std::pair<enum ml_worker_result, ml_training_response_t>
89 ml_dimension_calculated_numbers(ml_worker_t *worker, ml_dimension_t *dim)
90 {
91 ml_training_response_t training_response = {};
92
93 training_response.first_entry_on_response = rrddim_first_entry_s_of_tier(dim->rd, 0);
94 training_response.last_entry_on_response = rrddim_last_entry_s_of_tier(dim->rd, 0);
95
96 unsigned chart_update_every = dim->rd->rrdset->update_every;
97 size_t smoothing_window = ml_dimension_smoothing_window(dim);
98 size_t min_required_samples = Cfg.diff_n + smoothing_window + Cfg.lag_n;
99
100 auto round_up_div = [](time_t window, unsigned step) -> size_t {
101 if (window <= 0 || step == 0)
102 return 0;
103 return static_cast<size_t>((window + step - 1) / step);
104 };
105
106 size_t min_n = round_up_div(Cfg.min_training_window, chart_update_every);
107 size_t max_n = round_up_div(Cfg.training_window, chart_update_every);
108
109 if (min_n < min_required_samples)
110 min_n = min_required_samples;
111 if (max_n < min_required_samples)
112 max_n = min_required_samples;
113
114 // Figure out what our time window should be.
115 training_response.query_before_t = training_response.last_entry_on_response;
116 training_response.query_after_t = std::max(
117 training_response.query_before_t - Cfg.training_window, // Fixed time window
118 training_response.first_entry_on_response
119 );
120
121 if (training_response.query_after_t >= training_response.query_before_t) {
122 return { ML_WORKER_RESULT_INVALID_QUERY_TIME_RANGE, training_response };
123 }
124
125 if (rrdset_is_replicating(dim->rd->rrdset)) {
126 return { ML_WORKER_RESULT_CHART_UNDER_REPLICATION, training_response };
127 }
128
129 /*
130 * Execute the query
131 */
132 struct storage_engine_query_handle handle;
133
134 storage_engine_query_init(dim->rd->tiers[0].seb, dim->rd->tiers[0].smh, &handle,
135 training_response.query_after_t, training_response.query_before_t,
136 STORAGE_PRIORITY_SYNCHRONOUS);
137
138 size_t idx = 0;
139 memset(worker->training_cns, 0, sizeof(calculated_number_t) * max_n * (Cfg.lag_n + 1));
140 calculated_number_t last_value = std::numeric_limits<calculated_number_t>::quiet_NaN();
141
142 while (!storage_engine_query_is_finished(&handle)) {
143 if (idx == max_n)
144 break;
145
146 STORAGE_POINT sp = storage_engine_query_next_metric(&handle);
147
148 time_t timestamp = sp.end_time_s;
149 calculated_number_t value = sp.sum / sp.count;
150
151 if (netdata_double_isnumber(value)) {
152 if (!training_response.db_after_t)
153 training_response.db_after_t = timestamp;
154 training_response.db_before_t = timestamp;
155
156 worker->training_cns[idx] = value;
157 last_value = worker->training_cns[idx];
158 training_response.collected_values++;
159 } else
160 worker->training_cns[idx] = last_value;
161
162 idx++;
163 }
164 storage_engine_query_finalize(&handle);
165
166 pulse_queries_ml_query_completed(/* points_read */ idx);
167
168 training_response.total_values = idx;
169 if (training_response.collected_values < min_n) {
170 return { ML_WORKER_RESULT_NOT_ENOUGH_COLLECTED_VALUES, training_response };
171 }
172
173 // Find first non-NaN value.
174 for (idx = 0; std::isnan(worker->training_cns[idx]); idx++, training_response.total_values--) { }
175
176 // Overwrite NaN values.
177 if (idx != 0)
178 memmove(worker->training_cns, &worker->training_cns[idx], sizeof(calculated_number_t) * training_response.total_values);
179
180 if (training_response.total_values < min_required_samples)
181 return { ML_WORKER_RESULT_NOT_ENOUGH_COLLECTED_VALUES, training_response };
182
183 return { ML_WORKER_RESULT_OK, training_response };
184 }
185
186 const char *db_models_create_table =
187 "CREATE TABLE IF NOT EXISTS models("
188 " dim_id BLOB, after INT, before INT,"
189 " min_dist REAL, max_dist REAL,"
190 " c00 REAL, c01 REAL, c02 REAL, c03 REAL, c04 REAL, c05 REAL,"
191 " c10 REAL, c11 REAL, c12 REAL, c13 REAL, c14 REAL, c15 REAL,"
192 " PRIMARY KEY(dim_id, after)"
193 ");";
194
195 const char *db_models_add_model =
196 "INSERT OR REPLACE INTO models("
197 " dim_id, after, before,"
198 " min_dist, max_dist,"
199 " c00, c01, c02, c03, c04, c05,"
200 " c10, c11, c12, c13, c14, c15)"
201 "VALUES("
202 " @dim_id, @after, @before,"
203 " @min_dist, @max_dist,"
204 " @c00, @c01, @c02, @c03, @c04, @c05,"
205 " @c10, @c11, @c12, @c13, @c14, @c15);";
206
207 const char *db_models_load =
208 "SELECT after, before, min_dist, max_dist, "
209 "c00, c01, c02, c03, c04, c05, "
210 "c10, c11, c12, c13, c14, c15 FROM ("
211 "SELECT after, before, min_dist, max_dist, "
212 "c00, c01, c02, c03, c04, c05, "
213 "c10, c11, c12, c13, c14, c15 FROM models "
214 "WHERE dim_id = @dim_id AND after >= @after "
215 "ORDER BY after DESC LIMIT @n"
216 ") ORDER BY after ASC;";
217
218 const char *db_models_delete =
219 "DELETE FROM models "
220 "WHERE dim_id = @dim_id AND before < @before;";
221
222 const char *db_models_prune =
223 "DELETE FROM models "
224 "WHERE after < @after LIMIT @n;";
225
226 static int
227 ml_dimension_add_model(const nd_uuid_t *metric_uuid, const ml_kmeans_inlined_t *inlined_km)
228 {
229 static __thread sqlite3_stmt *res = NULL;
230 int param = 0;
231 int rc = 0;
232
233 if (unlikely(!ml_db)) {
234 nd_log_limit_static_global_var(erl, 1, 0);
235 nd_log_limit(&erl, NDLS_DAEMON, NDLP_ERR, "ML: Database has not been initialized to add ML models");
236 return 1;
237 }
238
239 if (unlikely(!res)) {
240 rc = prepare_statement(ml_db, db_models_add_model, &res);
241 if (unlikely(rc != SQLITE_OK)) {
242 error_report("Failed to prepare statement to store model, rc = %d", rc);
243 return 1;
244 }
245 }
246
247 rc = sqlite3_bind_blob(res, ++param, metric_uuid, sizeof(*metric_uuid), SQLITE_STATIC);
248 if (unlikely(rc != SQLITE_OK))
249 goto bind_fail;
250
251 rc = sqlite3_bind_int64(res, ++param, (sqlite3_int64) inlined_km->after);
252 if (unlikely(rc != SQLITE_OK))
253 goto bind_fail;
254
255 rc = sqlite3_bind_int64(res, ++param, (sqlite3_int64) inlined_km->before);
256 if (unlikely(rc != SQLITE_OK))
257 goto bind_fail;
258
259 rc = sqlite3_bind_double(res, ++param, inlined_km->min_dist);
260 if (unlikely(rc != SQLITE_OK))
261 goto bind_fail;
262
263 rc = sqlite3_bind_double(res, ++param, inlined_km->max_dist);
264 if (unlikely(rc != SQLITE_OK))
265 goto bind_fail;
266
267 for (const DSample &ds : inlined_km->cluster_centers) {
268 if (ds.size() != 6)
269 fatal("Expected dsample with 6 dimensions, got %ld", ds.size());
270
271 for (long idx = 0; idx != ds.size(); idx++) {
272 calculated_number_t cn = ds(idx);
273 int rc = sqlite3_bind_double(res, ++param, cn);
274 if (unlikely(rc != SQLITE_OK))
275 goto bind_fail;
276 }
277 }
278
279 rc = execute_insert(res);
280 if (unlikely(rc != SQLITE_DONE)) {
281 error_report("Failed to store model, rc = %d", rc);
282 return rc;
283 }
284
285 rc = sqlite3_reset(res);
286 if (unlikely(rc != SQLITE_OK)) {
287 error_report("Failed to reset statement when storing model, rc = %d", rc);
288 return rc;
289 }
290
291 return 0;
292
293 bind_fail:
294 error_report("Failed to bind parameter %d to store model, rc = %d", param, rc);
295 rc = sqlite3_reset(res);
296 if (unlikely(rc != SQLITE_OK))
297 error_report("Failed to reset statement to store model, rc = %d", rc);
298 return rc;
299 }
300
301 static int
302 ml_dimension_delete_models(const nd_uuid_t *metric_uuid, time_t before)
303 {
304 static __thread sqlite3_stmt *res = NULL;
305 int rc = 0;
306 int param = 0;
307
308 if (unlikely(!ml_db)) {
309 nd_log_limit_static_global_var(erl, 1, 0);
310 nd_log_limit(&erl, NDLS_DAEMON, NDLP_ERR, "ML: Database has not been initialized to delete ML models");
311 return 1;
312 }
313
314 if (unlikely(!res)) {
315 rc = prepare_statement(ml_db, db_models_delete, &res);
316 if (unlikely(rc != SQLITE_OK)) {
317 error_report("Failed to prepare statement to delete models, rc = %d", rc);
318 return rc;
319 }
320 }
321
322 rc = sqlite3_bind_blob(res, ++param, metric_uuid, sizeof(*metric_uuid), SQLITE_STATIC);
323 if (unlikely(rc != SQLITE_OK))
324 goto bind_fail;
325
326 rc = sqlite3_bind_int64(res, ++param, (sqlite3_int64) before);
327 if (unlikely(rc != SQLITE_OK))
328 goto bind_fail;
329
330 rc = execute_insert(res);
331 if (unlikely(rc != SQLITE_DONE)) {
332 error_report("Failed to delete models, rc = %d", rc);
333 return rc;
334 }
335
336 rc = sqlite3_reset(res);
337 if (unlikely(rc != SQLITE_OK)) {
338 error_report("Failed to reset statement when deleting models, rc = %d", rc);
339 return rc;
340 }
341
342 return 0;
343
344 bind_fail:
345 error_report("Failed to bind parameter %d to delete models, rc = %d", param, rc);
346 rc = sqlite3_reset(res);
347 if (unlikely(rc != SQLITE_OK))
348 error_report("Failed to reset statement to delete models, rc = %d", rc);
349 return rc;
350 }
351
352 static int
353 ml_prune_old_models(size_t num_models_to_prune)
354 {
355 static __thread sqlite3_stmt *res = NULL;
356 int rc = 0;
357 int param = 0;
358
359 if (unlikely(!ml_db)) {
360 nd_log_limit_static_global_var(erl, 1, 0);
361 nd_log_limit(&erl, NDLS_DAEMON, NDLP_ERR, "ML: Database has not been initialized to prune old ML models");
362 return 1;
363 }
364
365 if (unlikely(!res)) {
366 rc = prepare_statement(ml_db, db_models_prune, &res);
367 if (unlikely(rc != SQLITE_OK)) {
368 error_report("Failed to prepare statement to prune models, rc = %d", rc);
369 return rc;
370 }
371 }
372
373 time_t after = now_realtime_sec() - (time_t) Cfg.delete_models_older_than;
374
375 rc = sqlite3_bind_int64(res, ++param, (sqlite3_int64) after);
376 if (unlikely(rc != SQLITE_OK))
377 goto bind_fail;
378
379 rc = sqlite3_bind_int(res, ++param, num_models_to_prune);
380 if (unlikely(rc != SQLITE_OK))
381 goto bind_fail;
382
383 rc = execute_insert(res);
384 if (unlikely(rc != SQLITE_DONE)) {
385 error_report("Failed to prune old models, rc = %d", rc);
386 return rc;
387 }
388
389 rc = sqlite3_reset(res);
390 if (unlikely(rc != SQLITE_OK)) {
391 error_report("Failed to reset statement when pruning old models, rc = %d", rc);
392 return rc;
393 }
394
395 return 0;
396
397 bind_fail:
398 error_report("Failed to bind parameter %d to prune old models, rc = %d", param, rc);
399 rc = sqlite3_reset(res);
400 if (unlikely(rc != SQLITE_OK))
401 error_report("Failed to reset statement to prune old models, rc = %d", rc);
402 return rc;
403 }
404
405 int ml_dimension_load_models(RRDDIM *rd, sqlite3_stmt **active_stmt) {
406 ml_dimension_t *dim = (ml_dimension_t *) rd->ml_dimension;
407 if (!dim)
408 return 0;
409
410 spinlock_lock(&dim->slock);
411 bool is_empty = dim->km_contexts.empty();
412 spinlock_unlock(&dim->slock);
413
414 if (!is_empty)
415 return 0;
416
417 std::vector<ml_kmeans_t> V;
418
419 sqlite3_stmt *res = active_stmt ? *active_stmt : NULL;
420 int rc = 0;
421 int param = 0;
422
423 if (unlikely(!ml_db)) {
424 nd_log_limit_static_global_var(erl, 1, 0);
425 nd_log_limit(&erl, NDLS_DAEMON, NDLP_ERR, "ML: Database has not been initialized to load ML models");
426 return 1;
427 }
428
429 if (unlikely(!res)) {
430 rc = sqlite3_prepare_v2(ml_db, db_models_load, -1, &res, NULL);
431 if (unlikely(rc != SQLITE_OK)) {
432 error_report("Failed to prepare statement to load models, rc = %d", rc);
433 return 1;
434 }
435 if (active_stmt)
436 *active_stmt = res;
437 }
438
439 nd_uuid_t *rd_uuid = uuidmap_uuid_ptr(dim->rd->uuid);
440 rc = sqlite3_bind_blob(res, ++param, rd_uuid, sizeof(*rd_uuid), SQLITE_STATIC);
441 if (unlikely(rc != SQLITE_OK))
442 goto bind_fail;
443
444 rc = sqlite3_bind_int64(res, ++param, now_realtime_sec() - (Cfg.num_models_to_use * Cfg.train_every));
445 if (unlikely(rc != SQLITE_OK))
446 goto bind_fail;
447
448 rc = sqlite3_bind_int64(res, ++param, Cfg.num_models_to_use);
449 if (unlikely(rc != SQLITE_OK))
450 goto bind_fail;
451
452 spinlock_lock(&dim->slock);
453
454 dim->km_contexts.reserve(Cfg.num_models_to_use);
455 while ((rc = sqlite3_step_monitored(res)) == SQLITE_ROW) {
456 ml_kmeans_t km;
457
458 sqlite3_int64 raw_after = sqlite3_column_int64(res, 0);
459 sqlite3_int64 raw_before = sqlite3_column_int64(res, 1);
460 // Protect against silent truncation when time_t is narrower than int64_t
461 // (e.g. 32-bit builds, corrupted DB, or far-future timestamps).
462 if (!ml_sqlite_int64_fits_time_t(raw_after) ||
463 !ml_sqlite_int64_fits_time_t(raw_before)) {
464 error_report("Skipping ML model row with out-of-range timestamps: after=%" PRId64 " before=%" PRId64,
465 (int64_t) raw_after, (int64_t) raw_before);
466 continue;
467 }
468
469 km.after = (time_t) raw_after;
470 km.before = (time_t) raw_before;
471
472 km.min_dist = sqlite3_column_double(res, 2);
473 km.max_dist = sqlite3_column_double(res, 3);
474
475 km.cluster_centers.resize(2);
476
477 km.cluster_centers[0].set_size(Cfg.lag_n + 1);
478 km.cluster_centers[0](0) = sqlite3_column_double(res, 4);
479 km.cluster_centers[0](1) = sqlite3_column_double(res, 5);
480 km.cluster_centers[0](2) = sqlite3_column_double(res, 6);
481 km.cluster_centers[0](3) = sqlite3_column_double(res, 7);
482 km.cluster_centers[0](4) = sqlite3_column_double(res, 8);
483 km.cluster_centers[0](5) = sqlite3_column_double(res, 9);
484
485 km.cluster_centers[1].set_size(Cfg.lag_n + 1);
486 km.cluster_centers[1](0) = sqlite3_column_double(res, 10);
487 km.cluster_centers[1](1) = sqlite3_column_double(res, 11);
488 km.cluster_centers[1](2) = sqlite3_column_double(res, 12);
489 km.cluster_centers[1](3) = sqlite3_column_double(res, 13);
490 km.cluster_centers[1](4) = sqlite3_column_double(res, 14);
491 km.cluster_centers[1](5) = sqlite3_column_double(res, 15);
492
493 dim->km_contexts.emplace_back(km);
494 }
495
496 if (!dim->km_contexts.empty()) {
497 dim->ts = TRAINING_STATUS_TRAINED;
498 }
499
500 spinlock_unlock(&dim->slock);
501
502 if (unlikely(rc != SQLITE_DONE))
503 error_report("Failed to load models, rc = %d", rc);
504
505 if (active_stmt)
506 rc = sqlite3_reset(res);
507 else
508 rc = sqlite3_finalize(res);
509 if (unlikely(rc != SQLITE_OK))
510 error_report("Failed to %s statement when loading models, rc = %d", active_stmt ? "reset" : "finalize", rc);
511
512 return 0;
513
514 bind_fail:
515 error_report("Failed to bind parameter %d to load models, rc = %d", param, rc);
516 rc = sqlite3_reset(res);
517 if (unlikely(rc != SQLITE_OK))
518 error_report("Failed to reset statement to load models, rc = %d", rc);
519 return 1;
520 }
521
522 static void ml_dimension_serialize_kmeans(const ml_dimension_t *dim, BUFFER *wb)
523 {
524 RRDDIM *rd = dim->rd;
525
526 buffer_json_initialize(wb, "\"", "\"", 0, true, BUFFER_JSON_OPTIONS_MINIFY);
527 buffer_json_member_add_string(wb, "version", "1");
528 buffer_json_member_add_string(wb, "machine-guid", rd->rrdset->rrdhost->machine_guid);
529 buffer_json_member_add_string(wb, "chart", rrdset_id(rd->rrdset));
530 buffer_json_member_add_string(wb, "dimension", rrddim_id(rd));
531
532 buffer_json_member_add_object(wb, "model");
533 ml_kmeans_serialize(&dim->km_contexts.back(), wb);
534 buffer_json_object_close(wb);
535
536 buffer_json_finalize(wb);
537 }
538
539 bool
540 ml_dimension_deserialize_kmeans(const char *json_str)
541 {
542 if (!json_str) {
543 netdata_log_error("Failed to deserialize kmeans: json string is null");
544 return false;
545 }
546
547 struct json_object *root = json_tokener_parse(json_str);
548 if (!root) {
549 netdata_log_error("Failed to deserialize kmeans: json parsing failed");
550 return false;
551 }
552
553 // Check the version
554 {
555 struct json_object *tmp_obj;
556 if (!json_object_object_get_ex(root, "version", &tmp_obj)) {
557 netdata_log_error("Failed to deserialize kmeans: missing key 'version'");
558 json_object_put(root);
559 return false;
560 }
561 if (!json_object_is_type(tmp_obj, json_type_string)) {
562 netdata_log_error("Failed to deserialize kmeans: failed to parse string for 'version'");
563 json_object_put(root);
564 return false;
565 }
566 const char *version = json_object_get_string(tmp_obj);
567
568 if (strcmp(version, "1")) {
569 netdata_log_error("Failed to deserialize kmeans: expected version 1");
570 json_object_put(root);
571 return false;
572 }
573 }
574
575 // Get the value of each key
576 std::array<const char *, 3> values;
577 {
578 std::array<const char *, 3> keys = {
579 "machine-guid",
580 "chart",
581 "dimension",
582 };
583
584 struct json_object *tmp_obj;
585 for (size_t i = 0; i != keys.size(); i++) {
586 if (!json_object_object_get_ex(root, keys[i], &tmp_obj)) {
587 netdata_log_error("Failed to deserialize kmeans: missing key '%s'", keys[i]);
588 json_object_put(root);
589 return false;
590 }
591 if (!json_object_is_type(tmp_obj, json_type_string)) {
592 netdata_log_error("Failed to deserialize kmeans: missing string value for key '%s'", keys[i]);
593 json_object_put(root);
594 return false;
595 }
596 values[i] = json_object_get_string(tmp_obj);
597 }
598 }
599
600 DimensionLookupInfo DLI(values[0], values[1], values[2]);
601
602 // Parse the kmeans model
603 ml_kmeans_inlined_t inlined_km;
604 {
605 struct json_object *kmeans_obj;
606 if (!json_object_object_get_ex(root, "model", &kmeans_obj)) {
607 netdata_log_error("Failed to deserialize kmeans: missing key 'model'");
608 json_object_put(root);
609 return false;
610 }
611 if (!json_object_is_type(kmeans_obj, json_type_object)) {
612 netdata_log_error("Failed to deserialize kmeans: failed to parse object for 'model'");
613 json_object_put(root);
614 return false;
615 }
616
617 if (!ml_kmeans_deserialize(&inlined_km, kmeans_obj)) {
618 json_object_put(root);
619 return false;
620 }
621 }
622
623 AcquiredDimension AcqDim(DLI);
624 if (!AcqDim.acquired()) {
625 json_object_put(root);
626 return false;
627 }
628
629 ml_dimension_t *Dim = reinterpret_cast<ml_dimension_t *>(AcqDim.dimension());
630 if (!Dim) {
631 pulse_ml_models_ignored();
632 json_object_put(root);
633 return true;
634 }
635
636 // ml_host may have been unpublished by ml_host_delete() concurrently;
637 // the acquired RRDHOST keeps RH alive but not RH->ml_host.
638 ml_host_t *host = AcqDim.host();
639 if (!host) {
640 pulse_ml_models_ignored();
641 json_object_put(root);
642 return true;
643 }
644
645 ml_queue_item_t item;
646 item.type = ML_QUEUE_ITEM_TYPE_ADD_EXISTING_MODEL;
647 item.add_existing_model = {
648 DLI, inlined_km
649 };
650 ml_queue_push(host->queue, item);
651
652 json_object_put(root);
653 return true;
654 }
655
656 static void ml_dimension_stream_kmeans(ml_worker_t *worker, const ml_dimension_t *dim)
657 {
658 struct sender_state *s = dim->rd->rrdset->rrdhost->sender;
659 if (!s)
660 return;
661
662 if(!stream_sender_has_capabilities(dim->rd->rrdset->rrdhost, STREAM_CAP_ML_MODELS) ||
663 !rrdset_check_upstream_exposed(dim->rd->rrdset) ||
664 !rrddim_check_upstream_exposed(dim->rd))
665 return;
666
667 // Reuse worker's buffers instead of allocating new ones
668 BUFFER *payload = worker->stream_payload_buffer;
669 buffer_flush(payload);
670 ml_dimension_serialize_kmeans(dim, payload);
671
672 BUFFER *wb = worker->stream_wb_buffer;
673 buffer_flush(wb);
674
675 buffer_sprintf(
676 wb, PLUGINSD_KEYWORD_JSON " " PLUGINSD_KEYWORD_JSON_CMD_ML_MODEL "\n%s\n" PLUGINSD_KEYWORD_JSON_END "\n",
677 buffer_tostring(payload));
678
679 sender_commit_clean_buffer(s, wb, STREAM_TRAFFIC_TYPE_METADATA);
680 pulse_ml_models_sent();
681 }
682
683 bool ml_dimension_train_model_precheck(enum ml_metric_type mt,
684 bool has_received_downstream_model,
685 bool training_in_progress,
686 enum ml_worker_result *worker_res)
687 {
688 if (has_received_downstream_model) {
689 *worker_res = ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED;
690 return true;
691 }
692
693 if (mt == METRIC_TYPE_CONSTANT) {
694 *worker_res = ML_WORKER_RESULT_OK;
695 return true;
696 }
697
698 if (training_in_progress) {
699 *worker_res = ML_WORKER_RESULT_TRAINING_IN_PROGRESS;
700 return true;
701 }
702
703 return false;
704 }
705
706 bool ml_should_requeue_create_new_model(enum ml_worker_result worker_res)
707 {
708 // TRAINING_IN_PROGRESS keeps requeueing so the dim stays in the periodic
709 // retrain cycle; the worker loop is paced by Cfg.train_every, so this is
710 // not a tight CPU spin.
711 return worker_res != ML_WORKER_RESULT_NULL_ACQUIRED_DIMENSION &&
712 worker_res != ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED;
713 }
714
715 bool ml_should_publish_model_update(bool host_running,
716 uint32_t current_generation,
717 uint32_t expected_generation,
718 bool *training_in_progress)
719 {
720 if (!host_running || current_generation != expected_generation) {
721 if (training_in_progress)
722 *training_in_progress = false;
723 return false;
724 }
725
726 return true;
727 }
728
729 void ml_dimension_finalize_constant_state(ml_dimension_t *dim)
730 {
731 dim->mt = METRIC_TYPE_CONSTANT;
732 dim->ts = TRAINING_STATUS_TRAINED;
733 dim->suppression_anomaly_counter = 0;
734 dim->suppression_window_counter = 0;
735 }
736
737 static bool ml_dimension_update_models(ml_worker_t *worker, ml_dimension_t *dim, uint32_t expected_generation, bool from_downstream)
738 {
739 worker_is_busy(WORKER_TRAIN_UPDATE_MODELS);
740
741 spinlock_lock(&dim->slock);
742
743 ml_host_t *host = (ml_host_t *) __atomic_load_n(&dim->rd->rrdset->rrdhost->ml_host, __ATOMIC_ACQUIRE);
744 if (!ml_should_publish_model_update(host && host->ml_running,
745 dim->reset_generation,
746 expected_generation,
747 &dim->training_in_progress)) {
748 spinlock_unlock(&dim->slock);
749 return false;
750 }
751
752 // Mark the dim as downstream-supplied only after the publish-check passes
753 // and under the same slock as the install. Setting it earlier would risk
754 // suppressing local training if the install was cancelled.
755 if (from_downstream)
756 dim->has_received_downstream_model = true;
757
758 if (dim->km_contexts.size() < Cfg.num_models_to_use) {
759 dim->km_contexts.emplace_back(dim->kmeans);
760 } else {
761 bool can_drop_middle_km = false;
762
763 if (Cfg.num_models_to_use > 2) {
764 const ml_kmeans_inlined_t *old_km = &dim->km_contexts[dim->km_contexts.size() - 1];
765 const ml_kmeans_inlined_t *middle_km = &dim->km_contexts[dim->km_contexts.size() - 2];
766 const ml_kmeans_t *new_km = &dim->kmeans;
767
768 can_drop_middle_km = (middle_km->after < old_km->before) &&
769 (middle_km->before > new_km->after);
770 }
771
772 if (can_drop_middle_km) {
773 dim->km_contexts.back() = dim->kmeans;
774 } else {
775 std::rotate(std::begin(dim->km_contexts), std::begin(dim->km_contexts) + 1, std::end(dim->km_contexts));
776 dim->km_contexts[dim->km_contexts.size() - 1] = dim->kmeans;
777 }
778 }
779
780 ml_dimension_finalize_constant_state(dim);
781
782 // Add the latest model to the list of pending models to flush.
783 ml_model_info_t model_info;
784 nd_uuid_t *rd_uuid = uuidmap_uuid_ptr(dim->rd->uuid);
785 uuid_copy(model_info.metric_uuid, *rd_uuid);
786 model_info.inlined_kmeans = dim->km_contexts.back();
787 worker->pending_model_info.push_back(model_info);
788
789 ml_dimension_stream_kmeans(worker, dim);
790
791 // Clear the training in progress flag
792 dim->training_in_progress = false;
793
794 spinlock_unlock(&dim->slock);
795 return true;
796 }
797
798 static enum ml_worker_result
799 ml_dimension_train_model(ml_worker_t *worker, ml_dimension_t *dim)
800 {
801 worker_is_busy(WORKER_TRAIN_QUERY);
802
803 spinlock_lock(&dim->slock);
804 ml_worker_result precheck;
805 if (ml_dimension_train_model_precheck(dim->mt,
806 dim->has_received_downstream_model,
807 dim->training_in_progress,
808 &precheck)) {
809 if (precheck == ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED)
810 dim->create_new_model_queued = false;
811 spinlock_unlock(&dim->slock);
812 return precheck;
813 }
814
815 // Mark training as in progress and snapshot the generation so that
816 // ml_dimension_update_models() can detect a stop/reset that happened
817 // while training was running.
818 dim->training_in_progress = true;
819 uint32_t generation = dim->reset_generation;
820 spinlock_unlock(&dim->slock);
821
822 auto P = ml_dimension_calculated_numbers(worker, dim);
823 ml_worker_result worker_result = P.first;
824 ml_training_response_t training_response = P.second;
825
826 if (worker_result != ML_WORKER_RESULT_OK) {
827 spinlock_lock(&dim->slock);
828
829 dim->mt = METRIC_TYPE_CONSTANT;
830 dim->suppression_anomaly_counter = 0;
831 dim->suppression_window_counter = 0;
832 dim->training_in_progress = false;
833
834 spinlock_unlock(&dim->slock);
835
836 return worker_result;
837 }
838
839 // compute kmeans
840 worker_is_busy(WORKER_TRAIN_KMEANS);
841 {
842 memcpy(worker->scratch_training_cns, worker->training_cns,
843 training_response.total_values * sizeof(calculated_number_t));
844
845 size_t smoothing_window = ml_dimension_smoothing_window(dim);
846
847 ml_features_t features = {
848 Cfg.diff_n, smoothing_window, Cfg.lag_n,
849 worker->scratch_training_cns, training_response.total_values,
850 worker->training_cns, training_response.total_values
851 };
852
853 // Calculate dynamic sampling ratio based on expected output size
854 // After diff and smooth, we'll have approximately this many vectors
855 size_t expected_vectors = training_response.total_values;
856 if (Cfg.diff_n > 0) expected_vectors--;
857 if (smoothing_window > 1) expected_vectors = expected_vectors - smoothing_window + 1;
858 expected_vectors = expected_vectors - Cfg.lag_n;
859
860 double sampling_ratio = 1.0;
861 if (expected_vectors > Cfg.max_training_vectors) {
862 sampling_ratio = (double)Cfg.max_training_vectors / expected_vectors;
863 }
864
865 // Apply sampling during lag feature extraction
866 ml_features_preprocess(&features, worker->training_samples, sampling_ratio);
867
868 // Preprocessing can leave fewer than 2 vectors after diff/smooth/lag/sampling.
869 // k-means cannot build 2 cluster centers from that input, so reuse the
870 // post-cycle state machine and bail out before kmeans_train.
871 if (worker->training_samples.size() < 2) {
872 spinlock_lock(&dim->slock);
873 ml_dimension_finalize_constant_state(dim);
874 dim->training_in_progress = false;
875 spinlock_unlock(&dim->slock);
876 return ML_WORKER_RESULT_NOT_ENOUGH_COLLECTED_VALUES;
877 }
878
879 ml_kmeans_init(&dim->kmeans);
880 ml_kmeans_train(&dim->kmeans, worker->training_samples, Cfg.max_kmeans_iters, training_response.query_after_t, training_response.query_before_t);
881 }
882
883 // update models
884 (void) ml_dimension_update_models(worker, dim, generation, /*from_downstream=*/false);
885
886 return worker_result;
887 }
888
889 bool
890 ml_dimension_predict(ml_dimension_t *dim, calculated_number_t value, bool exists)
891 {
892 // Nothing to do if ML is disabled for this dimension
893 if (dim->mls != MACHINE_LEARNING_STATUS_ENABLED)
894 return false;
895
896 // Acquire lock to protect dim->cns from concurrent access by ml_host_stop()
897 if (spinlock_trylock(&dim->slock) == 0)
898 return false;
899
900 // Don't treat values that don't exist as anomalous
901 if (!exists) {
902 dim->cns.clear();
903 dim->cns_head = 0;
904 spinlock_unlock(&dim->slock);
905 return false;
906 }
907
908 // Save the value and return if we don't have enough values for a sample
909 size_t smoothing_window = ml_dimension_smoothing_window(dim);
910 unsigned n = Cfg.diff_n + smoothing_window + Cfg.lag_n;
911
912 size_t cns_size = dim->cns.size();
913
914 // The ring buffer modulus is derived from the current effective smoothing
915 // window. When the effective window changes, the existing history is only
916 // reusable if it is still a linear chronological prefix, which in this
917 // representation means cns_head == 0. Wrapped ring state from the old
918 // modulus must be discarded before warmup/indexing/linearization continue.
919 bool invalid_head = (cns_size > 0 && dim->cns_head >= cns_size);
920 bool size_changed_with_wrapped_state = (cns_size != n && dim->cns_head != 0);
921 bool shrunk_below_existing_history = (cns_size > n);
922 if (invalid_head || size_changed_with_wrapped_state || shrunk_below_existing_history) {
923 dim->cns.clear();
924 dim->cns_head = 0;
925 cns_size = 0;
926 }
927
928 if (cns_size < n) {
929 dim->cns.push_back(value);
930 spinlock_unlock(&dim->slock);
931 return false;
932 }
933
934 // Compare incoming value against the most recent sample (newest_idx).
935 //
936 // The old std::rotate code compared against the oldest element being dropped — that
937 // was a side effect of rotate mechanics, not intentional design.
938 //
939 // Downstream effect: when same_value is false, we set dim->mt = METRIC_TYPE_VARIABLE.
940 // This controls two things:
941 // 1. ml_dimension_train_model() skips training when mt == METRIC_TYPE_CONSTANT.
942 // 2. Statistics reporting counts constant vs variable dimensions.
943 //
944 // Comparing against newest is safe (and more correct) because:
945 // - For truly constant series, all elements are equal — either comparison works.
946 // - For changing series, comparing against newest detects the transition on the
947 // first differing tick. The old oldest-comparison could miss transitions when
948 // the oldest element happened to equal the new value by coincidence.
949 // - mt is reset to METRIC_TYPE_CONSTANT after each training cycle,
950 // so a single false negative cannot cause a permanent misclassification.
951 size_t newest_idx = (dim->cns_head + n - 1) % n;
952 bool same_value = (dim->cns[newest_idx] == value);
953 dim->cns[dim->cns_head] = value;
954 dim->cns_head = (dim->cns_head + 1) % n;
955
956 // Create the sample
957 calculated_number_t src_cns[128];
958 calculated_number_t dst_cns[128];
959 constexpr size_t src_cns_capacity = sizeof(src_cns) / sizeof(src_cns[0]);
960 constexpr size_t dst_cns_capacity = sizeof(dst_cns) / sizeof(dst_cns[0]);
961 fatal_assert((n <= src_cns_capacity && n <= dst_cns_capacity) &&
962 "Static buffers too small to perform prediction. "
963 "This should not be possible with the default clamping of feature extraction options");
964
965 size_t first_chunk = n - dim->cns_head;
966 memcpy(src_cns, dim->cns.data() + dim->cns_head, first_chunk * sizeof(calculated_number_t));
967 if (dim->cns_head)
968 memcpy(src_cns + first_chunk, dim->cns.data(), dim->cns_head * sizeof(calculated_number_t));
969 memcpy(dst_cns, src_cns, n * sizeof(calculated_number_t));
970
971 ml_features_t features = {
972 Cfg.diff_n, smoothing_window, Cfg.lag_n,
973 dst_cns, n, src_cns, n
974 };
975 ml_features_preprocess_predict(&features, dim->feature);
976
977 // Mark the metric time as variable if we received different values
978 if (!same_value)
979 dim->mt = METRIC_TYPE_VARIABLE;
980
981 // Ignore silenced dimensions
982 if (dim->ts == TRAINING_STATUS_SILENCED) {
983 spinlock_unlock(&dim->slock);
984 return false;
985 }
986
987 dim->suppression_window_counter++;
988
989 /*
990 * Use the KMeans models to check if the value is anomalous
991 */
992
993 size_t sum = 0;
994 size_t models_consulted = 0;
995
996 for (const auto &km_ctx : dim->km_contexts) {
997 models_consulted++;
998
999 calculated_number_t anomaly_score = ml_kmeans_anomaly_score(&km_ctx, dim->feature);
1000 if (std::isnan(anomaly_score))
1001 continue;
1002
1003 if (anomaly_score < (100 * Cfg.dimension_anomaly_score_threshold)) {
1004 spinlock_unlock(&dim->slock);
1005 pulse_ml_models_consulted(models_consulted);
1006 return false;
1007 }
1008
1009 sum += 1;
1010 }
1011
1012 dim->suppression_anomaly_counter += sum ? 1 : 0;
1013
1014 if ((dim->suppression_anomaly_counter >= Cfg.suppression_threshold) &&
1015 (dim->suppression_window_counter >= Cfg.suppression_window)) {
1016 dim->ts = TRAINING_STATUS_SILENCED;
1017 }
1018
1019 spinlock_unlock(&dim->slock);
1020
1021 pulse_ml_models_consulted(models_consulted);
1022 return sum;
1023 }
1024
1025 /*
1026 * Chart
1027 */
1028
1029 static bool
1030 ml_chart_is_available_for_ml(ml_chart_t *chart)
1031 {
1032 return rrdset_is_available_for_exporting_and_alarms(chart->rs);
1033 }
1034
1035 void
1036 ml_chart_update_dimension(ml_chart_t *chart, ml_dimension_t *dim, bool is_anomalous)
1037 {
1038 switch (dim->mls) {
1039 case MACHINE_LEARNING_STATUS_DISABLED_DUE_TO_EXCLUDED_CHART:
1040 chart->mls.num_machine_learning_status_disabled_sp++;
1041 return;
1042 case MACHINE_LEARNING_STATUS_ENABLED: {
1043 chart->mls.num_machine_learning_status_enabled++;
1044
1045 switch (dim->mt) {
1046 case METRIC_TYPE_CONSTANT:
1047 chart->mls.num_metric_type_constant++;
1048 chart->mls.num_training_status_trained++;
1049 chart->mls.num_normal_dimensions++;
1050 return;
1051 case METRIC_TYPE_VARIABLE:
1052 chart->mls.num_metric_type_variable++;
1053 break;
1054 }
1055
1056 switch (dim->ts) {
1057 case TRAINING_STATUS_UNTRAINED:
1058 chart->mls.num_training_status_untrained++;
1059 return;
1060 case TRAINING_STATUS_TRAINED:
1061 chart->mls.num_training_status_trained++;
1062
1063 chart->mls.num_anomalous_dimensions += is_anomalous;
1064 chart->mls.num_normal_dimensions += !is_anomalous;
1065 return;
1066 case TRAINING_STATUS_SILENCED:
1067 chart->mls.num_training_status_silenced++;
1068 chart->mls.num_training_status_trained++;
1069
1070 chart->mls.num_anomalous_dimensions += is_anomalous;
1071 chart->mls.num_normal_dimensions += !is_anomalous;
1072 return;
1073 }
1074
1075 return;
1076 }
1077 }
1078 }
1079
1080 /*
1081 * Host detection & training functions
1082 */
1083
1084 #define WORKER_JOB_DETECTION_COLLECT_STATS 0
1085 #define WORKER_JOB_DETECTION_DIM_CHART 1
1086 #define WORKER_JOB_DETECTION_HOST_CHART 2
1087 #define WORKER_JOB_DETECTION_STATS 3
1088
1089 static void
1090 ml_host_detect_once(ml_host_t *host, ONEWAYALLOC *owa)
1091 {
1092 worker_is_busy(WORKER_JOB_DETECTION_COLLECT_STATS);
1093
1094 ml_machine_learning_stats_t mls_copy = {};
1095 ml_machine_learning_stats_t host_mls = {};
1096 calculated_number_t host_anomaly_rate = 0.0;
1097
1098 if (host->ml_running) {
1099 // Snapshot the stop generation before the unlocked walk. If it changes
1100 // by the time we publish, a stop ran while we were reading chart->mls
1101 // and the accumulated snapshot must be discarded.
1102 uint64_t stop_gen_before = host->ml_stop_generation.load();
1103
1104 /*
1105 * prediction/detection stats
1106 */
1107 void *rsp = NULL;
1108 rrdset_foreach_read(rsp, host->rh) {
1109 RRDSET *rs = static_cast<RRDSET *>(rsp);
1110
1111 ml_chart_t *chart = (ml_chart_t *) __atomic_load_n(&rs->ml_chart, __ATOMIC_ACQUIRE);
1112 if (!chart)
1113 continue;
1114
1115 if (!ml_chart_is_available_for_ml(chart))
1116 continue;
1117
1118 ml_machine_learning_stats_t chart_mls = chart->mls;
1119
1120 host_mls.num_machine_learning_status_enabled += chart_mls.num_machine_learning_status_enabled;
1121 host_mls.num_machine_learning_status_disabled_sp += chart_mls.num_machine_learning_status_disabled_sp;
1122
1123 host_mls.num_metric_type_constant += chart_mls.num_metric_type_constant;
1124 host_mls.num_metric_type_variable += chart_mls.num_metric_type_variable;
1125
1126 host_mls.num_training_status_untrained += chart_mls.num_training_status_untrained;
1127 host_mls.num_training_status_pending_without_model += chart_mls.num_training_status_pending_without_model;
1128 host_mls.num_training_status_trained += chart_mls.num_training_status_trained;
1129 host_mls.num_training_status_pending_with_model += chart_mls.num_training_status_pending_with_model;
1130 host_mls.num_training_status_silenced += chart_mls.num_training_status_silenced;
1131
1132 host_mls.num_anomalous_dimensions += chart_mls.num_anomalous_dimensions;
1133 host_mls.num_normal_dimensions += chart_mls.num_normal_dimensions;
1134
1135 if (spinlock_trylock(&host->context_anomaly_rate_spinlock))
1136 {
1137 STRING *key = rs->context;
1138 auto &um = host->context_anomaly_rate;
1139 auto it = um.find(key);
1140 if (it == um.end()) {
1141 STRING *owned_key = string_dup(key);
1142 auto insert_result = um.emplace(owned_key, ml_context_anomaly_rate_t {
1143 .rd = NULL,
1144 .normal_dimensions = 0,
1145 .anomalous_dimensions = 0
1146 });
1147 if (!insert_result.second)
1148 string_freez(owned_key);
1149 it = insert_result.first;
1150 }
1151
1152 it->second.anomalous_dimensions += chart_mls.num_anomalous_dimensions;
1153 it->second.normal_dimensions += chart_mls.num_normal_dimensions;
1154 spinlock_unlock(&host->context_anomaly_rate_spinlock);
1155 }
1156 }
1157 rrdset_foreach_done(rsp);
1158
1159 size_t num_active_dimensions = host_mls.num_anomalous_dimensions + host_mls.num_normal_dimensions;
1160 if (num_active_dimensions)
1161 host_anomaly_rate = static_cast<double>(host_mls.num_anomalous_dimensions) / num_active_dimensions;
1162
1163 // Publish the final host snapshot after chart traversal so chart
1164 // deletion cannot block other host->mutex users for the full walk.
1165 // Discard the snapshot if either (a) ml_running is now false, or
1166 // (b) the stop generation changed since the walk started — the
1167 // latter catches a stop+start that completed during the walk and
1168 // would otherwise pass the boolean check. In either case our
1169 // unlocked chart->mls reads may have raced ml_host_stop, so zero
1170 // the snapshot and the per-context counts. The chart updates below
1171 // run unconditionally: ml_update_dimensions_chart reads
1172 // host->ml_running directly (so the ml_running chart records the
1173 // stop), and the chart-update path resets and republishes the rest.
1174 netdata_mutex_lock(&host->mutex);
1175 uint64_t stop_gen_after = host->ml_stop_generation.load();
1176 if (!host->ml_running || stop_gen_before != stop_gen_after) {
1177 host_mls = {};
1178 host_anomaly_rate = 0.0;
1179
1180 spinlock_lock(&host->context_anomaly_rate_spinlock);
1181 for (auto &p : host->context_anomaly_rate) {
1182 p.second.anomalous_dimensions = 0;
1183 p.second.normal_dimensions = 0;
1184 }
1185 spinlock_unlock(&host->context_anomaly_rate_spinlock);
1186 }
1187 host->mls = host_mls;
1188 host->host_anomaly_rate = host_anomaly_rate;
1189 mls_copy = host_mls;
1190 netdata_mutex_unlock(&host->mutex);
1191
1192 worker_is_busy(WORKER_JOB_DETECTION_DIM_CHART);
1193 ml_update_dimensions_chart(host, mls_copy);
1194
1195 worker_is_busy(WORKER_JOB_DETECTION_HOST_CHART);
1196 ml_update_host_and_detection_rate_charts(host, host_anomaly_rate * 10000.0, owa);
1197 } else {
1198 host->host_anomaly_rate = 0.0;
1199 }
1200 }
1201
1202 void ml_detect_main(void *arg)
1203 {
1204 UNUSED(arg);
1205
1206 worker_register("MLDETECT");
1207 worker_register_job_name(WORKER_JOB_DETECTION_COLLECT_STATS, "collect stats");
1208 worker_register_job_name(WORKER_JOB_DETECTION_DIM_CHART, "dim chart");
1209 worker_register_job_name(WORKER_JOB_DETECTION_HOST_CHART, "host chart");
1210 worker_register_job_name(WORKER_JOB_DETECTION_STATS, "training stats");
1211
1212 heartbeat_t hb;
1213 heartbeat_init(&hb, USEC_PER_SEC);
1214
1215 // Single onewayalloc arena reused across every host and loop iteration
1216 // for the whole detect thread lifetime — one mmap/munmap pair instead
1217 // of one per host per second. The arena is reset between hosts inside
1218 // ml_update_host_and_detection_rate_charts, so peak memory stays bounded
1219 // by a single host's anomaly-rate query scratch.
1220 ONEWAYALLOC *detect_owa = onewayalloc_create(0);
1221
1222 while (!Cfg.detection_stop && service_running(SERVICE_COLLECTORS)) {
1223 worker_is_idle();
1224 heartbeat_next(&hb);
1225
1226 RRDHOST *rh;
1227 rrd_rdlock();
1228 rrdhost_foreach_read(rh) {
1229 ml_host_t *host = (ml_host_t *) __atomic_load_n(&rh->ml_host, __ATOMIC_ACQUIRE);
1230 if (!host)
1231 continue;
1232
1233 if (!service_running(SERVICE_COLLECTORS))
1234 break;
1235
1236 ml_host_detect_once(host, detect_owa);
1237 }
1238 rrd_rdunlock();
1239
1240 if (Cfg.enable_statistics_charts) {
1241 // collect and update training thread stats
1242 for (size_t idx = 0; idx != Cfg.num_worker_threads; idx++) {
1243 ml_worker_t *worker = &Cfg.workers[idx];
1244
1245 netdata_mutex_lock(&worker->nd_mutex);
1246 ml_queue_stats_t queue_stats = worker->queue_stats;
1247 netdata_mutex_unlock(&worker->nd_mutex);
1248
1249 ml_update_training_statistics_chart(worker, queue_stats);
1250 }
1251 }
1252 }
1253
1254 onewayalloc_destroy(detect_owa);
1255
1256 Cfg.training_stop = true;
1257 finalize_self_prepared_sql_statements();
1258 }
1259
1260 static void ml_flush_pending_models(ml_worker_t *worker) {
1261 static time_t next_vacuum_run = 0;
1262 int op_no = 1;
1263
1264 // begin transaction
1265 int rc = db_execute(ml_db, "BEGIN TRANSACTION;", NULL);
1266
1267 // add/delete models
1268 if (!rc) {
1269 op_no++;
1270
1271 for (const auto &pending_model: worker->pending_model_info) {
1272 if (!rc)
1273 rc = ml_dimension_add_model(&pending_model.metric_uuid, &pending_model.inlined_kmeans);
1274
1275 if (!rc)
1276 rc = ml_dimension_delete_models(&pending_model.metric_uuid, pending_model.inlined_kmeans.before - (Cfg.num_models_to_use * Cfg.train_every));
1277 }
1278 }
1279
1280 // prune old models
1281 if (!rc) {
1282 if ((worker->num_db_transactions % 64) == 0) {
1283 rc = ml_prune_old_models(worker->num_models_to_prune);
1284 if (!rc)
1285 worker->num_models_to_prune = 0;
1286 }
1287 }
1288
1289 // commit transaction
1290 if (!rc) {
1291 op_no++;
1292 rc = db_execute(ml_db, "COMMIT TRANSACTION;", NULL);
1293 }
1294
1295 // rollback transaction on failure
1296 if (rc) {
1297 netdata_log_error("Trying to rollback ML transaction because it failed with rc=%d, op_no=%d", rc, op_no);
1298 op_no++;
1299 rc = db_execute(ml_db, "ROLLBACK;", NULL);
1300 if (rc)
1301 netdata_log_error("ML transaction rollback failed with rc=%d", rc);
1302 }
1303
1304 if (!rc) {
1305 worker->num_db_transactions++;
1306 worker->num_models_to_prune += worker->pending_model_info.size();
1307 }
1308
1309 vacuum_database(ml_db, "ML", 0, 0, &next_vacuum_run);
1310
1311 worker->pending_model_info.clear();
1312 }
1313
1314 static enum ml_worker_result ml_worker_create_new_model(ml_worker_t *worker, ml_request_create_new_model_t req) {
1315 AcquiredDimension AcqDim(req.DLI);
1316
1317 if (!AcqDim.acquired()) {
1318 return ML_WORKER_RESULT_NULL_ACQUIRED_DIMENSION;
1319 }
1320
1321 ml_dimension_t *Dim = reinterpret_cast<ml_dimension_t *>(AcqDim.dimension());
1322 return ml_dimension_train_model(worker, Dim);
1323 }
1324
1325 static enum ml_worker_result ml_worker_add_existing_model(ml_worker_t *worker, ml_request_add_existing_model_t req) {
1326 AcquiredDimension AcqDim(req.DLI);
1327
1328 if (!AcqDim.acquired()) {
1329 return ML_WORKER_RESULT_NULL_ACQUIRED_DIMENSION;
1330 }
1331
1332 ml_dimension_t *Dim = reinterpret_cast<ml_dimension_t *>(AcqDim.dimension());
1333 if (!Dim) {
1334 pulse_ml_models_ignored();
1335 return ML_WORKER_RESULT_OK;
1336 }
1337
1338 ml_host_t *host = (ml_host_t *) __atomic_load_n(&Dim->rd->rrdset->rrdhost->ml_host, __ATOMIC_ACQUIRE);
1339 if (!host || !host->ml_running) {
1340 pulse_ml_models_ignored();
1341 return ML_WORKER_RESULT_OK;
1342 }
1343
1344 spinlock_lock(&Dim->slock);
1345
1346 // Loop detection: skip if we already have this exact model.
1347 // The (after, before) pair uniquely identifies a model per dimension and is
1348 // preserved across hops, so a model that loops back is detected as a duplicate.
1349 for (const auto &km : Dim->km_contexts) {
1350 if (km.after == req.inlined_km.after && km.before == req.inlined_km.before) {
1351 spinlock_unlock(&Dim->slock);
1352 pulse_ml_models_ignored();
1353 return ML_WORKER_RESULT_OK;
1354 }
1355 }
1356
1357 // Reject models that are not newer than the newest accepted model. This
1358 // prevents an older model from being re-accepted after it has been evicted
1359 // from km_contexts and later loops back from downstream.
1360 if (!Dim->km_contexts.empty()) {
1361 const auto &latest_km = Dim->km_contexts.back();
1362 if (req.inlined_km.before <= latest_km.before) {
1363 spinlock_unlock(&Dim->slock);
1364 pulse_ml_models_ignored();
1365 return ML_WORKER_RESULT_OK;
1366 }
1367 }
1368
1369 // Skip if training is in progress to avoid race condition.
1370 if (Dim->training_in_progress) {
1371 spinlock_unlock(&Dim->slock);
1372 pulse_ml_models_ignored();
1373 return ML_WORKER_RESULT_OK;
1374 }
1375
1376 // Stage the incoming kmeans into the dim's working buffer; the actual
1377 // install into km_contexts and the has_received_downstream_model flag-set
1378 // happen inside ml_dimension_update_models() under the same slock as the
1379 // publish-check, so a concurrent ml_host_stop() either commits both or
1380 // cancels both.
1381 Dim->kmeans = req.inlined_km;
1382 uint32_t generation = Dim->reset_generation;
1383 spinlock_unlock(&Dim->slock);
1384 if (ml_dimension_update_models(worker, Dim, generation, /*from_downstream=*/true))
1385 pulse_ml_models_received();
1386
1387 return ML_WORKER_RESULT_OK;
1388 }
1389
1390 void ml_train_main(void *arg) {
1391 ml_worker_t *worker = (ml_worker_t *) arg;
1392
1393 char worker_name[1024];
1394 snprintfz(worker_name, 1024, "ml_worker_%zu", worker->id);
1395 worker_register("MLTRAIN");
1396
1397 worker_register_job_name(WORKER_TRAIN_QUEUE_POP, "pop queue");
1398 worker_register_job_name(WORKER_TRAIN_ACQUIRE_DIMENSION, "acquire");
1399 worker_register_job_name(WORKER_TRAIN_QUERY, "query");
1400 worker_register_job_name(WORKER_TRAIN_KMEANS, "kmeans");
1401 worker_register_job_name(WORKER_TRAIN_UPDATE_MODELS, "update models");
1402 worker_register_job_name(WORKER_TRAIN_RELEASE_DIMENSION, "release");
1403 worker_register_job_name(WORKER_TRAIN_UPDATE_HOST, "update host");
1404 worker_register_job_name(WORKER_TRAIN_FLUSH_MODELS, "flush models");
1405
1406 while (!Cfg.training_stop) {
1407 if(!stream_control_ml_should_be_running()) {
1408 worker_is_idle();
1409 stream_control_throttle();
1410 continue;
1411 }
1412
1413 worker_is_busy(WORKER_TRAIN_QUEUE_POP);
1414
1415 ml_queue_stats_t loop_stats{};
1416
1417 ml_queue_item_t item = ml_queue_pop(worker->queue);
1418 if (item.type == ML_QUEUE_ITEM_STOP_REQUEST) {
1419 break;
1420 }
1421
1422 ml_queue_size_t queue_size = ml_queue_size(worker->queue);
1423
1424 usec_t allotted_ut = (Cfg.train_every * USEC_PER_SEC) / (queue_size.create_new_model + 1);
1425 if (allotted_ut > USEC_PER_SEC)
1426 allotted_ut = USEC_PER_SEC;
1427
1428 usec_t start_ut = now_monotonic_usec();
1429
1430 enum ml_worker_result worker_res;
1431
1432 switch (item.type) {
1433 case ML_QUEUE_ITEM_TYPE_CREATE_NEW_MODEL: {
1434 worker_res = ml_worker_create_new_model(worker, item.create_new_model);
1435 if (ml_should_requeue_create_new_model(worker_res)) {
1436 ml_queue_push(worker->queue, item);
1437 }
1438 break;
1439 }
1440 case ML_QUEUE_ITEM_TYPE_ADD_EXISTING_MODEL: {
1441 worker_res = ml_worker_add_existing_model(worker, item.add_existing_model);
1442 break;
1443 }
1444 default: {
1445 fatal("Unknown queue item type");
1446 }
1447 }
1448
1449 usec_t consumed_ut = now_monotonic_usec() - start_ut;
1450
1451 usec_t remaining_ut = 0;
1452 if (consumed_ut < allotted_ut)
1453 remaining_ut = allotted_ut - consumed_ut;
1454
1455 if (Cfg.enable_statistics_charts) {
1456 worker_is_busy(WORKER_TRAIN_UPDATE_HOST);
1457
1458 ml_queue_stats_t queue_stats = ml_queue_stats(worker->queue);
1459
1460 loop_stats.total_add_existing_model_requests_pushed = queue_stats.total_add_existing_model_requests_pushed;
1461 loop_stats.total_add_existing_model_requests_popped = queue_stats.total_add_existing_model_requests_popped;
1462 loop_stats.total_create_new_model_requests_pushed = queue_stats.total_create_new_model_requests_pushed;
1463 loop_stats.total_create_new_model_requests_popped = queue_stats.total_create_new_model_requests_popped;
1464
1465 loop_stats.allotted_ut = allotted_ut;
1466 loop_stats.consumed_ut = consumed_ut;
1467 loop_stats.remaining_ut = remaining_ut;
1468
1469 switch (worker_res) {
1470 case ML_WORKER_RESULT_OK:
1471 loop_stats.item_result_ok = 1;
1472 break;
1473 case ML_WORKER_RESULT_INVALID_QUERY_TIME_RANGE:
1474 loop_stats.item_result_invalid_query_time_range = 1;
1475 break;
1476 case ML_WORKER_RESULT_NOT_ENOUGH_COLLECTED_VALUES:
1477 loop_stats.item_result_not_enough_collected_values = 1;
1478 break;
1479 case ML_WORKER_RESULT_NULL_ACQUIRED_DIMENSION:
1480 loop_stats.item_result_null_acquired_dimension = 1;
1481 break;
1482 case ML_WORKER_RESULT_CHART_UNDER_REPLICATION:
1483 loop_stats.item_result_chart_under_replication = 1;
1484 break;
1485 case ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED:
1486 loop_stats.item_result_ok = 1;
1487 break;
1488 case ML_WORKER_RESULT_TRAINING_IN_PROGRESS:
1489 loop_stats.item_result_ok = 1;
1490 break;
1491 }
1492
1493 netdata_mutex_lock(&worker->nd_mutex);
1494
1495 worker->queue_stats.total_add_existing_model_requests_pushed = loop_stats.total_add_existing_model_requests_pushed;
1496 worker->queue_stats.total_add_existing_model_requests_popped = loop_stats.total_add_existing_model_requests_popped;
1497
1498 worker->queue_stats.total_create_new_model_requests_pushed = loop_stats.total_create_new_model_requests_pushed;
1499 worker->queue_stats.total_create_new_model_requests_popped = loop_stats.total_create_new_model_requests_popped;
1500
1501 worker->queue_stats.allotted_ut += loop_stats.allotted_ut;
1502 worker->queue_stats.consumed_ut += loop_stats.consumed_ut;
1503 worker->queue_stats.remaining_ut += loop_stats.remaining_ut;
1504
1505 worker->queue_stats.item_result_ok += loop_stats.item_result_ok;
1506 worker->queue_stats.item_result_invalid_query_time_range += loop_stats.item_result_invalid_query_time_range;
1507 worker->queue_stats.item_result_not_enough_collected_values += loop_stats.item_result_not_enough_collected_values;
1508 worker->queue_stats.item_result_null_acquired_dimension += loop_stats.item_result_null_acquired_dimension;
1509 worker->queue_stats.item_result_chart_under_replication += loop_stats.item_result_chart_under_replication;
1510
1511 netdata_mutex_unlock(&worker->nd_mutex);
1512 }
1513
1514 bool should_sleep = true;
1515
1516 if (worker->pending_model_info.size() >= Cfg.flush_models_batch_size) {
1517 worker_is_busy(WORKER_TRAIN_FLUSH_MODELS);
1518 netdata_mutex_lock(&db_mutex);
1519 ml_flush_pending_models(worker);
1520 netdata_mutex_unlock(&db_mutex);
1521 should_sleep = false;
1522 }
1523
1524 if (item.type == ML_QUEUE_ITEM_TYPE_ADD_EXISTING_MODEL) {
1525 should_sleep = false;
1526 }
1527
1528 if (!should_sleep)
1529 continue;
1530
1531 worker_is_idle();
1532 std::this_thread::sleep_for(std::chrono::microseconds{remaining_ut});
1533 }
1534 finalize_self_prepared_sql_statements();
1535 }