omdnotificationforwarder 4.5__tar.gz → 4.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/PKG-INFO +15 -2
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/README.md +14 -1
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/bin/notificationforwarder +1 -1
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/pyproject.toml +1 -1
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/baseclass.py +44 -7
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/logger.py +2 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/spool.py +17 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/logger.py +5 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/TESTS.md +30 -4
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_delivery_resilience.py +35 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_loggers.py +37 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_resilience_spool.py +391 -18
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/.gitignore +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/component_loader.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/__init__.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/naemonlog/reporter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/__init__.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_config.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_flow.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/telegram/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/__init__.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/util.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/webhook/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_alertmanager.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_builtin_plugins_focus.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_classes.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_formatters.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_forwarders.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_reporters.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_discard.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_formatter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_integration_cli.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_loading_resolution.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_logger.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_nostr.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_oauth2webhook.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_orchestration_flow.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_package.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_paths.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_reporter.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_runtime_foundation.py +0 -0
- {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: omdnotificationforwarder
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.6
|
|
4
4
|
Summary: A framework for notification scripts for OMD
|
|
5
5
|
Project-URL: Homepage, https://github.com/lausser/noteventificationforhandlerwarder
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/lausser/noteventificationforhandlerwarder/issues
|
|
@@ -521,10 +521,23 @@ define command{
|
|
|
521
521
|
|
|
522
522
|
When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
|
|
523
523
|
|
|
524
|
-
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool.
|
|
524
|
+
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one, oldest first. Events that are successfully submitted are deleted from the spool. The first event that fails again ends the flush: it and all later events stay in the queue for the next attempt. A receiver that does not accept one event will most likely not accept the next ones either, so a flush costs at most one submit timeout instead of one per spooled event.
|
|
525
|
+
|
|
526
|
+
If the receiver rejects a spooled event for good (for example because of an invalid payload), that event must not block the queue until `max_spool_minutes` have passed. When a flush stopped at an event and the current event is then accepted by the receiver, the receiver is evidently up. The stopped event is moved to the end of the queue and logged as "spooled event moved to the end of the spool". It keeps its original time, so it is still dropped when `max_spool_minutes` have passed, and the next flush can deliver the events behind it.
|
|
525
527
|
|
|
526
528
|
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
527
529
|
|
|
530
|
+
### Delivery Order
|
|
531
|
+
|
|
532
|
+
Spooled events are stored and re-submitted in the order in which they failed. As long as every delivery succeeds, the receiver gets the events in the order in which they were created. After delivery failures, this is not guaranteed. A newer event can reach the receiver before an older one, for example a RECOVERY before its PROBLEM:
|
|
533
|
+
|
|
534
|
+
- While one notificationforwarder process is flushing the spool, another process submits its own new event directly, ahead of the spooled ones.
|
|
535
|
+
- A spooled event was rejected while the receiver accepted the current event. The rejected event is moved to the end of the spool and is delivered, if at all, after newer events.
|
|
536
|
+
- The flush stopped early, or `probe()` returned False. The process still submits its own new event directly.
|
|
537
|
+
- A submit ran into the timeout, but the receiver got the event anyway. The event stays in the spool, and the receiver gets it a second time later, after newer events.
|
|
538
|
+
|
|
539
|
+
If the receiver depends on the order, let the formatter include the event's creation time (`omd_originating_timestamp`, set for every event) in the payload. The receiver or its integration can then ignore an event that is older than the state it already has.
|
|
540
|
+
|
|
528
541
|
### Probe Method
|
|
529
542
|
|
|
530
543
|
Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
|
|
@@ -500,10 +500,23 @@ define command{
|
|
|
500
500
|
|
|
501
501
|
When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
|
|
502
502
|
|
|
503
|
-
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool.
|
|
503
|
+
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one, oldest first. Events that are successfully submitted are deleted from the spool. The first event that fails again ends the flush: it and all later events stay in the queue for the next attempt. A receiver that does not accept one event will most likely not accept the next ones either, so a flush costs at most one submit timeout instead of one per spooled event.
|
|
504
|
+
|
|
505
|
+
If the receiver rejects a spooled event for good (for example because of an invalid payload), that event must not block the queue until `max_spool_minutes` have passed. When a flush stopped at an event and the current event is then accepted by the receiver, the receiver is evidently up. The stopped event is moved to the end of the queue and logged as "spooled event moved to the end of the spool". It keeps its original time, so it is still dropped when `max_spool_minutes` have passed, and the next flush can deliver the events behind it.
|
|
504
506
|
|
|
505
507
|
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
506
508
|
|
|
509
|
+
### Delivery Order
|
|
510
|
+
|
|
511
|
+
Spooled events are stored and re-submitted in the order in which they failed. As long as every delivery succeeds, the receiver gets the events in the order in which they were created. After delivery failures, this is not guaranteed. A newer event can reach the receiver before an older one, for example a RECOVERY before its PROBLEM:
|
|
512
|
+
|
|
513
|
+
- While one notificationforwarder process is flushing the spool, another process submits its own new event directly, ahead of the spooled ones.
|
|
514
|
+
- A spooled event was rejected while the receiver accepted the current event. The rejected event is moved to the end of the spool and is delivered, if at all, after newer events.
|
|
515
|
+
- The flush stopped early, or `probe()` returned False. The process still submits its own new event directly.
|
|
516
|
+
- A submit ran into the timeout, but the receiver got the event anyway. The event stays in the spool, and the receiver gets it a second time later, after newer events.
|
|
517
|
+
|
|
518
|
+
If the receiver depends on the order, let the formatter include the event's creation time (`omd_originating_timestamp`, set for every event) in the payload. The receiver or its integration can then ignore an event that is older than the state it already has.
|
|
519
|
+
|
|
507
520
|
### Probe Method
|
|
508
521
|
|
|
509
522
|
Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
|
|
@@ -78,7 +78,7 @@ Example for an HTTP-based reporter:
|
|
|
78
78
|
help='Logger type: text (default) or json, or custom logger module',
|
|
79
79
|
default='text')
|
|
80
80
|
parser.add_argument('--version', action='version',
|
|
81
|
-
version=f'%(prog)s 4.
|
|
81
|
+
version=f'%(prog)s 4.6')
|
|
82
82
|
|
|
83
83
|
args = parser.parse_args()
|
|
84
84
|
if not hasattr(args, 'formatter'):
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/baseclass.py
RENAMED
|
@@ -330,6 +330,7 @@ class NotificationForwarder(object):
|
|
|
330
330
|
return enrich_raw_event(raw_event)
|
|
331
331
|
|
|
332
332
|
def forward_formatted(self, formatted_event):
|
|
333
|
+
self.flush_stopped_at = None
|
|
333
334
|
try:
|
|
334
335
|
"""probe() checks if a forwarder is principally capable to submit
|
|
335
336
|
an event. It is mostly used to contact an api and confirm that
|
|
@@ -376,6 +377,11 @@ class NotificationForwarder(object):
|
|
|
376
377
|
'formatted_event': formatted_event,
|
|
377
378
|
'status': 'success'
|
|
378
379
|
})
|
|
380
|
+
if formatted_event is not None and self.flush_stopped_at is not None:
|
|
381
|
+
# the receiver accepted this event, so it is up. the spooled
|
|
382
|
+
# event at which the flush stopped was rejected for its own
|
|
383
|
+
# reasons and must not block the spool until it expires.
|
|
384
|
+
self.move_stopped_event_to_end()
|
|
379
385
|
return success
|
|
380
386
|
return False
|
|
381
387
|
|
|
@@ -411,7 +417,38 @@ class NotificationForwarder(object):
|
|
|
411
417
|
def acquire_lock_with_retry(self, lock_file, max_attempts=3, base_delay=0.1):
|
|
412
418
|
return acquire_lock_with_retry(lock_file, logger, max_attempts=max_attempts, base_delay=base_delay)
|
|
413
419
|
|
|
420
|
+
def move_stopped_event_to_end(self):
|
|
421
|
+
event_id = self.flush_stopped_at
|
|
422
|
+
self.flush_stopped_at = None
|
|
423
|
+
try:
|
|
424
|
+
with open(self.db_lock_file, "w") as lock_file:
|
|
425
|
+
# a running flush could hold the row in memory, so move it
|
|
426
|
+
# only while no other process is flushing
|
|
427
|
+
if not self.acquire_lock_with_retry(lock_file, max_attempts=1):
|
|
428
|
+
logger.debug("missed the flush lock, rejected event stays in place", {
|
|
429
|
+
'event_id': event_id,
|
|
430
|
+
})
|
|
431
|
+
return
|
|
432
|
+
try:
|
|
433
|
+
new_event_id = self.spool_store.move_to_end(event_id)
|
|
434
|
+
finally:
|
|
435
|
+
import fcntl
|
|
436
|
+
fcntl.lockf(lock_file, fcntl.LOCK_UN)
|
|
437
|
+
if new_event_id is not None:
|
|
438
|
+
logger.critical("spooled event moved to the end of the spool", {
|
|
439
|
+
'event_id': event_id,
|
|
440
|
+
'new_event_id': new_event_id,
|
|
441
|
+
'action': 'moved_to_end',
|
|
442
|
+
})
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.critical("could not move rejected spooled event", {
|
|
445
|
+
'event_id': event_id,
|
|
446
|
+
'exception': e,
|
|
447
|
+
})
|
|
448
|
+
|
|
414
449
|
def flush(self):
|
|
450
|
+
# id of the spooled event at which the flush stopped, see move_stopped_event_to_end()
|
|
451
|
+
self.flush_stopped_at = None
|
|
415
452
|
with open(self.db_lock_file, "w") as lock_file:
|
|
416
453
|
locked = self.acquire_lock_with_retry(lock_file)
|
|
417
454
|
if locked:
|
|
@@ -429,8 +466,10 @@ class NotificationForwarder(object):
|
|
|
429
466
|
})
|
|
430
467
|
# walk through the rows which existed when the flush started.
|
|
431
468
|
# rows spooled meanwhile by concurrent processes are left for the
|
|
432
|
-
# next flush and each row is tried at most once
|
|
433
|
-
#
|
|
469
|
+
# next flush and each row is tried at most once. the first failed
|
|
470
|
+
# submit ends the flush, because a receiver which does not accept
|
|
471
|
+
# one event will most likely not accept the next ones either and
|
|
472
|
+
# every further attempt would cost another submit timeout.
|
|
434
473
|
last_id = 0
|
|
435
474
|
snapshot_max_id = self.spool_store.max_id()
|
|
436
475
|
while True:
|
|
@@ -442,7 +481,6 @@ class NotificationForwarder(object):
|
|
|
442
481
|
'spooled_count': len(id_events),
|
|
443
482
|
'action': 'resend'
|
|
444
483
|
})
|
|
445
|
-
progress = False
|
|
446
484
|
for id, text in id_events:
|
|
447
485
|
last_id = id
|
|
448
486
|
raw_event = self.spool_store.decode(text)
|
|
@@ -454,7 +492,6 @@ class NotificationForwarder(object):
|
|
|
454
492
|
'action': 'discard_during_replay'
|
|
455
493
|
})
|
|
456
494
|
self.spool_store.delete(id)
|
|
457
|
-
progress = True
|
|
458
495
|
elif formatted_event:
|
|
459
496
|
replay_attempted += 1
|
|
460
497
|
try:
|
|
@@ -465,7 +502,6 @@ class NotificationForwarder(object):
|
|
|
465
502
|
error_message = str(e)
|
|
466
503
|
if success:
|
|
467
504
|
self.spool_store.delete(id)
|
|
468
|
-
progress = True
|
|
469
505
|
replayed += 1
|
|
470
506
|
logger.info("delete spooled event", {
|
|
471
507
|
'spooled_count': 1,
|
|
@@ -481,6 +517,8 @@ class NotificationForwarder(object):
|
|
|
481
517
|
if error_message:
|
|
482
518
|
context['exception'] = error_message
|
|
483
519
|
logger.critical("event stays in spool", context)
|
|
520
|
+
self.flush_stopped_at = id
|
|
521
|
+
break
|
|
484
522
|
else:
|
|
485
523
|
deleted_trash += 1
|
|
486
524
|
logger.critical("could not format spooled event", {
|
|
@@ -490,12 +528,11 @@ class NotificationForwarder(object):
|
|
|
490
528
|
'action': 'could_not_format'
|
|
491
529
|
})
|
|
492
530
|
self.spool_store.delete(id)
|
|
493
|
-
progress = True
|
|
494
531
|
logger.info("delete trash event", {
|
|
495
532
|
'event_id': id,
|
|
496
533
|
'action': 'delete_trash'
|
|
497
534
|
})
|
|
498
|
-
if not
|
|
535
|
+
if self.flush_stopped_at is not None:
|
|
499
536
|
# only the rows this flush was responsible for, not
|
|
500
537
|
# the ones spooled concurrently after the snapshot
|
|
501
538
|
logger.critical("spooled events could not be submitted", {
|
|
@@ -121,6 +121,8 @@ class JsonLogger(NotificationLogger):
|
|
|
121
121
|
msg_data["split_count"] = context['split_count']
|
|
122
122
|
if 'event_id' in context:
|
|
123
123
|
msg_data["event_id"] = context['event_id']
|
|
124
|
+
if 'new_event_id' in context:
|
|
125
|
+
msg_data["new_event_id"] = context['new_event_id']
|
|
124
126
|
|
|
125
127
|
# Add action type
|
|
126
128
|
if 'action' in context:
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/spool.py
RENAMED
|
@@ -80,6 +80,23 @@ class SpoolStore:
|
|
|
80
80
|
self.cursor.execute("DELETE FROM " + self.table_name + " WHERE id = ?", (event_id,))
|
|
81
81
|
self.connection.commit()
|
|
82
82
|
|
|
83
|
+
def move_to_end(self, event_id):
|
|
84
|
+
# re-insert the row behind all others, keeping payload and timestamp,
|
|
85
|
+
# so it still expires on schedule. a row which is gone or already the
|
|
86
|
+
# last one (this includes a reused id) is left alone.
|
|
87
|
+
sql_copy = ("INSERT INTO " + self.table_name + "(payload, timestamp)"
|
|
88
|
+
" SELECT payload, timestamp FROM " + self.table_name +
|
|
89
|
+
" WHERE id = ? AND id < (SELECT MAX(id) FROM " + self.table_name + ")")
|
|
90
|
+
with self._lock:
|
|
91
|
+
self.cursor.execute(sql_copy, (event_id,))
|
|
92
|
+
if self.cursor.rowcount != 1:
|
|
93
|
+
self.connection.commit()
|
|
94
|
+
return None
|
|
95
|
+
new_id = self.cursor.lastrowid
|
|
96
|
+
self.cursor.execute("DELETE FROM " + self.table_name + " WHERE id = ?", (event_id,))
|
|
97
|
+
self.connection.commit()
|
|
98
|
+
return new_id
|
|
99
|
+
|
|
83
100
|
def decode(self, text):
|
|
84
101
|
return json.loads(text)
|
|
85
102
|
|
|
@@ -93,6 +93,11 @@ class TextLogger(NotificationLogger):
|
|
|
93
93
|
if 'split_error' in context and 'raw_event' in context:
|
|
94
94
|
return f"error split_events failed for {context['raw_event']}"
|
|
95
95
|
|
|
96
|
+
# Handle a rejected spooled event which was moved behind the others
|
|
97
|
+
if context.get('action') == 'moved_to_end':
|
|
98
|
+
return (f"event {context.get('event_id', '')} was rejected while the receiver accepts "
|
|
99
|
+
f"other events and moved to the end of the spool as {context.get('new_event_id', '')}")
|
|
100
|
+
|
|
96
101
|
# Handle spooled count messages
|
|
97
102
|
if 'spooled_count' in context and 'action' in context:
|
|
98
103
|
action = context['action']
|
|
@@ -167,13 +167,32 @@ Integration tests for `load_forwarder()` using the `pythonpath/lib/python` test
|
|
|
167
167
|
| Test | What it verifies | Intention |
|
|
168
168
|
|------|------------------|-----------|
|
|
169
169
|
| `test_discarded_event_deleted_with_trash_log` | Spooled event returning a discarded `FormattedEvent` during replay is skipped and deleted, not submitted | The flush loop checks `is_discarded` and treats discarded events as trash. |
|
|
170
|
-
| `test_persistent_failure_keeps_row_and_terminates` | `submit()` always returning `False` → event stays in spool, stuck detection fires | Persistent failures are not silently dropped; the system logs and stops
|
|
170
|
+
| `test_persistent_failure_keeps_row_and_terminates` | `submit()` always returning `False` → event stays in spool, stuck detection fires | Persistent failures are not silently dropped; the system logs and stops at the first failed replay. |
|
|
171
171
|
| `test_spool_init_failure_returns_false` | `SpoolStore.enqueue` raising `sqlite3.OperationalError` → returns `False` with "database error" log | Database errors are caught and logged, not raised. |
|
|
172
|
-
| `test_flush_beyond_batch_limit_stuck_detection` | 12 spooled events (exceeds batch limit of 10)
|
|
173
|
-
| `test_flush_terminates_while_other_processes_spool` | All submits fail while a second connection spools a new event after each submit → flush stops
|
|
172
|
+
| `test_flush_beyond_batch_limit_stuck_detection` | 12 spooled events (exceeds batch limit of 10), the first 11 replays succeed and the 12th fails → the flush continues into the second batch, stops at the 12th event, summary attempted=12, recovered=11, stays_in_spool=1 | Batch boundaries do not end a flush; only a failed replay does, and the counters stay accurate. |
|
|
173
|
+
| `test_flush_terminates_while_other_processes_spool` | All submits fail while a second connection spools a new event after each submit → flush stops at the first failed submit, only event 0 submitted, all 13 rows kept, stuck log reports `spooled_count` 12 (snapshot rows, not the live queue) | A flush is bounded to the rows present at its start; concurrent spooling cannot keep it (and the flush lock) alive. |
|
|
174
174
|
| `test_formatter_returns_none_during_replay_deletes_trash` | `format_event` returning `None` during replay → event deleted as trash | Un-formattable events are cleaned up, not retried forever. |
|
|
175
175
|
| `test_summary_logging_resumes_after_flush` | `baseclass_logs_summary` is reset to `True` at flush start | The no_more_logging flag is reset so operators see summary logs after recovery. |
|
|
176
176
|
|
|
177
|
+
### class TestFlushStopAndMoveToEnd
|
|
178
|
+
|
|
179
|
+
Spooled events 0, 1, 2 (ids 1, 2, 3) are replayed through a mocked `submit()`; the live event is forwarded with `forward()`.
|
|
180
|
+
|
|
181
|
+
| Test | What it verifies | Intention |
|
|
182
|
+
|------|------------------|-----------|
|
|
183
|
+
| `test_flush_stops_at_first_failed_replay` | All replays fail → only event 0 submitted, all three stay, `flush_stopped_at` = 1, critical "event stays in spool" and "spooled events could not be submitted" | During an outage a flush costs one submit timeout, not one per spooled event. |
|
|
184
|
+
| `test_flush_stops_after_successful_replays` | Event 0 succeeds, event 1 fails → 0 removed, 2 not submitted, 1 and 2 stay | The flush stops at the first failure, also after successful replays. |
|
|
185
|
+
| `test_flush_stops_on_submit_timeout` | `submit()` raises `ForwarderTimeoutError` → only event 0 submitted | A timeout counts as a failed replay. |
|
|
186
|
+
| `test_flush_continues_after_trash` | Event 0 discarded during replay, event 1 succeeds → spool empty, `flush_stopped_at` is `None` | Removed trash does not stop the flush. |
|
|
187
|
+
| `test_rejected_event_moves_to_end` | Event 0 rejected, live event accepted → spool order 1, 2, 0; the moved row (id 4) keeps the original timestamp; critical "spooled event moved to the end of the spool" with `event_id` 1, `new_event_id` 4; the next forward delivers 1 and 2 and does not move 0 again (now last) | A permanently rejected event cannot block the backlog. |
|
|
188
|
+
| `test_no_move_when_live_submit_fails` | All submits fail → spool 0, 1, 2, live; no move | During an outage the order of the backlog stays as it is. |
|
|
189
|
+
| `test_no_move_without_live_submit` | `forward_formatted(None)` → flush stops at 0, nothing submitted afterwards, no move | Only a successful submit proves that the receiver is up. |
|
|
190
|
+
| `test_heartbeat_success_moves_rejected_event` | Live event is a heartbeat and is accepted → spool order 1, 2, 0 | Heartbeats also prove that the receiver is up. |
|
|
191
|
+
| `test_no_move_while_another_process_flushes` | A child process takes the flush lock while the live event is submitted → no move, debug "missed the flush lock, rejected event stays in place"; after the lock is released the next forward moves 0 | A running flush may hold the row in memory, so the move happens only under the flush lock. |
|
|
192
|
+
| `test_no_move_when_event_is_gone` | Another connection deletes event 0 while the live event is submitted → spool 1, 2, no move, no duplicate | A row delivered by another process is not copied back. |
|
|
193
|
+
| `test_no_move_when_event_is_already_last` | The rejected event is the only one → stays with id 1, no move log | Moving the last event would change nothing. |
|
|
194
|
+
| `test_moved_event_still_expires` | Event 0 spooled 4 minutes ago is moved, then `prune_expired(3)` drops it | Retention is measured from the original spool time. |
|
|
195
|
+
|
|
177
196
|
### class TestSpoolStore
|
|
178
197
|
|
|
179
198
|
Low-level `SpoolStore` operations against a real SQLite database.
|
|
@@ -191,7 +210,11 @@ Low-level `SpoolStore` operations against a real SQLite database.
|
|
|
191
210
|
| `test_enqueue_non_serializable_data` | Enqueuing a `set` raises `TypeError` | Non-JSON-serializable data is rejected. |
|
|
192
211
|
| `test_decode_round_trip` | JSON encode → decode produces the original dict | Serialization preserves data. |
|
|
193
212
|
| `test_concurrent_enqueue` | Two threads each enqueuing 10 events concurrently → 20 rows, no corruption | Thread safety of concurrent writes. |
|
|
194
|
-
| `test_concurrent_enqueue_shared_instance` |
|
|
213
|
+
| `test_concurrent_enqueue_shared_instance` | Two threads each enqueue 10 events on one shared SpoolStore instance → 20 rows, no errors | The per-instance thread lock serializes access to the shared cursor; without it the test fails or crashes the interpreter. |
|
|
214
|
+
| `test_move_to_end_keeps_payload_and_timestamp` | `move_to_end(1)` with 3 rows → returns 4, order (2, 1), (3, 2), (4, 0), timestamp of the moved row unchanged | The moved row still expires on schedule. |
|
|
215
|
+
| `test_move_to_end_skips_last_and_missing_rows` | `move_to_end()` of the last row or a missing id → `None`, rows unchanged | A reused id is always the last one, so it is never moved. |
|
|
216
|
+
| `test_move_to_end_does_not_hold_write_lock` | After a move and after a skipped move, a second connection with a 0.1 s busy timeout can insert | The move commits, other processes can keep spooling. |
|
|
217
|
+
| `test_concurrent_mixed_operations_shared_instance` | One thread enqueues 200 events while another fetches (by `after_id`), counts and deletes every other row on the same instance → no errors, fetched rows in id order and never twice, every event either fetched or still spooled, `count()` = enqueued − deleted | Mixed operations on a shared instance stay consistent. SQLite reuses the id of a deleted last row, so the test does not assume ids grow monotonically. |
|
|
195
218
|
|
|
196
219
|
---
|
|
197
220
|
|
|
@@ -371,6 +394,7 @@ Smoke tests for every logging call path in `baseclass.py`. Each test calls a spe
|
|
|
371
394
|
| `test_concurrent_flush_suppressed` | `info("concurrent flush suppressed", ...)` renders | Concurrent flush suppression path is covered. |
|
|
372
395
|
| `test_spooled_events_to_be_resent` | `info("spooled events to be re-sent", ...)` renders | Spool replay initiation path is covered. |
|
|
373
396
|
| `test_spooled_events_could_not_be_submitted` | `critical("spooled events could not be submitted", ...)` renders | Persistent submission failure path is covered. |
|
|
397
|
+
| `test_spooled_event_moved_to_end` | `action: moved_to_end` renders "event 7 was rejected while the receiver accepts other events and moved to the end of the spool as 31" | The text log names the old and the new id of a moved event. |
|
|
374
398
|
| `test_delivery_failed_could_not_persist` | `critical("delivery failed and event could not be persisted", ...)` renders | Unrecoverable failure path is covered. |
|
|
375
399
|
| `test_formatted_event_incomplete` | `critical("formatted event incomplete", ...)` renders | Incomplete event path is covered. |
|
|
376
400
|
|
|
@@ -379,6 +403,7 @@ Smoke tests for every logging call path in `baseclass.py`. Each test calls a spe
|
|
|
379
403
|
| Test | What it verifies | Intention |
|
|
380
404
|
|------|------------------|-----------|
|
|
381
405
|
| `test_success_structured_fields` | JsonLogger `info("forwarded", ...)` with event, status, forwarder_name, split_count renders structured JSON | Success path JSON structure is correct. |
|
|
406
|
+
| `test_moved_to_end_structured_fields` | JsonLogger output of a moved event contains `event_id`, `new_event_id` and `action` | Both ids reach the JSON log. |
|
|
382
407
|
| `test_failure_structured_fields` | JsonLogger `critical("forward failed", ...)` with exception, event, spooled, forwarder_name, attempt renders structured JSON | Failure path JSON structure is correct. |
|
|
383
408
|
|
|
384
409
|
---
|
|
@@ -503,6 +528,7 @@ Smoke tests for every logging call path in `baseclass.py`. Each test calls a spe
|
|
|
503
528
|
| `test_flush_replays_spooled_events_on_recovery` | New forwarder (no `fail`) triggers flush → spooled events replayed and deleted | Recovery path: flush + replay. |
|
|
504
529
|
| `test_flush_drops_expired_spooled_events` | Spooled events older than `max_spool_minutes` are dropped during flush | Expired events are cleaned up. |
|
|
505
530
|
| `test_flush_logs_when_concurrent_lock_is_unavailable` | Flush lock not acquired → "concurrent flush suppressed" log | Concurrent flush prevention works. |
|
|
531
|
+
| `test_flush_is_suppressed_while_another_process_holds_the_lock` | A child process holds the real fcntl flush lock → flush tries 3 times, logs "concurrent flush suppressed", spooled event stays; after the child releases the lock the next flush replays it | The cross-process flush lock and its retry work against a real competing process. |
|
|
506
532
|
| `test_timeout_decorator_raises_forwarder_timeout` | `@timeout` decorator raises `ForwarderTimeoutError` on timeout | Timeout enforcement works. |
|
|
507
533
|
| `test_timeout_decorator_preserves_underlying_exception` | Non-timeout exceptions propagate normally through `@timeout` | Timeout doesn't swallow other exceptions. |
|
|
508
534
|
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_delivery_resilience.py
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
|
+
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
import time
|
|
6
7
|
|
|
@@ -131,6 +132,40 @@ def test_flush_logs_when_concurrent_lock_is_unavailable(resilience_setup, monkey
|
|
|
131
132
|
assert "DEBUG - missed the flush lock" in log
|
|
132
133
|
|
|
133
134
|
|
|
135
|
+
def test_flush_is_suppressed_while_another_process_holds_the_lock(resilience_setup):
|
|
136
|
+
forwarder = new_example_forwarder(fail="1")
|
|
137
|
+
forwarder.forward({"description": "spool me", "signature": "sig-locked"})
|
|
138
|
+
assert forwarder.num_spooled_events() == 1
|
|
139
|
+
|
|
140
|
+
# fcntl locks are per process, so the competing flush must be a real process
|
|
141
|
+
holder = subprocess.Popen(
|
|
142
|
+
[sys.executable, "-c",
|
|
143
|
+
"import fcntl, sys\n"
|
|
144
|
+
"f = open(sys.argv[1], 'w')\n"
|
|
145
|
+
"fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
|
|
146
|
+
"print('locked', flush=True)\n"
|
|
147
|
+
"sys.stdin.read()\n",
|
|
148
|
+
forwarder.db_lock_file],
|
|
149
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
|
150
|
+
try:
|
|
151
|
+
assert holder.stdout.readline().strip() == "locked"
|
|
152
|
+
recovering_forwarder = new_example_forwarder()
|
|
153
|
+
recovering_forwarder.flush()
|
|
154
|
+
assert recovering_forwarder.num_spooled_events() == 1
|
|
155
|
+
log = open(get_logfile(recovering_forwarder)).read()
|
|
156
|
+
assert log.count("DEBUG - flush lock failed") == 3
|
|
157
|
+
assert "INFO - concurrent flush suppressed" in log
|
|
158
|
+
finally:
|
|
159
|
+
holder.stdin.close()
|
|
160
|
+
holder.wait(timeout=10)
|
|
161
|
+
|
|
162
|
+
# once the other process has released the lock, the next flush replays the event
|
|
163
|
+
recovering_forwarder.flush()
|
|
164
|
+
assert recovering_forwarder.num_spooled_events() == 0
|
|
165
|
+
signatures = [line.strip() for line in open(recovering_forwarder.signaturefile).readlines()]
|
|
166
|
+
assert signatures == ["sig-locked"]
|
|
167
|
+
|
|
168
|
+
|
|
134
169
|
def test_timeout_decorator_raises_forwarder_timeout():
|
|
135
170
|
@timeout(0.01, error_message="submit ran into a timeout")
|
|
136
171
|
def slow_call():
|
|
@@ -417,6 +417,20 @@ class TestTextLoggerMessageBranches:
|
|
|
417
417
|
finally:
|
|
418
418
|
pl.removeHandler(h)
|
|
419
419
|
|
|
420
|
+
def test_spooled_event_moved_to_end(self, setup):
|
|
421
|
+
"""'spooled event moved to the end of the spool' names the old and new id."""
|
|
422
|
+
logger, pl, h = self._make_logger("test_moved_to_end")
|
|
423
|
+
try:
|
|
424
|
+
message = logger._build_message("spooled event moved to the end of the spool", {
|
|
425
|
+
'event_id': 7,
|
|
426
|
+
'new_event_id': 31,
|
|
427
|
+
'action': 'moved_to_end',
|
|
428
|
+
})
|
|
429
|
+
finally:
|
|
430
|
+
pl.removeHandler(h)
|
|
431
|
+
assert message == ("event 7 was rejected while the receiver accepts other events "
|
|
432
|
+
"and moved to the end of the spool as 31")
|
|
433
|
+
|
|
420
434
|
def test_delivery_failed_could_not_persist(self, setup):
|
|
421
435
|
"""'delivery failed and event could not be persisted' message."""
|
|
422
436
|
logger, pl, h = self._make_logger("test_could_not_persist")
|
|
@@ -473,6 +487,29 @@ class TestJsonLoggerStructuredFields:
|
|
|
473
487
|
finally:
|
|
474
488
|
pl.removeHandler(h)
|
|
475
489
|
|
|
490
|
+
def test_moved_to_end_structured_fields(self, setup):
|
|
491
|
+
"""JSON output of a moved event includes both ids and the action."""
|
|
492
|
+
import io
|
|
493
|
+
import json as json_mod
|
|
494
|
+
logger, pl, h = self._make_logger("test_json_moved_to_end")
|
|
495
|
+
stream = io.StringIO()
|
|
496
|
+
capture = logging.StreamHandler(stream)
|
|
497
|
+
pl.addHandler(capture)
|
|
498
|
+
try:
|
|
499
|
+
logger.critical("spooled event moved to the end of the spool", {
|
|
500
|
+
'event_id': 7,
|
|
501
|
+
'new_event_id': 31,
|
|
502
|
+
'action': 'moved_to_end',
|
|
503
|
+
})
|
|
504
|
+
finally:
|
|
505
|
+
pl.removeHandler(capture)
|
|
506
|
+
pl.removeHandler(h)
|
|
507
|
+
entry = json_mod.loads(stream.getvalue().strip().splitlines()[-1])
|
|
508
|
+
text = json_mod.dumps(entry)
|
|
509
|
+
assert '"event_id": 7' in text
|
|
510
|
+
assert '"new_event_id": 31' in text
|
|
511
|
+
assert '"action": "moved_to_end"' in text
|
|
512
|
+
|
|
476
513
|
def test_failure_structured_fields(self, setup):
|
|
477
514
|
"""JSON failure path includes spool and exception fields."""
|
|
478
515
|
import json as json_mod
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_resilience_spool.py
RENAMED
|
@@ -546,7 +546,7 @@ class TestSpoolReplayEdges:
|
|
|
546
546
|
assert any("database error" in str(c) for c in critical_calls)
|
|
547
547
|
|
|
548
548
|
def test_flush_beyond_batch_limit_stuck_detection(self, setup, monkeypatch):
|
|
549
|
-
"""Flush
|
|
549
|
+
"""Flush continues into the next batch and stops at the first failed replay."""
|
|
550
550
|
forwarder = _new_forwarder(
|
|
551
551
|
"example",
|
|
552
552
|
forwarder_name="webhook",
|
|
@@ -559,11 +559,14 @@ class TestSpoolReplayEdges:
|
|
|
559
559
|
|
|
560
560
|
assert forwarder.num_spooled_events() == 12
|
|
561
561
|
|
|
562
|
-
#
|
|
563
|
-
|
|
564
|
-
return False
|
|
562
|
+
# the first 11 replays succeed, the 12th (in the second batch) fails
|
|
563
|
+
submitted = []
|
|
565
564
|
|
|
566
|
-
|
|
565
|
+
def fail_twelfth(formatted_event):
|
|
566
|
+
submitted.append(formatted_event.eventopts["event_num"])
|
|
567
|
+
return formatted_event.eventopts["event_num"] != 11
|
|
568
|
+
|
|
569
|
+
monkeypatch.setattr(forwarder, "submit", fail_twelfth)
|
|
567
570
|
|
|
568
571
|
def format_ok(raw):
|
|
569
572
|
fe = _formatted_event(raw)
|
|
@@ -579,8 +582,8 @@ class TestSpoolReplayEdges:
|
|
|
579
582
|
original_logger = baseclass.logger
|
|
580
583
|
|
|
581
584
|
class MockLogger:
|
|
582
|
-
def info(self, msg, *args, **kwargs):
|
|
583
|
-
info_calls.append(msg)
|
|
585
|
+
def info(self, msg, context=None, *args, **kwargs):
|
|
586
|
+
info_calls.append((msg, context))
|
|
584
587
|
def critical(self, msg, *args, **kwargs):
|
|
585
588
|
critical_calls.append(msg)
|
|
586
589
|
def warning(self, msg, *args, **kwargs):
|
|
@@ -594,14 +597,19 @@ class TestSpoolReplayEdges:
|
|
|
594
597
|
finally:
|
|
595
598
|
baseclass.logger = original_logger
|
|
596
599
|
|
|
597
|
-
#
|
|
598
|
-
assert
|
|
599
|
-
|
|
600
|
-
|
|
600
|
+
# the flush went on into the second batch and stopped at the 12th event
|
|
601
|
+
assert submitted == list(range(12))
|
|
602
|
+
assert forwarder.num_spooled_events() == 1
|
|
603
|
+
assert forwarder.flush_stopped_at == 12
|
|
601
604
|
assert any("could not be submitted" in str(c) for c in critical_calls)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
+
summary = [ctx for msg, ctx in info_calls if msg == "spool replay summary"]
|
|
606
|
+
assert summary == [{
|
|
607
|
+
'attempted': 12,
|
|
608
|
+
'recovered_count': 11,
|
|
609
|
+
'stays_in_spool_count': 1,
|
|
610
|
+
'deleted_trash_count': 0,
|
|
611
|
+
'dropped_count': 0,
|
|
612
|
+
}]
|
|
605
613
|
|
|
606
614
|
def test_flush_terminates_while_other_processes_spool(self, setup, monkeypatch):
|
|
607
615
|
"""Flush must not loop forever when concurrent processes keep spooling.
|
|
@@ -663,10 +671,10 @@ class TestSpoolReplayEdges:
|
|
|
663
671
|
baseclass.logger = original_logger
|
|
664
672
|
other_process.close()
|
|
665
673
|
|
|
666
|
-
#
|
|
667
|
-
assert submitted ==
|
|
668
|
-
# nothing lost: 12 original +
|
|
669
|
-
assert forwarder.num_spooled_events() ==
|
|
674
|
+
# the first failed submit ends the flush
|
|
675
|
+
assert submitted == [0]
|
|
676
|
+
# nothing lost: 12 original + 1 spooled concurrently
|
|
677
|
+
assert forwarder.num_spooled_events() == 13
|
|
670
678
|
# the stuck log counts only the rows of this flush's snapshot
|
|
671
679
|
stuck = [ctx for msg, ctx in critical_calls if msg == "spooled events could not be submitted"]
|
|
672
680
|
assert stuck == [{'spooled_count': 12, 'action': 'could_not_submit'}]
|
|
@@ -743,6 +751,256 @@ class TestSpoolReplayEdges:
|
|
|
743
751
|
assert forwarder.baseclass_logs_summary is True
|
|
744
752
|
|
|
745
753
|
|
|
754
|
+
# ============================================================================
|
|
755
|
+
# Flush stops at the first failure, rejected events move to the end
|
|
756
|
+
# ============================================================================
|
|
757
|
+
|
|
758
|
+
class _RecordingLogger:
|
|
759
|
+
"""Records (level, message, context) of every log call."""
|
|
760
|
+
def __init__(self):
|
|
761
|
+
self.calls = []
|
|
762
|
+
|
|
763
|
+
def __getattr__(self, level):
|
|
764
|
+
def log(msg, context=None, *args, **kwargs):
|
|
765
|
+
self.calls.append((level, msg, context))
|
|
766
|
+
return log
|
|
767
|
+
|
|
768
|
+
def messages(self, level):
|
|
769
|
+
return [msg for lvl, msg, _ctx in self.calls if lvl == level]
|
|
770
|
+
|
|
771
|
+
def moved(self):
|
|
772
|
+
return [ctx for _lvl, msg, ctx in self.calls if msg == "spooled event moved to the end of the spool"]
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class TestFlushStopAndMoveToEnd:
|
|
776
|
+
"""Spooled events are numbered 0, 1, 2, ... (ids 1, 2, 3, ...); the live event is "live"."""
|
|
777
|
+
|
|
778
|
+
LIVE = {"HOSTNAME": "h1", "event_num": "live"}
|
|
779
|
+
|
|
780
|
+
def _forwarder(self, monkeypatch, submit, num_spooled=3, heartbeat=False):
|
|
781
|
+
forwarder = _new_forwarder(
|
|
782
|
+
"example",
|
|
783
|
+
forwarder_name="webhook",
|
|
784
|
+
forwarder_opts={"url": "http://example.invalid/api"},
|
|
785
|
+
)
|
|
786
|
+
for i in range(num_spooled):
|
|
787
|
+
forwarder.spool_store.enqueue({"HOSTNAME": "h1", "event_num": i})
|
|
788
|
+
|
|
789
|
+
def format_ok(raw):
|
|
790
|
+
fe = _formatted_event(raw)
|
|
791
|
+
fe.payload = {"data": "test"}
|
|
792
|
+
fe.summary = "test"
|
|
793
|
+
if raw.get("event_num") == "live":
|
|
794
|
+
fe.is_heartbeat = heartbeat
|
|
795
|
+
return fe
|
|
796
|
+
|
|
797
|
+
monkeypatch.setattr(forwarder, "format_event", format_ok)
|
|
798
|
+
monkeypatch.setattr(forwarder, "submit", submit)
|
|
799
|
+
# new() has set the module logger, so record from here on
|
|
800
|
+
self.log = _RecordingLogger()
|
|
801
|
+
monkeypatch.setattr(baseclass, "logger", self.log)
|
|
802
|
+
return forwarder
|
|
803
|
+
|
|
804
|
+
def _spooled(self, forwarder):
|
|
805
|
+
rows = forwarder.spool_store.fetch_batch(limit=100)
|
|
806
|
+
return [forwarder.spool_store.decode(text)["event_num"] for _id, text in rows]
|
|
807
|
+
|
|
808
|
+
def _timestamp(self, forwarder, event_id):
|
|
809
|
+
forwarder.dbcurs.execute(
|
|
810
|
+
"SELECT timestamp FROM {table} WHERE id = ?".format(table=forwarder.table_name), (event_id,))
|
|
811
|
+
return forwarder.dbcurs.fetchone()[0]
|
|
812
|
+
|
|
813
|
+
@staticmethod
|
|
814
|
+
def _reject_first(formatted_event):
|
|
815
|
+
return formatted_event.eventopts["event_num"] != 0
|
|
816
|
+
|
|
817
|
+
def test_flush_stops_at_first_failed_replay(self, setup, monkeypatch):
|
|
818
|
+
submitted = []
|
|
819
|
+
|
|
820
|
+
def always_fail(formatted_event):
|
|
821
|
+
submitted.append(formatted_event.eventopts["event_num"])
|
|
822
|
+
return False
|
|
823
|
+
|
|
824
|
+
forwarder = self._forwarder(monkeypatch, always_fail)
|
|
825
|
+
forwarder.flush()
|
|
826
|
+
|
|
827
|
+
assert submitted == [0]
|
|
828
|
+
assert self._spooled(forwarder) == [0, 1, 2]
|
|
829
|
+
assert forwarder.flush_stopped_at == 1
|
|
830
|
+
assert self.log.messages("critical") == ["event stays in spool", "spooled events could not be submitted"]
|
|
831
|
+
|
|
832
|
+
def test_flush_stops_after_successful_replays(self, setup, monkeypatch):
|
|
833
|
+
submitted = []
|
|
834
|
+
|
|
835
|
+
def fail_second(formatted_event):
|
|
836
|
+
submitted.append(formatted_event.eventopts["event_num"])
|
|
837
|
+
return formatted_event.eventopts["event_num"] != 1
|
|
838
|
+
|
|
839
|
+
forwarder = self._forwarder(monkeypatch, fail_second)
|
|
840
|
+
forwarder.flush()
|
|
841
|
+
|
|
842
|
+
assert submitted == [0, 1]
|
|
843
|
+
assert self._spooled(forwarder) == [1, 2]
|
|
844
|
+
|
|
845
|
+
def test_flush_stops_on_submit_timeout(self, setup, monkeypatch):
|
|
846
|
+
submitted = []
|
|
847
|
+
|
|
848
|
+
def time_out(formatted_event):
|
|
849
|
+
submitted.append(formatted_event.eventopts["event_num"])
|
|
850
|
+
raise baseclass.ForwarderTimeoutError("submit ran into a timeout")
|
|
851
|
+
|
|
852
|
+
forwarder = self._forwarder(monkeypatch, time_out)
|
|
853
|
+
forwarder.flush()
|
|
854
|
+
|
|
855
|
+
assert submitted == [0]
|
|
856
|
+
assert self._spooled(forwarder) == [0, 1, 2]
|
|
857
|
+
|
|
858
|
+
def test_flush_continues_after_trash(self, setup, monkeypatch):
|
|
859
|
+
submitted = []
|
|
860
|
+
|
|
861
|
+
def succeed(formatted_event):
|
|
862
|
+
submitted.append(formatted_event.eventopts["event_num"])
|
|
863
|
+
return True
|
|
864
|
+
|
|
865
|
+
forwarder = self._forwarder(monkeypatch, succeed, num_spooled=2)
|
|
866
|
+
format_ok = forwarder.format_event
|
|
867
|
+
|
|
868
|
+
def discard_first(raw):
|
|
869
|
+
fe = format_ok(raw)
|
|
870
|
+
if raw.get("event_num") == 0:
|
|
871
|
+
fe.discard()
|
|
872
|
+
return fe
|
|
873
|
+
|
|
874
|
+
monkeypatch.setattr(forwarder, "format_event", discard_first)
|
|
875
|
+
forwarder.flush()
|
|
876
|
+
|
|
877
|
+
assert submitted == [1]
|
|
878
|
+
assert self._spooled(forwarder) == []
|
|
879
|
+
assert forwarder.flush_stopped_at is None
|
|
880
|
+
|
|
881
|
+
def test_rejected_event_moves_to_end(self, setup, monkeypatch):
|
|
882
|
+
forwarder = self._forwarder(monkeypatch, self._reject_first)
|
|
883
|
+
forwarder.dbcurs.execute(
|
|
884
|
+
"UPDATE {table} SET timestamp = datetime('now', '-1 minutes') WHERE id = 1".format(
|
|
885
|
+
table=forwarder.table_name))
|
|
886
|
+
forwarder.dbconn.commit()
|
|
887
|
+
original_timestamp = self._timestamp(forwarder, 1)
|
|
888
|
+
|
|
889
|
+
# the flush stops at 0, the live event is accepted -> 0 moves behind 1 and 2
|
|
890
|
+
forwarder.forward(dict(self.LIVE))
|
|
891
|
+
|
|
892
|
+
assert self._spooled(forwarder) == [1, 2, 0]
|
|
893
|
+
assert self._timestamp(forwarder, 4) == original_timestamp
|
|
894
|
+
assert self.log.moved() == [{'event_id': 1, 'new_event_id': 4, 'action': 'moved_to_end'}]
|
|
895
|
+
|
|
896
|
+
# the next flush delivers 1 and 2 and stops at 0 again, which is now last
|
|
897
|
+
forwarder.forward(dict(self.LIVE))
|
|
898
|
+
assert self._spooled(forwarder) == [0]
|
|
899
|
+
assert len(self.log.moved()) == 1
|
|
900
|
+
|
|
901
|
+
def test_no_move_when_live_submit_fails(self, setup, monkeypatch):
|
|
902
|
+
def always_fail(formatted_event):
|
|
903
|
+
return False
|
|
904
|
+
|
|
905
|
+
forwarder = self._forwarder(monkeypatch, always_fail)
|
|
906
|
+
forwarder.forward(dict(self.LIVE))
|
|
907
|
+
|
|
908
|
+
assert self._spooled(forwarder) == [0, 1, 2, "live"]
|
|
909
|
+
assert self.log.moved() == []
|
|
910
|
+
|
|
911
|
+
def test_no_move_without_live_submit(self, setup, monkeypatch):
|
|
912
|
+
forwarder = self._forwarder(monkeypatch, self._reject_first)
|
|
913
|
+
# forward_formatted() without an event: flush only, nothing submitted afterwards
|
|
914
|
+
forwarder.forward_formatted(None)
|
|
915
|
+
|
|
916
|
+
assert self._spooled(forwarder) == [0, 1, 2]
|
|
917
|
+
assert self.log.moved() == []
|
|
918
|
+
|
|
919
|
+
def test_heartbeat_success_moves_rejected_event(self, setup, monkeypatch):
|
|
920
|
+
live_heartbeats = []
|
|
921
|
+
|
|
922
|
+
def reject_first(formatted_event):
|
|
923
|
+
if formatted_event.eventopts["event_num"] == "live":
|
|
924
|
+
live_heartbeats.append(formatted_event.is_heartbeat)
|
|
925
|
+
return self._reject_first(formatted_event)
|
|
926
|
+
|
|
927
|
+
forwarder = self._forwarder(monkeypatch, reject_first, heartbeat=True)
|
|
928
|
+
forwarder.forward(dict(self.LIVE))
|
|
929
|
+
|
|
930
|
+
assert live_heartbeats == [True]
|
|
931
|
+
assert self._spooled(forwarder) == [1, 2, 0]
|
|
932
|
+
|
|
933
|
+
def test_no_move_while_another_process_flushes(self, setup, monkeypatch):
|
|
934
|
+
import subprocess
|
|
935
|
+
holder = []
|
|
936
|
+
|
|
937
|
+
def reject_first_and_lock(formatted_event):
|
|
938
|
+
if formatted_event.eventopts["event_num"] == "live" and not holder:
|
|
939
|
+
# another process takes the flush lock between the flush and the move
|
|
940
|
+
holder.append(subprocess.Popen(
|
|
941
|
+
[sys.executable, "-c",
|
|
942
|
+
"import fcntl, sys\n"
|
|
943
|
+
"f = open(sys.argv[1], 'w')\n"
|
|
944
|
+
"fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
|
|
945
|
+
"print('locked', flush=True)\n"
|
|
946
|
+
"sys.stdin.read()\n",
|
|
947
|
+
forwarder.db_lock_file],
|
|
948
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True))
|
|
949
|
+
assert holder[0].stdout.readline().strip() == "locked"
|
|
950
|
+
return self._reject_first(formatted_event)
|
|
951
|
+
|
|
952
|
+
forwarder = self._forwarder(monkeypatch, reject_first_and_lock)
|
|
953
|
+
try:
|
|
954
|
+
forwarder.forward(dict(self.LIVE))
|
|
955
|
+
assert self._spooled(forwarder) == [0, 1, 2]
|
|
956
|
+
assert self.log.moved() == []
|
|
957
|
+
assert "missed the flush lock, rejected event stays in place" in self.log.messages("debug")
|
|
958
|
+
finally:
|
|
959
|
+
holder[0].stdin.close()
|
|
960
|
+
holder[0].wait(timeout=10)
|
|
961
|
+
|
|
962
|
+
# the next flush stops at 0 again and, with the lock free, moves it
|
|
963
|
+
forwarder.forward(dict(self.LIVE))
|
|
964
|
+
assert self._spooled(forwarder) == [1, 2, 0]
|
|
965
|
+
|
|
966
|
+
def test_no_move_when_event_is_gone(self, setup, monkeypatch):
|
|
967
|
+
def reject_first_then_gone(formatted_event):
|
|
968
|
+
if formatted_event.eventopts["event_num"] == "live":
|
|
969
|
+
# another process delivered and removed event 0 meanwhile
|
|
970
|
+
other_process = SpoolStore(forwarder.db_file, forwarder.table_name)
|
|
971
|
+
other_process.open()
|
|
972
|
+
other_process.delete(1)
|
|
973
|
+
other_process.close()
|
|
974
|
+
return self._reject_first(formatted_event)
|
|
975
|
+
|
|
976
|
+
forwarder = self._forwarder(monkeypatch, reject_first_then_gone)
|
|
977
|
+
forwarder.forward(dict(self.LIVE))
|
|
978
|
+
|
|
979
|
+
assert self._spooled(forwarder) == [1, 2]
|
|
980
|
+
assert self.log.moved() == []
|
|
981
|
+
|
|
982
|
+
def test_no_move_when_event_is_already_last(self, setup, monkeypatch):
|
|
983
|
+
forwarder = self._forwarder(monkeypatch, self._reject_first, num_spooled=1)
|
|
984
|
+
forwarder.forward(dict(self.LIVE))
|
|
985
|
+
|
|
986
|
+
assert [event_id for event_id, _text in forwarder.spool_store.fetch_batch()] == [1]
|
|
987
|
+
assert self.log.moved() == []
|
|
988
|
+
|
|
989
|
+
def test_moved_event_still_expires(self, setup, monkeypatch):
|
|
990
|
+
forwarder = self._forwarder(monkeypatch, self._reject_first)
|
|
991
|
+
forwarder.dbcurs.execute(
|
|
992
|
+
"UPDATE {table} SET timestamp = datetime('now', '-4 minutes') WHERE id = 1".format(
|
|
993
|
+
table=forwarder.table_name))
|
|
994
|
+
forwarder.dbconn.commit()
|
|
995
|
+
|
|
996
|
+
forwarder.forward(dict(self.LIVE))
|
|
997
|
+
assert self._spooled(forwarder) == [1, 2, 0]
|
|
998
|
+
|
|
999
|
+
# a retention of 3 minutes, measured from the original spool time, drops 0
|
|
1000
|
+
assert forwarder.spool_store.prune_expired(3) == 1
|
|
1001
|
+
assert self._spooled(forwarder) == [1, 2]
|
|
1002
|
+
|
|
1003
|
+
|
|
746
1004
|
# ============================================================================
|
|
747
1005
|
# SpoolStore direct tests
|
|
748
1006
|
# ============================================================================
|
|
@@ -997,3 +1255,118 @@ class TestSpoolStore:
|
|
|
997
1255
|
assert errors == []
|
|
998
1256
|
assert store.count() == 20
|
|
999
1257
|
store.close()
|
|
1258
|
+
|
|
1259
|
+
def test_concurrent_mixed_operations_shared_instance(self, setup):
|
|
1260
|
+
"""One thread enqueues while another fetches, counts and deletes on the
|
|
1261
|
+
same SpoolStore instance. Fetches see only fully stored rows in id order,
|
|
1262
|
+
no row twice, and the final count matches enqueued minus deleted."""
|
|
1263
|
+
import threading
|
|
1264
|
+
|
|
1265
|
+
db_path = os.path.join(OMD_ROOT, "tmp", "test_spool_shared_mixed.db")
|
|
1266
|
+
store = SpoolStore(db_path, "test_events")
|
|
1267
|
+
store.open()
|
|
1268
|
+
store.init_db()
|
|
1269
|
+
|
|
1270
|
+
num_events = 200
|
|
1271
|
+
errors = []
|
|
1272
|
+
fetched = []
|
|
1273
|
+
deleted = []
|
|
1274
|
+
writer_done = threading.Event()
|
|
1275
|
+
|
|
1276
|
+
def writer():
|
|
1277
|
+
try:
|
|
1278
|
+
for i in range(num_events):
|
|
1279
|
+
store.enqueue({"seq": i})
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
errors.append(e)
|
|
1282
|
+
finally:
|
|
1283
|
+
writer_done.set()
|
|
1284
|
+
|
|
1285
|
+
def reader():
|
|
1286
|
+
try:
|
|
1287
|
+
last_id = 0
|
|
1288
|
+
while True:
|
|
1289
|
+
finished = writer_done.is_set()
|
|
1290
|
+
batch = store.fetch_batch(limit=10, after_id=last_id)
|
|
1291
|
+
store.count()
|
|
1292
|
+
store.max_id()
|
|
1293
|
+
for event_id, text in batch:
|
|
1294
|
+
fetched.append((event_id, store.decode(text)["seq"]))
|
|
1295
|
+
last_id = event_id
|
|
1296
|
+
# delete every other row, keep the rest
|
|
1297
|
+
if event_id % 2 == 0:
|
|
1298
|
+
store.delete(event_id)
|
|
1299
|
+
deleted.append(event_id)
|
|
1300
|
+
if finished and not batch:
|
|
1301
|
+
break
|
|
1302
|
+
except Exception as e:
|
|
1303
|
+
errors.append(e)
|
|
1304
|
+
|
|
1305
|
+
threads = [threading.Thread(target=writer), threading.Thread(target=reader)]
|
|
1306
|
+
for t in threads:
|
|
1307
|
+
t.start()
|
|
1308
|
+
for t in threads:
|
|
1309
|
+
t.join(timeout=30)
|
|
1310
|
+
assert not any(t.is_alive() for t in threads)
|
|
1311
|
+
|
|
1312
|
+
assert errors == []
|
|
1313
|
+
ids = [event_id for event_id, _seq in fetched]
|
|
1314
|
+
assert ids == sorted(set(ids))
|
|
1315
|
+
# without AUTOINCREMENT sqlite reuses the id of a deleted last row, so
|
|
1316
|
+
# an event can land behind the reader's after_id. It must then still
|
|
1317
|
+
# be in the spool: every event was either fetched once or is kept.
|
|
1318
|
+
fetched_seqs = [seq for _event_id, seq in fetched]
|
|
1319
|
+
assert fetched_seqs == sorted(set(fetched_seqs))
|
|
1320
|
+
remaining = [store.decode(text)["seq"] for _id, text in store.fetch_batch(limit=num_events)]
|
|
1321
|
+
kept_seqs = [seq for event_id, seq in fetched if event_id not in deleted]
|
|
1322
|
+
assert sorted(set(fetched_seqs) | set(remaining)) == list(range(num_events))
|
|
1323
|
+
assert set(kept_seqs) <= set(remaining)
|
|
1324
|
+
assert store.count() == num_events - len(deleted)
|
|
1325
|
+
store.close()
|
|
1326
|
+
|
|
1327
|
+
def _store_with_rows(self, name, num_rows):
|
|
1328
|
+
db_path = os.path.join(OMD_ROOT, "tmp", name)
|
|
1329
|
+
store = SpoolStore(db_path, "test_events")
|
|
1330
|
+
store.open()
|
|
1331
|
+
store.init_db()
|
|
1332
|
+
for i in range(num_rows):
|
|
1333
|
+
store.enqueue({"seq": i})
|
|
1334
|
+
return store
|
|
1335
|
+
|
|
1336
|
+
def test_move_to_end_keeps_payload_and_timestamp(self, setup):
|
|
1337
|
+
"""move_to_end() puts the row behind all others with its payload and timestamp."""
|
|
1338
|
+
store = self._store_with_rows("test_spool_move.db", 3)
|
|
1339
|
+
store.cursor.execute("UPDATE test_events SET timestamp = '2020-01-01 00:00:00' WHERE id = 1")
|
|
1340
|
+
store.connection.commit()
|
|
1341
|
+
|
|
1342
|
+
assert store.move_to_end(1) == 4
|
|
1343
|
+
rows = store.fetch_batch()
|
|
1344
|
+
assert [(event_id, store.decode(text)["seq"]) for event_id, text in rows] == [(2, 1), (3, 2), (4, 0)]
|
|
1345
|
+
store.cursor.execute("SELECT timestamp FROM test_events WHERE id = 4")
|
|
1346
|
+
assert store.cursor.fetchone()[0] == "2020-01-01 00:00:00"
|
|
1347
|
+
store.close()
|
|
1348
|
+
|
|
1349
|
+
def test_move_to_end_skips_last_and_missing_rows(self, setup):
|
|
1350
|
+
"""A row that is already last, or gone, is not moved."""
|
|
1351
|
+
store = self._store_with_rows("test_spool_move_skip.db", 2)
|
|
1352
|
+
|
|
1353
|
+
assert store.move_to_end(2) is None
|
|
1354
|
+
assert store.move_to_end(99) is None
|
|
1355
|
+
assert [event_id for event_id, _text in store.fetch_batch()] == [1, 2]
|
|
1356
|
+
store.close()
|
|
1357
|
+
|
|
1358
|
+
def test_move_to_end_does_not_hold_write_lock(self, setup):
|
|
1359
|
+
"""After move_to_end() and after a skipped move, other connections can insert."""
|
|
1360
|
+
store = self._store_with_rows("test_spool_move_lock.db", 2)
|
|
1361
|
+
other = sqlite3.connect(store.db_file, timeout=0.1)
|
|
1362
|
+
try:
|
|
1363
|
+
store.move_to_end(1)
|
|
1364
|
+
other.execute("INSERT INTO test_events(payload) VALUES ('{}')")
|
|
1365
|
+
other.commit()
|
|
1366
|
+
store.move_to_end(99)
|
|
1367
|
+
other.execute("INSERT INTO test_events(payload) VALUES ('{}')")
|
|
1368
|
+
other.commit()
|
|
1369
|
+
finally:
|
|
1370
|
+
other.close()
|
|
1371
|
+
assert store.count() == 4
|
|
1372
|
+
store.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/util.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_builtin_plugins_focus.py
RENAMED
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_formatters.py
RENAMED
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_forwarders.py
RENAMED
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_reporters.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_loading_resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_orchestration_flow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_runtime_foundation.py
RENAMED
|
File without changes
|
|
File without changes
|