From e315beb5df525e60ead1d064482a26bc964fd69a Mon Sep 17 00:00:00 2001 From: Dom Hastings Date: Wed, 12 Aug 2026 10:22:46 +0100 Subject: [PATCH] Return empty metrics if the `.db` file has no data. Fixes #1199. Signed-off-by: Dom Hastings --- prometheus_client/mmap_dict.py | 9 +++++++ tests/test_multiprocess.py | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/prometheus_client/mmap_dict.py b/prometheus_client/mmap_dict.py index edd895cd..c5dab96c 100644 --- a/prometheus_client/mmap_dict.py +++ b/prometheus_client/mmap_dict.py @@ -87,6 +87,15 @@ def read_all_values_from_file(filename): # Read the first block of data, including the first 4 bytes which tell us # how much of the file (which is preallocated to _INITIAL_MMAP_SIZE bytes) is occupied. data = infp.read(mmap.PAGESIZE) + if not data: + # __init__ creates the file and only truncates it to + # _INITIAL_MMAP_SIZE afterwards, so a reader can observe it while + # it is still empty; a process dying in that window can also leave + # an empty file behind for good. Treat it as a file with nothing + # recorded in it yet, mirroring the `capacity == 0` case in + # __init__, rather than failing to unpack a header that isn't + # there. Anything longer than this is left to fail loudly. + return iter(()) used = _unpack_integer(data, 0)[0] if used > len(data): # Then read in the rest, if needed. data += infp.read(used - len(data)) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index eab93f50..5f622f3e 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -1,6 +1,7 @@ import glob import os import shutil +import struct import tempfile import unittest import warnings @@ -477,6 +478,26 @@ def test_missing_gauge_file_during_merge(self): os.path.join(self.tempdir, 'gauge_livesum_9999999.db'), ])) + def test_uninitialized_file_during_merge(self): + # This file exists but is still zero bytes, just like a file created by + # MmapedDict.__init__ that has not been truncated to its initial size + # yet, or one left behind by a process that died in that window. + # This should not raise and return no metrics + empty_file = os.path.join(self.tempdir, 'counter_9999999.db') + open(empty_file, 'wb').close() + + self.assertFalse(self.collector.merge([empty_file])) + + def test_uninitialized_file_does_not_hide_other_metrics(self): + # A single unreadable file must not take the whole collection with it, + # as an empty file can be left behind indefinitely: it is named after a + # pid that never comes back, so nothing cleans it up. + c = Counter('c', 'help', registry=None) + c.inc(1) + open(os.path.join(self.tempdir, 'counter_9999999.db'), 'wb').close() + + self.assertEqual(1, self.registry.get_sample_value('c_total')) + def test_remove_clear_warning(self): os.environ['PROMETHEUS_MULTIPROC_DIR'] = self.tempdir with warnings.catch_warnings(record=True) as w: @@ -638,6 +659,32 @@ def test_corruption_detected(self): with self.assertRaises(RuntimeError): list(self.d.read_all_values()) + def test_read_all_values_from_empty_file(self): + # A reader can observe the file in the window between __init__ creating + # it and truncating it to _INITIAL_MMAP_SIZE, and a process dying in + # that window leaves it empty for good. Either way it holds no values, + # so it should read as empty rather than failing to unpack a header + # that was never written. + fd, empty_file = tempfile.mkstemp() + os.close(fd) + try: + self.assertEqual([], list(mmap_dict.MmapedDict.read_all_values_from_file(empty_file))) + finally: + os.unlink(empty_file) + + def test_read_all_values_from_file_with_truncated_contents(self): + # Only a wholly empty file is tolerated: a file claiming to hold more + # than it does is corrupt, and should still be reported as such. + fd, short_file = tempfile.mkstemp() + os.close(fd) + try: + with open(short_file, 'wb') as outfp: + outfp.write(mmap_dict._pack_integer_func(100)) + with self.assertRaises(struct.error): + list(mmap_dict.MmapedDict.read_all_values_from_file(short_file)) + finally: + os.unlink(short_file) + def tearDown(self): self.d.close() os.unlink(self.tempfile)