Files
zoneminder/tests/zm_time.cpp
Isaac Connor 62ff384855 fix: store event StartDateTime with second precision to match disk path refs #4870
SystemTimePointToMysqlString appended ".%06d" microseconds to the string it
hands MySQL for the Events.StartDateTime datetime column. That column has no
fractional precision, and MySQL 8 ROUNDS a fractional value when storing it, so
a start_time of 23:59:59.5xx-.999999 local was promoted to 00:00:00 of the next
day. Event::SetPath() derives the on-disk day folder from to_time_t(start_time),
which truncates, so it landed on the previous day.

For continuous recording the event start is backdated to the preceding keyframe,
which for a section forced closed at local midnight falls just before midnight.
On MySQL 8 the DB row then recorded the next day while the files were written
under the previous day's folder, producing a permanent zmaudit path mismatch and
orphaned files when the event aged out (the purge path is built from
StartDateTime).

Format the value to whole seconds only so it matches to_time_t() used by
SetPath(), keeping the DB row and the disk folder on the same second regardless
of whether the DB engine rounds or truncates.

Add tests/zm_time.cpp covering the floor-not-round behaviour and consistency
with the to_time_t-derived path second.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:42:10 -04:00

85 lines
3.9 KiB
C++

/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_time.h"
#include <chrono>
#include <ctime>
#include <string>
// Build the local-time "%Y-%m-%d %H:%M:%S" string for a whole-second time_t the
// same way SetPath() derives the on-disk event directory, so the test stays
// timezone-independent (it compares against the same localtime conversion the
// production code uses rather than a hard-coded TZ-specific string).
static std::string LocalSecondString(time_t sec) {
tm tm_local = {};
localtime_r(&sec, &tm_local);
char buffer[32];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm_local);
return std::string(buffer);
}
TEST_CASE("SystemTimePointToMysqlString: whole second has no fractional part") {
// 1764623999 == 2025-12-01 19:59:59 UTC; exact-second timepoints must format
// without a fractional component because the Events.StartDateTime column is
// datetime (second precision).
SystemTimePoint tp = std::chrono::system_clock::from_time_t(1764623999);
REQUIRE(SystemTimePointToMysqlString(tp) == LocalSecondString(1764623999));
}
TEST_CASE("SystemTimePointToMysqlString: sub-second part is floored, never rounded up") {
// refs #4870: a continuous event whose backdated start keyframe lands in the
// last fraction of a second before local midnight must not be promoted to the
// next second. MySQL 8 rounds fractional seconds when storing into a
// datetime(0) column, while Event::SetPath() truncates via to_time_t(); if the
// string we hand MySQL carries a roundable fractional, the DB row and the
// on-disk day folder diverge by a day. The string must already be floored to
// the whole second that SetPath() (to_time_t) uses.
const time_t base_sec = 1764623999; // floored second SetPath would use
// Worst case: 999999us before the next second boundary - MySQL 8 would round
// this up to base_sec + 1 if we emitted ".999999".
SystemTimePoint tp_high =
std::chrono::system_clock::from_time_t(base_sec) + Microseconds(999999);
REQUIRE(SystemTimePointToMysqlString(tp_high) == LocalSecondString(base_sec));
// Half-second: the classic round-vs-truncate divergence point.
SystemTimePoint tp_half =
std::chrono::system_clock::from_time_t(base_sec) + Microseconds(500000);
REQUIRE(SystemTimePointToMysqlString(tp_half) == LocalSecondString(base_sec));
// Small fraction: trivially floors with either policy, included for coverage.
SystemTimePoint tp_low =
std::chrono::system_clock::from_time_t(base_sec) + Microseconds(1);
REQUIRE(SystemTimePointToMysqlString(tp_low) == LocalSecondString(base_sec));
}
TEST_CASE("SystemTimePointToMysqlString: matches the second used to build the event path") {
// The DB StartDateTime and Event::SetPath() must resolve to the same calendar
// day. SetPath() derives the directory from to_time_t(start_time); the Mysql
// string must use that identical floored second so the day folder and the
// StartDateTime row never disagree (refs #4870).
const time_t base_sec = 1764623999;
SystemTimePoint tp =
std::chrono::system_clock::from_time_t(base_sec) + Microseconds(999999);
time_t path_sec = std::chrono::system_clock::to_time_t(tp);
REQUIRE(SystemTimePointToMysqlString(tp) == LocalSecondString(path_sec));
}