ci/cd: fix tup cache rebuild (#525)
Problem: `sync-tup-ctime.py` realigned every node's ctime, which hid real source changes whose Tupfile didn't change. Fix: stamp the cache with its commit and on restore, `git diff` against it, keeping changed files dirty so tup rebuilds them. Reviewed-on: #525 Reviewed-by: Mikhail Frolov <mixa.frolov2003@gmail.com> Reviewed-by: Gleb Zaharov <risdeveau@lair.moe>
This commit was merged in pull request #525.
This commit is contained in:
@@ -2,10 +2,13 @@
|
||||
# Sync tup's mtime/mtime_ns in .tup/db with the actual filesystem ctime.
|
||||
#
|
||||
# On Linux, tup uses st_ctim (ctime) for change detection, not st_mtim.
|
||||
# After restoring build cache with cp -a, files get new ctimes while the
|
||||
# After restoring a build cache with cp -a, files get new ctimes while the
|
||||
# database still has old values. This script updates the database to match.
|
||||
#
|
||||
# Usage: python3 sync-tup-ctime.py [path/to/.tup/db]
|
||||
# Files in changed-files-list (changed since the cache's commit) are forced
|
||||
# dirty instead, so tup still rebuilds them and their dependents.
|
||||
#
|
||||
# Usage: python3 sync-tup-ctime.py [path/to/.tup/db] [changed-files-list]
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -15,6 +18,15 @@ db_path = sys.argv[1] if len(sys.argv) > 1 else ".tup/db"
|
||||
if not os.path.isfile(db_path):
|
||||
sys.exit(f"Database not found: {db_path}")
|
||||
|
||||
# Paths changed since the cache's commit: keep dirty so tup rebuilds them.
|
||||
dirty = set()
|
||||
if len(sys.argv) > 2 and sys.argv[2]:
|
||||
if os.path.isfile(sys.argv[2]):
|
||||
with open(sys.argv[2], encoding="utf-8") as f:
|
||||
dirty = {line.strip() for line in f if line.strip()}
|
||||
else:
|
||||
print(f"Warning: changed-files list not found: {sys.argv[2]}")
|
||||
|
||||
db = sqlite3.connect(db_path)
|
||||
# Map every node id -> (parent dir id, name). Any node type can be a parent
|
||||
# directory in the chain (generated/variant dirs are not type 0/2), so the map
|
||||
@@ -25,7 +37,8 @@ nodes = {
|
||||
"select id, dir, name from node"
|
||||
)
|
||||
}
|
||||
paths, updated, skipped = {}, 0, []
|
||||
paths, updated, forced, skipped = {}, 0, 0, []
|
||||
matched = set() # dirty paths that actually resolved to a tup node
|
||||
|
||||
for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
|
||||
"select id, dir, name, type, mtime, mtime_ns from node where type in (0, 2, 4)"
|
||||
@@ -37,7 +50,23 @@ for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
|
||||
cur, part = nodes[cur]
|
||||
parts.append(part)
|
||||
paths[dir_id] = "/".join(reversed(parts))
|
||||
path = os.path.join(paths[dir_id], name) if paths[dir_id] else name
|
||||
# Build the path with forward slashes (tup stores them that way and git
|
||||
# emits them that way), independent of the host os.sep. tup's root dir
|
||||
# node is named ".", so paths come out as "./foo/bar"; strip that prefix
|
||||
# so they match git's plain "foo/bar" entries in the dirty set.
|
||||
path = paths[dir_id] + "/" + name if paths[dir_id] else name
|
||||
if path.startswith("./"):
|
||||
path = path[2:]
|
||||
|
||||
# Changed since the cache base: force a rebuild instead of aligning.
|
||||
if path in dirty:
|
||||
matched.add(path)
|
||||
if old_sec != 0 or old_ns != 0:
|
||||
db.execute(
|
||||
"update node set mtime=0, mtime_ns=0 where id=?", (node_id,))
|
||||
forced += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
@@ -53,13 +82,20 @@ for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
print(f"Updated ctime for {updated} nodes, skipped {len(skipped)} missing files")
|
||||
print(f"Aligned {updated} nodes, forced {forced} changed nodes dirty "
|
||||
f"({len(matched)}/{len(dirty)} changed paths matched a tup node), "
|
||||
f"skipped {len(skipped)} missing files")
|
||||
|
||||
# A changed path that matches no tup node is normal (docs, CI files, etc.),
|
||||
# but if NONE of them match while the list is non-empty, the path format from
|
||||
# git almost certainly disagrees with tup's node paths -- which means changed
|
||||
# files would silently NOT be rebuilt. Make that loud instead of silent.
|
||||
if dirty and not matched:
|
||||
print("WARNING: no changed path matched a tup node -- path format "
|
||||
"mismatch likely; changed files may NOT be rebuilt.")
|
||||
|
||||
if skipped:
|
||||
from collections import Counter
|
||||
by_type = Counter(ntype for _, ntype in skipped)
|
||||
print("Skipped by node type: "
|
||||
+ ", ".join(f"type {t}: {c}" for t, c in sorted(by_type.items())))
|
||||
print("Skipped (missing) files:")
|
||||
for path, ntype in sorted(skipped):
|
||||
print(f" [type {ntype}] {path}")
|
||||
|
||||
@@ -127,12 +127,20 @@ jobs:
|
||||
echo "cache_restored=true" >> $GITEA_OUTPUT
|
||||
fi
|
||||
|
||||
# cache restore resets file ctimes
|
||||
# tup keys change detection on ctime
|
||||
# so realign its DB to avoid rebuilding everything
|
||||
# cache restore resets ctimes; realign tup DB to skip a full rebuild,
|
||||
# but keep files changed since the cache's commit dirty so tup rebuilds them
|
||||
- name: Sync tup DB with filesystem ctime
|
||||
if: steps.check-tup-cache.outputs.cache_restored == 'true'
|
||||
run: python3 .gitea/utils/sync-tup-ctime.py
|
||||
run: |
|
||||
DIRTY=$(mktemp)
|
||||
BASE=$(cat .tup/build-commit 2>/dev/null || true)
|
||||
if [ -n "$BASE" ] && git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
|
||||
git -c core.quotePath=false diff --name-only "$BASE" HEAD > "$DIRTY"
|
||||
echo "Cache built at $BASE; $(wc -l < "$DIRTY") changed files kept dirty"
|
||||
python3 .gitea/utils/sync-tup-ctime.py .tup/db "$DIRTY"
|
||||
else
|
||||
echo "No usable cache base commit; skipping sync (tup rebuilds all)"
|
||||
fi
|
||||
|
||||
# write the variant config
|
||||
- name: Configure tup
|
||||
@@ -161,6 +169,10 @@ jobs:
|
||||
source kos32-export-env-vars ${{ gitea.workspace }}
|
||||
tup build-${{ matrix.lang }}
|
||||
|
||||
# stamp the cache with its commit so later builds diff against it
|
||||
- name: Record cache base commit
|
||||
run: echo "${{ gitea.sha }}" > .tup/build-commit
|
||||
|
||||
|
||||
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ARTIFACTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -129,12 +129,20 @@ jobs:
|
||||
echo "cache_restored=true" >> $GITEA_OUTPUT
|
||||
fi
|
||||
|
||||
# cache restore resets file ctimes
|
||||
# tup keys change detection on ctime
|
||||
# so realign its DB to avoid rebuilding everything
|
||||
# cache restore resets ctimes; realign tup DB to skip a full rebuild,
|
||||
# but keep files changed since the cache's commit dirty so tup rebuilds them
|
||||
- name: Sync tup DB with filesystem ctime
|
||||
if: steps.check-tup-cache.outputs.cache_restored == 'true'
|
||||
run: python3 .gitea/utils/sync-tup-ctime.py
|
||||
run: |
|
||||
DIRTY=$(mktemp)
|
||||
BASE=$(cat .tup/build-commit 2>/dev/null || true)
|
||||
if [ -n "$BASE" ] && (git cat-file -e "${BASE}^{commit}" 2>/dev/null || git fetch --depth=1 origin "$BASE" 2>/dev/null); then
|
||||
git -c core.quotePath=false diff --name-only "$BASE" HEAD > "$DIRTY"
|
||||
echo "Cache built at $BASE; $(wc -l < "$DIRTY") changed files kept dirty"
|
||||
python3 .gitea/utils/sync-tup-ctime.py .tup/db "$DIRTY"
|
||||
else
|
||||
echo "No usable cache base commit; skipping sync (tup rebuilds all)"
|
||||
fi
|
||||
|
||||
# write the variant config
|
||||
- name: Configure tup
|
||||
|
||||
Reference in New Issue
Block a user