Skip to content

Commit 4d02d24

Browse files
committed
Store parquet blob refs as basenames; honor DISK_TREE_ROOT
Scan blobs are stored as basenames (`<uuid>.parquet`) in SQLite's `scan.blob` and in the `child_scan_id` column inside chunked parquets, so the on-disk tree is portable across machines and `DISK_TREE_ROOT` locations. Storage backends (`hybrid`, `parquet`) gain a `_resolve()` helper that treats absolute refs as legacy and resolves basenames against `SCANS_DIR`. Server gets a top-level `resolve_blob()` for the same. Adds `disk-tree migrate-blobs` to convert existing absolute refs (both in SQLite and inside parquet `child_scan_id` columns) to basenames. Also: `DISK_TREE_ROOT` env var now drives the root data directory (was hard-coded to `~/.config/disk-tree`).
1 parent 439619b commit 4d02d24

6 files changed

Lines changed: 167 additions & 54 deletions

File tree

src/disk_tree/cli/index.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,11 @@ def index(
6565
if scan.error_count:
6666
summary += f", {scan.error_count} permission errors"
6767
print(summary)
68-
stat = os.stat(scan.blob)
69-
print(f"Scan cached path: {scan.blob} ({iec(stat.st_size)})")
68+
from os.path import isabs, join
69+
from disk_tree.config import SCANS_DIR
70+
blob_path = scan.blob if isabs(scan.blob) else join(SCANS_DIR, scan.blob)
71+
stat = os.stat(blob_path)
72+
print(f"Scan cached path: {blob_path} ({iec(stat.st_size)})")
7073
if scan.error_count:
7174
import json
7275
error_paths = json.loads(scan.error_paths) if scan.error_paths else []

src/disk_tree/cli/migrate.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Migration commands for disk-tree database."""
22
from os import makedirs, rename
3-
from os.path import basename, isfile, join
3+
from os.path import basename, isabs, isfile, join
44

55
import pandas as pd
66
from click import command, option
@@ -251,3 +251,91 @@ def migrate_hybrid(dry_run: bool):
251251
else:
252252
err(f"Migration complete: {migrated} migrated ({chunked} chunked), {skipped} skipped, {errors} errors")
253253
err(f"Originals backed up to: {backup_dir}")
254+
255+
256+
@cli.command('migrate-blobs')
257+
@option('-n', '--dry-run', is_flag=True, help="Show what would be done without making changes")
258+
def migrate_blobs(dry_run: bool):
259+
"""Convert absolute parquet blob refs to basenames (relative to SCANS_DIR).
260+
261+
Rewrites:
262+
- scan.blob column in SQLite
263+
- child_scan_id column inside hybrid parquet files
264+
"""
265+
import sqlite3
266+
267+
if not isfile(DB_PATH):
268+
err(f"Database not found: {DB_PATH}")
269+
return
270+
271+
conn = sqlite3.connect(DB_PATH)
272+
conn.row_factory = sqlite3.Row
273+
cursor = conn.cursor()
274+
275+
cursor.execute("SELECT id, blob FROM scan")
276+
rows = cursor.fetchall()
277+
db_updated = 0
278+
db_skipped = 0
279+
for row in rows:
280+
blob = row['blob']
281+
if not isabs(blob):
282+
db_skipped += 1
283+
continue
284+
new_blob = basename(blob)
285+
if dry_run:
286+
err(f" scan.id={row['id']}: {blob} -> {new_blob}")
287+
else:
288+
cursor.execute("UPDATE scan SET blob = ? WHERE id = ?", (new_blob, row['id']))
289+
db_updated += 1
290+
if not dry_run:
291+
conn.commit()
292+
err(f"SQLite scan rows: {db_updated} updated, {db_skipped} already relative")
293+
294+
# Rewrite child_scan_id inside each parquet
295+
cursor.execute("SELECT id, blob FROM scan")
296+
rows = cursor.fetchall()
297+
counts = {'updated': 0, 'skipped': 0, 'errors': 0}
298+
for row in rows:
299+
blob = row['blob']
300+
blob_path = blob if isabs(blob) else join(SCANS_DIR, blob)
301+
_normalize_parquet_chunks(blob_path, dry_run, counts)
302+
303+
conn.close()
304+
err(f"Parquet files: {counts['updated']} rewritten, {counts['skipped']} unchanged, {counts['errors']} errors")
305+
306+
307+
def _normalize_parquet_chunks(blob_path: str, dry_run: bool, counts: dict) -> None:
308+
"""Rewrite child_scan_id column to basenames; recurse into referenced chunks."""
309+
if not isfile(blob_path):
310+
err(f" Parquet not found: {blob_path}")
311+
counts['errors'] += 1
312+
return
313+
try:
314+
df = pd.read_parquet(blob_path)
315+
except Exception as e:
316+
err(f" Error reading {blob_path}: {e}")
317+
counts['errors'] += 1
318+
return
319+
320+
if 'child_scan_id' not in df.columns:
321+
counts['skipped'] += 1
322+
return
323+
324+
refs = df['child_scan_id'].dropna()
325+
abs_refs = refs[refs.apply(isabs)]
326+
if abs_refs.empty:
327+
counts['skipped'] += 1
328+
else:
329+
if dry_run:
330+
err(f" Would rewrite {len(abs_refs)} refs in {basename(blob_path)}")
331+
else:
332+
df['child_scan_id'] = df['child_scan_id'].apply(
333+
lambda v: basename(v) if isinstance(v, str) and isabs(v) else v
334+
)
335+
df.to_parquet(blob_path, index=False)
336+
counts['updated'] += 1
337+
338+
# Recurse into chunk parquets (resolve via basename in case still abs-on-disk)
339+
for child_ref in df['child_scan_id'].dropna():
340+
child_path = child_ref if isabs(child_ref) else join(SCANS_DIR, basename(child_ref))
341+
_normalize_parquet_chunks(child_path, dry_run, counts)

src/disk_tree/config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from os import environ as env, makedirs, walk
2-
from os.path import join, exists
2+
from os.path import join, exists, expanduser
33

44
DISK_TREE_ROOT_VAR = 'DISK_TREE_ROOT'
55
HOME = env['HOME']
66
CONFIG_DIR = join(HOME, '.config')
7-
ROOT_DIR = join(CONFIG_DIR, 'disk-tree')
7+
DEFAULT_ROOT_DIR = join(CONFIG_DIR, 'disk-tree')
8+
ROOT_DIR = expanduser(env.get(DISK_TREE_ROOT_VAR, DEFAULT_ROOT_DIR))
89

910
if not exists(ROOT_DIR):
1011
makedirs(ROOT_DIR)

src/disk_tree/server.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from os import listdir, makedirs, remove, stat
3-
from os.path import abspath, dirname, exists, isdir, isfile, join
3+
from os.path import abspath, dirname, exists, isabs, isdir, isfile, join
44
import shutil
55
import sqlite3
66
import subprocess
@@ -13,9 +13,19 @@
1313
from flask import Flask, jsonify, redirect, request, g, Response, send_from_directory, url_for
1414
from flask_cors import CORS
1515

16-
from disk_tree.config import SQLITE_PATH
16+
from disk_tree.config import SCANS_DIR, SQLITE_PATH
1717
from disk_tree.storage import get_backend
1818

19+
20+
def resolve_blob(blob_ref: str) -> str:
21+
"""Resolve a parquet blob ref to its absolute path.
22+
23+
Honors legacy absolute refs and ignores DuckDB/SQLite opaque refs.
24+
"""
25+
if not blob_ref or blob_ref.startswith(('ddb:', 'sqlite:')):
26+
return blob_ref
27+
return blob_ref if isabs(blob_ref) else join(SCANS_DIR, blob_ref)
28+
1929
app = Flask(__name__)
2030
CORS(app)
2131

@@ -116,7 +126,7 @@ def resolve_chunk_for_path(blob_ref: str, rel_path: str) -> tuple[str, str]:
116126
return blob_ref, rel_path
117127

118128
# Load the root parquet to check for child_scan_id
119-
df = pd.read_parquet(blob_ref)
129+
df = pd.read_parquet(resolve_blob(blob_ref))
120130
if 'child_scan_id' not in df.columns:
121131
return blob_ref, rel_path
122132

@@ -130,7 +140,7 @@ def resolve_chunk_for_path(blob_ref: str, rel_path: str) -> tuple[str, str]:
130140
if pd.notna(row.get('child_scan_id')):
131141
# This ancestor is chunked - resolve to the chunk
132142
chunk_ref = row['child_scan_id']
133-
if exists(chunk_ref):
143+
if exists(resolve_blob(chunk_ref)):
134144
# Rebase the remaining path relative to chunk root
135145
remaining = '/'.join(parts[i+1:]) if i + 1 < len(parts) else '.'
136146
# Recursively resolve in case of nested chunks
@@ -421,8 +431,8 @@ def get_scan():
421431
uri = '/'
422432

423433
# Find the best matching scan (exact match or ancestor)
424-
if uri.startswith('s3://'):
425-
search_path = uri
434+
if '://' in uri:
435+
search_path = uri # s3://, ssh://, etc.
426436
else:
427437
search_path = uri if uri.startswith('/') else f'/{uri}'
428438

@@ -443,6 +453,7 @@ def get_scan():
443453
else:
444454
# Find the most recent scan covering this path (exact or ancestor)
445455
# Collect all candidate scans, then pick the freshest
456+
from disk_tree.backends import url_parent
446457
candidate_scans = []
447458
test_path = search_path
448459
while test_path:
@@ -453,12 +464,8 @@ def get_scan():
453464
row = cursor.fetchone()
454465
if row:
455466
candidate_scans.append(dict(row))
456-
# Go up one directory
457-
if test_path == '/' or test_path == 's3://':
458-
break
459-
parent = dirname(test_path)
460-
# Avoid infinite loop: if dirname didn't change, we're at root
461-
if parent == test_path:
467+
parent = url_parent(test_path)
468+
if parent is None or parent == test_path:
462469
break
463470
test_path = parent
464471

@@ -809,9 +816,9 @@ def row_to_dict(row):
809816
child_scan_dfs = []
810817
for _, row in direct_children_df.iterrows():
811818
child_scan = row.get('child_scan_id')
812-
if pd.notna(child_scan) and exists(child_scan):
819+
if pd.notna(child_scan) and exists(resolve_blob(child_scan)):
813820
try:
814-
child_df = pd.read_parquet(child_scan)
821+
child_df = pd.read_parquet(resolve_blob(child_scan))
815822
# Only load direct children (depth=1) from child scans
816823
# These become depth=2 in the parent context
817824
child_df = child_df[child_df['depth'] == 1]
@@ -1688,7 +1695,7 @@ def delete_path():
16881695
df.loc[mask, 'n_children'] = df.loc[mask, 'n_children'] - 1
16891696

16901697
# Rewrite parquet (this is the expensive part)
1691-
df.to_parquet(blob_ref, index=False)
1698+
df.to_parquet(resolve_blob(blob_ref), index=False)
16921699

16931700
# Update denormalized stats in SQLite scan metadata
16941701
root_row = df[df['path'] == '.']

0 commit comments

Comments
 (0)