test_usage_details_archiver.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """Pure unit tests for ``UsageDetailsArchiver``.
  2. DB-level (sqlite/postgres) integration is intentionally out of scope here.
  3. This file covers everything that doesn't touch the database:
  4. * calendar arithmetic for the retention cutoff
  5. * hot ↔ archive table shape alignment (required by the bulk SQL path)
  6. * cron expression validation + next-fire computation
  7. """
  8. from datetime import datetime
  9. import pytest
  10. from gpustack.schemas.model_usage_details import (
  11. ModelUsageDetails,
  12. ModelUsageDetailsArchive,
  13. )
  14. from gpustack.server.usage_details_archiver import (
  15. UsageDetailsArchiver,
  16. _assert_archive_shape_aligned,
  17. _months_ago,
  18. )
  19. # ---------------------------------------------------------------------------
  20. # _months_ago — calendar arithmetic, not timedelta(days=30*N)
  21. # ---------------------------------------------------------------------------
  22. def test_months_ago_simple_subtraction():
  23. assert _months_ago(datetime(2026, 5, 7, 12, 0), 13) == datetime(2025, 4, 7, 12, 0)
  24. def test_months_ago_clamps_short_target_month():
  25. # 3/31 - 1 month must clamp to 2/28 (or 2/29 in leap years), not overflow to 3/3.
  26. assert _months_ago(datetime(2026, 3, 31, 12, 0), 1) == datetime(2026, 2, 28, 12, 0)
  27. def test_months_ago_handles_leap_year():
  28. assert _months_ago(datetime(2024, 3, 31), 1) == datetime(2024, 2, 29)
  29. def test_months_ago_wraps_year_boundary():
  30. assert _months_ago(datetime(2026, 1, 15, 12, 0), 2) == datetime(2025, 11, 15, 12, 0)
  31. def test_months_ago_wraps_year_boundary_multi():
  32. # 25 months back crosses two year boundaries.
  33. assert _months_ago(datetime(2026, 5, 7), 25) == datetime(2024, 4, 7)
  34. def test_months_ago_preserves_time_of_day():
  35. src = datetime(2026, 5, 7, 13, 45, 30, 123456)
  36. out = _months_ago(src, 13)
  37. assert (out.hour, out.minute, out.second, out.microsecond) == (13, 45, 30, 123456)
  38. # ---------------------------------------------------------------------------
  39. # Hot ↔ archive table shape alignment
  40. # (bulk INSERT ... SELECT positionally requires identical column lists)
  41. # ---------------------------------------------------------------------------
  42. def test_archive_shape_alignment_passes_for_current_schemas():
  43. # Should not raise — this is the runtime contract the archiver depends on.
  44. _assert_archive_shape_aligned()
  45. def test_archive_shape_alignment_includes_all_business_columns():
  46. """Belt-and-suspenders against silent column loss — call out the columns
  47. we expect on both sides explicitly so a future schema change can't strip
  48. them and still pass ``_assert_archive_shape_aligned``."""
  49. expected = {
  50. "id",
  51. "user_id",
  52. "user_name",
  53. "model_id",
  54. "model_name",
  55. "model_route_id",
  56. "model_route_name",
  57. "provider_id",
  58. "provider_name",
  59. "provider_type",
  60. "cluster_id",
  61. "cluster_name",
  62. "api_key_id",
  63. "api_key_name",
  64. "access_key",
  65. "api_key_is_custom",
  66. "date",
  67. "prompt_token_count",
  68. "completion_token_count",
  69. "prompt_cached_token_count",
  70. "operation",
  71. "started_at",
  72. "completed_at",
  73. "created_at",
  74. "updated_at",
  75. "deleted_at",
  76. }
  77. hot = {c.name for c in ModelUsageDetails.__table__.columns}
  78. archive = {c.name for c in ModelUsageDetailsArchive.__table__.columns}
  79. assert expected <= hot, f"hot table missing: {expected - hot}"
  80. assert expected <= archive, f"archive table missing: {expected - archive}"
  81. def test_archive_shape_alignment_raises_on_drift(monkeypatch):
  82. """Synthetic drift via monkeypatched __table__ proves the assertion
  83. actually trips — without this we can't trust the no-raise case above."""
  84. class _FakeColumn:
  85. def __init__(self, name):
  86. self.name = name
  87. class _FakeTable:
  88. def __init__(self, columns):
  89. self.columns = columns
  90. drifted_archive = _FakeTable(
  91. [_FakeColumn(c.name) for c in ModelUsageDetailsArchive.__table__.columns]
  92. )
  93. drifted_archive.columns = drifted_archive.columns[:-1] # drop one column
  94. monkeypatch.setattr(ModelUsageDetailsArchive, "__table__", drifted_archive)
  95. with pytest.raises(RuntimeError, match="column mismatch"):
  96. _assert_archive_shape_aligned()
  97. # ---------------------------------------------------------------------------
  98. # Construction-time validation of the cron expression
  99. # ---------------------------------------------------------------------------
  100. def test_archiver_rejects_invalid_cron(monkeypatch):
  101. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_ARCHIVE_CRON", "garbage")
  102. with pytest.raises(ValueError, match="USAGE_DETAILS_ARCHIVE_CRON"):
  103. UsageDetailsArchiver()
  104. def test_archiver_rejects_empty_cron(monkeypatch):
  105. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_ARCHIVE_CRON", "")
  106. with pytest.raises(ValueError, match="USAGE_DETAILS_ARCHIVE_CRON"):
  107. UsageDetailsArchiver()
  108. def test_archiver_reads_retention_and_batch_size(monkeypatch):
  109. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_RETENTION_MONTHS", 7)
  110. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_ARCHIVE_BATCH_SIZE", 250)
  111. arc = UsageDetailsArchiver()
  112. assert arc._retention_months == 7
  113. assert arc._batch_size == 250
  114. # ---------------------------------------------------------------------------
  115. # Next-fire computation across common cron expressions
  116. # ---------------------------------------------------------------------------
  117. @pytest.mark.parametrize(
  118. "expr,upper_bound_seconds",
  119. [
  120. ("0 3 * * *", 24 * 3600), # daily at 03:00 — within a day
  121. ("*/15 * * * *", 15 * 60), # every 15 minutes
  122. ("0 */6 * * *", 6 * 3600), # every 6 hours
  123. ("30 2 * * 0", 7 * 24 * 3600), # weekly Sunday — within a week
  124. ("0 0 1 * *", 32 * 24 * 3600), # 1st of month — within ~1 month
  125. ],
  126. )
  127. def test_seconds_until_next_fire_within_bound(monkeypatch, expr, upper_bound_seconds):
  128. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_ARCHIVE_CRON", expr)
  129. secs = UsageDetailsArchiver()._seconds_until_next_fire()
  130. assert secs is not None
  131. assert 0 < secs <= upper_bound_seconds
  132. def test_seconds_until_next_fire_returns_float(monkeypatch):
  133. monkeypatch.setattr("gpustack.envs.USAGE_DETAILS_ARCHIVE_CRON", "*/5 * * * *")
  134. assert isinstance(UsageDetailsArchiver()._seconds_until_next_fire(), float)