From e2d6a454bfd82e1035174af75b1657ddf3816a36 Mon Sep 17 00:00:00 2001 From: host Date: Mon, 26 Oct 2026 05:04:59 +0800 Subject: [PATCH] Fix workers time columns to DOUBLE PRECISION on Postgres SQLite REAL is 8-byte double, but Postgres REAL is 4-byte float4 (~7 significant digits). Epoch seconds (10 digits) were rounded to 128-second steps, so worker liveness ages were off by up to +-64s and could even go negative (observed live on the host: last_seen ahead of NOW() by 20s). New tables use DOUBLE PRECISION; existing Postgres tables are upgraded only when information_schema actually reports REAL, so the ALTER does not run on every heartbeat (it would take an ACCESS EXCLUSIVE lock each time). SQLite path is unchanged (its REAL is already double precision). Regression test asserts new tables use DOUBLE PRECISION. --- cloud/tests/test_cloud_dispatch_pr.py | 21 ++++++++++++ cloud/workers.py | 46 +++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/cloud/tests/test_cloud_dispatch_pr.py b/cloud/tests/test_cloud_dispatch_pr.py index 7427e34..e3cd22d 100644 --- a/cloud/tests/test_cloud_dispatch_pr.py +++ b/cloud/tests/test_cloud_dispatch_pr.py @@ -458,5 +458,26 @@ class ProjectTaskFieldTestCase(_ServerCase): self.assertEqual(body["executor"], "docker") +class WorkerTablePrecisionTestCase(unittest.TestCase): + def test_workers_time_columns_are_double_precision(self): + # 回归:2026-10-26 主机实测,workers.last_seen 用 REAL 时, + # Postgres 的 float4 把 epoch 秒舍入到 128 秒一档,存活判断 + # 误差可达 ±64 秒、甚至出现负 age。新表必须是 DOUBLE PRECISION。 + import sqlite3 as _sqlite3 + tmp = tempfile.TemporaryDirectory() + db_path = os.path.join(tmp.name, "w.db") + from cloud.workers import ensure_workers_table + ensure_workers_table(db_path) + conn = _sqlite3.connect(db_path) + try: + rows = conn.execute("PRAGMA table_info(workers)").fetchall() + finally: + conn.close() + tmp.cleanup() + by_name = {r[1]: r[2] for r in rows} + self.assertIn("DOUBLE", by_name["last_seen"]) + self.assertIn("DOUBLE", by_name["created_at"]) + + if __name__ == "__main__": unittest.main() diff --git a/cloud/workers.py b/cloud/workers.py index fb6a789..73d16f9 100644 --- a/cloud/workers.py +++ b/cloud/workers.py @@ -42,7 +42,19 @@ def alive_threshold_seconds(override=None): def ensure_workers_table(db_path): - """建 workers 表(不存在时建,已存在时不动)。""" + """建 workers 表(不存在时建,已存在时不动,老库补精度)。 + + 注意:last_seen/created_at 必须是 DOUBLE PRECISION,不能用 REAL。 + SQLite 的 REAL 是 8 字节双精度,没问题;但 Postgres 的 REAL 是 + 4 字节 float4,只能存约 7 位有效数字,epoch 秒(10 位)会被 + 舍入到 128 秒一档,导致存活判断误差可达 ±64 秒、甚至出现负 age + (2026-10-26 主机实测:last_seen 比 NOW() 还大 20 秒)。 + 新表直接用 DOUBLE PRECISION;老库(已是 REAL 列)用 ALTER + TYPE 升到 DOUBLE PRECISION(float4 -> float8 无损,已存的舍入值 + 无法恢复,但之后的心跳不再舍入)。SQLite 下 ALTER TYPE 语法 + 不支持,靠 try/except 跳过——SQLite 的 REAL 本来就是双精度, + 不需要升。 + """ conn = _db_connect(str(db_path)) try: conn.execute( @@ -50,10 +62,38 @@ def ensure_workers_table(db_path): "worker_id TEXT PRIMARY KEY, " "executor TEXT, " "capacity INTEGER, " - "last_seen REAL, " - "created_at REAL)" + "last_seen DOUBLE PRECISION, " + "created_at DOUBLE PRECISION)" ) conn.commit() + # 老库升精度只在确实是 Postgres REAL 列时做一次:每次心跳都 + # ALTER 会反复拿 ACCESS EXCLUSIVE 锁,没必要。先查 + # information_schema(SQLite 下这张表不存在,查询失败即跳过, + # SQLite 的 REAL 本来就是双精度,不需要升)。 + for col in ("last_seen", "created_at"): + try: + row = conn.execute( + "SELECT data_type FROM information_schema.columns " + "WHERE table_name = 'workers' AND column_name = ?", + (col,)).fetchone() + except Exception: + try: + conn.rollback() + except Exception: + pass + continue + if not row or row[0] != "real": + continue + try: + conn.execute( + "ALTER TABLE workers ALTER COLUMN %s " + "TYPE DOUBLE PRECISION" % col) + conn.commit() + except Exception: + try: + conn.rollback() + except Exception: + pass finally: conn.close()