Skip to content

Commit 963011d

Browse files
authored
Add routine to automatically cleanup gtfs directories (#139)
1 parent e2e44d5 commit 963011d

4 files changed

Lines changed: 110 additions & 2 deletions

File tree

config/template.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
},
55
"gtfs": {
66
"dir": null,
7-
"refresh_interval_days": 7
7+
"refresh_interval_days": 7,
8+
"archive_retention_days": 90
89
},
910
"DATADOG_TRACE_ENABLED": false,
1011
"file_retention_days": 180,

src/gtfs.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
GTFS_ARCHIVES_PREFIX = "https://cdn.mbta.com/archive/"
2828
GTFS_ARCHIVES_FILENAME = "archived_feeds.txt"
2929

30+
# Number of days to retain downloaded GTFS archives before cleanup.
31+
# The most recent archive is always kept regardless of age.
32+
GTFS_ARCHIVE_RETENTION_DAYS = CONFIG["gtfs"].get("archive_retention_days", 90)
33+
3034
# defining these columns in particular becasue we use them everywhere
3135
RTE_DIR_STOP = ["route_id", "direction_id", "stop_id"]
3236

@@ -121,6 +125,48 @@ def _find_most_recent_gtfs_archive() -> Optional[pathlib.Path]:
121125
return None
122126

123127

128+
@tracer.wrap()
129+
def cleanup_old_gtfs_archives(reference_time: Optional[datetime.datetime] = None) -> int:
130+
"""Delete downloaded GTFS archives older than the configured retention period.
131+
132+
The most recent archive is always retained—even if older than the cutoff—since
133+
it is the active feed and the fallback used when newer archives can't be fetched.
134+
135+
Args:
136+
reference_time: Datetime used as the reference for the cutoff.
137+
Defaults to the current time.
138+
139+
Returns:
140+
Number of archive directories deleted.
141+
"""
142+
logger.info("Starting cleanup of old GTFS archives")
143+
if reference_time is None:
144+
reference_time = datetime.datetime.now()
145+
cutoff = reference_time - datetime.timedelta(days=GTFS_ARCHIVE_RETENTION_DAYS)
146+
147+
try:
148+
archive_dirs = [d for d in MAIN_DIR.iterdir() if d.is_dir()]
149+
except (PermissionError, OSError, IOError) as e:
150+
logger.error(f"Failed to scan GTFS archives directory: {e}")
151+
return 0
152+
153+
# Sort by name (date-based) descending and always keep the most recent archive.
154+
archive_dirs.sort(key=lambda d: d.name, reverse=True)
155+
156+
deleted = 0
157+
for archive_dir in archive_dirs[1:]:
158+
try:
159+
if datetime.datetime.fromtimestamp(archive_dir.stat().st_mtime) < cutoff:
160+
shutil.rmtree(archive_dir)
161+
deleted += 1
162+
logger.info(f"Deleted old GTFS archive: {archive_dir}")
163+
except (PermissionError, OSError, IOError) as e:
164+
logger.warning(f"Skipping {archive_dir}: {e}")
165+
166+
logger.info(f"Completed GTFS archive cleanup — deleted {deleted} archive(s)")
167+
return deleted
168+
169+
124170
@tracer.wrap()
125171
def get_gtfs_archive(dateint: int):
126172
"""

src/s3_upload.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from config import CONFIG
1414
from disk import DATA_DIR, cleanup_old_files
15+
from gtfs import cleanup_old_gtfs_archives
1516
from logger import set_up_logging
1617
from util import EASTERN_TIME, service_date
1718

@@ -65,8 +66,9 @@ def upload_todays_events_to_s3():
6566
end_time = time.time()
6667
logger.info(f"Uploaded {len(files_updated_today)} files to s3, took {end_time - start_time} seconds.")
6768

68-
# cleanup old files, free up disk space
69+
# cleanup old files and GTFS archives, free up disk space
6970
cleanup_old_files(reference_time=start_datetime)
71+
cleanup_old_gtfs_archives(reference_time=start_datetime)
7072

7173

7274
@tracer.wrap(service="gobble")

src/tests/test_gtfs.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import os
23
import numpy as np
34
import pandas as pd
45
import pathlib
@@ -296,3 +297,61 @@ def test_read_gtfs_date_exists_feed_is_read(self):
296297
assert "Harvard" in result.trips_by_route_id("1")["trip_headsign"].values
297298

298299
shutil.rmtree(expected_path)
300+
301+
302+
class TestCleanupOldGtfsArchives:
303+
"""Test cleanup_old_gtfs_archives function"""
304+
305+
@pytest.fixture
306+
def temp_gtfs_dir(self, tmp_path, monkeypatch):
307+
"""Point the GTFS archive dir at a temp directory with a known retention."""
308+
monkeypatch.setattr(gtfs, "MAIN_DIR", tmp_path)
309+
monkeypatch.setattr(gtfs, "GTFS_ARCHIVE_RETENTION_DAYS", 90)
310+
return tmp_path
311+
312+
def _make_archive(self, base: pathlib.Path, name: str, age_days: int) -> pathlib.Path:
313+
"""Create a fake archive directory whose mtime is age_days in the past."""
314+
archive_dir = base / name
315+
archive_dir.mkdir()
316+
(archive_dir / "trips.txt").write_text("trip_id\n")
317+
mtime = (datetime.datetime.now() - datetime.timedelta(days=age_days)).timestamp()
318+
os.utime(archive_dir, (mtime, mtime))
319+
return archive_dir
320+
321+
def test_deletes_archives_older_than_retention(self, temp_gtfs_dir):
322+
self._make_archive(temp_gtfs_dir, "20200101", age_days=200)
323+
self._make_archive(temp_gtfs_dir, "20260601", age_days=1)
324+
325+
deleted = gtfs.cleanup_old_gtfs_archives()
326+
327+
assert deleted == 1
328+
assert not (temp_gtfs_dir / "20200101").exists()
329+
assert (temp_gtfs_dir / "20260601").exists()
330+
331+
def test_keeps_archives_within_retention(self, temp_gtfs_dir):
332+
self._make_archive(temp_gtfs_dir, "20260501", age_days=30)
333+
self._make_archive(temp_gtfs_dir, "20260601", age_days=1)
334+
335+
deleted = gtfs.cleanup_old_gtfs_archives()
336+
337+
assert deleted == 0
338+
assert len([d for d in temp_gtfs_dir.iterdir() if d.is_dir()]) == 2
339+
340+
def test_always_keeps_most_recent_archive(self, temp_gtfs_dir):
341+
# Only one archive and it is very old—it must still be retained as the active feed.
342+
self._make_archive(temp_gtfs_dir, "20190101", age_days=500)
343+
344+
deleted = gtfs.cleanup_old_gtfs_archives()
345+
346+
assert deleted == 0
347+
assert (temp_gtfs_dir / "20190101").exists()
348+
349+
def test_ignores_archived_feeds_file(self, temp_gtfs_dir):
350+
(temp_gtfs_dir / gtfs.GTFS_ARCHIVES_FILENAME).write_text("data")
351+
self._make_archive(temp_gtfs_dir, "20200101", age_days=200)
352+
self._make_archive(temp_gtfs_dir, "20260601", age_days=1)
353+
354+
deleted = gtfs.cleanup_old_gtfs_archives()
355+
356+
assert deleted == 1
357+
assert (temp_gtfs_dir / gtfs.GTFS_ARCHIVES_FILENAME).exists()

0 commit comments

Comments
 (0)