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.
Files changed (73) hide show
  1. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/PKG-INFO +15 -2
  2. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/README.md +14 -1
  3. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/bin/notificationforwarder +1 -1
  4. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/pyproject.toml +1 -1
  5. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/baseclass.py +44 -7
  6. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/logger.py +2 -0
  7. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/spool.py +17 -0
  8. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/logger.py +5 -0
  9. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/TESTS.md +30 -4
  10. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_delivery_resilience.py +35 -0
  11. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_loggers.py +37 -0
  12. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_resilience_spool.py +391 -18
  13. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/.gitignore +0 -0
  14. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/component_loader.py +0 -0
  15. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/formatter.py +0 -0
  16. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/forwarder.py +0 -0
  17. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/formatter.py +0 -0
  18. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/forwarder.py +0 -0
  19. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/__init__.py +0 -0
  20. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/naemonlog/reporter.py +0 -0
  21. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/__init__.py +0 -0
  22. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/formatter.py +0 -0
  23. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/forwarder.py +0 -0
  24. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
  25. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
  26. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
  27. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
  28. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_config.py +0 -0
  29. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_flow.py +0 -0
  30. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/formatter.py +0 -0
  31. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/forwarder.py +0 -0
  32. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/telegram/forwarder.py +0 -0
  33. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/__init__.py +0 -0
  34. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/util.py +0 -0
  35. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/src/notificationforwarder/webhook/forwarder.py +0 -0
  36. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
  37. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
  38. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  39. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
  40. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  41. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
  42. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
  43. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
  44. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
  45. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
  46. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
  47. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  48. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
  49. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  50. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
  51. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
  52. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
  53. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
  54. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
  55. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_alertmanager.py +0 -0
  56. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_builtin_plugins_focus.py +0 -0
  57. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_classes.py +0 -0
  58. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_formatters.py +0 -0
  59. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_forwarders.py +0 -0
  60. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_contracts_reporters.py +0 -0
  61. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_discard.py +0 -0
  62. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_formatter.py +0 -0
  63. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_integration_cli.py +0 -0
  64. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_loading_resolution.py +0 -0
  65. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_logger.py +0 -0
  66. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_nostr.py +0 -0
  67. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_oauth2webhook.py +0 -0
  68. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_orchestration_flow.py +0 -0
  69. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_package.py +0 -0
  70. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_paths.py +0 -0
  71. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_reporter.py +0 -0
  72. {omdnotificationforwarder-4.5 → omdnotificationforwarder-4.6}/tests/test_runtime_foundation.py +0 -0
  73. {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.5
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. Events that still fail stay in the queue for the next attempt.
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. Events that still fail stay in the queue for the next attempt.
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.5')
81
+ version=f'%(prog)s 4.6')
82
82
 
83
83
  args = parser.parse_args()
84
84
  if not hasattr(args, 'formatter'):
@@ -21,7 +21,7 @@ packages = ["src/notificationforwarder"]
21
21
 
22
22
  [project]
23
23
  name = "omdnotificationforwarder"
24
- version = "4.5"
24
+ version = "4.6"
25
25
  authors = [
26
26
  { name="Gerhard Lausser", email="lausser@yahoo.com" },
27
27
  ]
@@ -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, so the flush lock
433
- # is not held forever while the destination keeps failing.
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 progress:
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:
@@ -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 retrying. |
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) with all submits failing → stuck detection, all events stay | The stuck detection logic handles batch boundaries correctly. |
173
- | `test_flush_terminates_while_other_processes_spool` | All submits fail while a second connection spools a new event after each submit → flush stops after the first batch, events 0–9 submitted once each, all 22 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. |
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` | Shared SpoolStore instance across threads documents SQLite cursor limitation | SQLite doesn't allow recursive cursor use; the real forwarder uses file locks. |
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
 
@@ -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
@@ -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 with more than 10 events triggers stuck detection correctly."""
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
- # All submits fail to trigger stuck detection
563
- def always_fail(formatted_event):
564
- return False
562
+ # the first 11 replays succeed, the 12th (in the second batch) fails
563
+ submitted = []
565
564
 
566
- monkeypatch.setattr(forwarder, "submit", always_fail)
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
- # All 12 events should still be in spool (all failed)
598
- assert forwarder.num_spooled_events() == 12
599
-
600
- # Stuck detection should have triggered after the first batch (10 fetched, none replayed)
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
- # spool replay summary should be logged
604
- assert any("spool replay summary" in str(c) for c in info_calls)
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
- # one batch failed completely -> stop, every event tried at most once
667
- assert submitted == list(range(10))
668
- # nothing lost: 12 original + 10 spooled concurrently
669
- assert forwarder.num_spooled_events() == 22
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()