File size: 15,570 Bytes
6f8eac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8b0a0d
 
 
 
 
 
6f8eac4
c8b0a0d
6f8eac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62765f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f8eac4
 
62765f3
 
 
 
6f8eac4
 
 
 
c8b0a0d
 
 
 
 
 
 
 
 
 
 
 
6f8eac4
 
 
 
 
1079f5b
 
 
 
 
cb00666
1079f5b
 
c8b0a0d
 
 
6f8eac4
 
 
c8b0a0d
 
6f8eac4
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""Stage 2 β€” export the OpenSearch indexes and the Neo4j database.

The OpenSearch export deliberately produces the exact file layout that
upstream's `services/deepsearch_api/loader.sh` consumes:

    <index>.settings.json   <index>.mapping.json   <index>.data.json

so the artifacts are restorable by the existing Helm `data_loader_job` and
container-test compose, not only by our own bake step. That compatibility is the
acceptance test for this stage (see the plan, Β§9 step 5).
"""

from __future__ import annotations

import gzip
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
from typing import Dict, List

import httpx

from .config import BUILD_DIR, REPO_ROOT, Config
from .enumerate_assets import load_manifest, save_manifest

ARTIFACTS = BUILD_DIR / "artifacts"
OS_ARTIFACTS = ARTIFACTS / "opensearch"
NEO4J_ARTIFACTS = ARTIFACTS / "neo4j"

COMPOSE_PROJECT = "simready-indexing"
ELASTICDUMP_IMAGE = "elasticdump/elasticsearch-dump:latest"


def _run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
    print("    $ " + " ".join(cmd[:6]) + (" ..." if len(cmd) > 6 else ""))
    return subprocess.run(cmd, check=True, **kwargs)


def _uid_gid() -> str:
    import os
    return f"{os.getuid()}:{os.getgid()}"


def discover_indexes(cfg: Config) -> List[str]:
    """Indexes belonging to this ES_NAME, read from the cluster.

    Discovery rather than a hardcoded pair: the pointcloud cache exists in some
    corpora, and a rename that missed one place would otherwise export an empty
    index without complaint.
    """
    response = httpx.get(f"{cfg.opensearch_url}/_cat/indices?format=json", timeout=30.0)
    response.raise_for_status()
    names = sorted(
        row["index"] for row in response.json()
        if row.get("index", "").startswith(f"{cfg.es_name}-")
    )
    if cfg.main_index not in names:
        raise SystemExit(
            f"main index {cfg.main_index} not found in the cluster. Found: {names or 'none'}. "
            f"Check ES_NAME against what the writers actually created."
        )
    return names


def _network_name() -> str:
    return f"{COMPOSE_PROJECT}_default"


def elasticdump(index: str, dump_type: str, out_path: Path) -> None:
    _run([
        "docker", "run", "--rm",
        "--network", _network_name(),
        "--user", _uid_gid(),
        "-v", f"{OS_ARTIFACTS}:/out",
        ELASTICDUMP_IMAGE,
        f"--input=http://opensearch:9200/{index}",
        f"--output=/out/{out_path.name}",
        f"--type={dump_type}",
        "--limit=1000",
        "--overwrite",
    ])


def sha256_of(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as fh:
        for block in iter(lambda: fh.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def gzip_file(path: Path) -> Path:
    dest = path.with_suffix(path.suffix + ".gz")
    with path.open("rb") as src, gzip.open(dest, "wb") as out:
        shutil.copyfileobj(src, out)
    return dest


def export_opensearch(cfg: Config, manifest: Dict) -> Dict:
    OS_ARTIFACTS.mkdir(parents=True, exist_ok=True)
    indexes = discover_indexes(cfg)
    print(f"  indexes: {', '.join(indexes)}")

    expected = manifest.get("asset_count", 0)
    main_count = int(
        httpx.get(f"{cfg.opensearch_url}/{cfg.main_index}/_count", timeout=30.0).json()["count"]
    )
    if expected and main_count != expected:
        raise SystemExit(
            f"pre-flight: {cfg.main_index} holds {main_count} docs but the run expects "
            f"{expected}. Refusing to export a partial index β€” re-run stage 1."
        )

    # Flush so everything written is on disk and countable.
    httpx.post(f"{cfg.opensearch_url}/_flush", timeout=120.0)

    exported: Dict[str, Dict] = {}
    for index in indexes:
        print(f"  exporting {index} ...")
        files = {}
        for dump_type, suffix in (
            ("settings", "settings.json"),
            ("mapping", "mapping.json"),
            ("data", "data.json"),
        ):
            plain = OS_ARTIFACTS / f"{index}.{suffix}"
            elasticdump(index, dump_type, plain)
            if not plain.exists() or plain.stat().st_size == 0:
                raise SystemExit(f"elasticdump produced no {dump_type} for {index}")
            compressed = gzip_file(plain)
            files[dump_type] = {
                "file": compressed.name,
                "bytes": compressed.stat().st_size,
                "sha256": sha256_of(compressed),
            }
        count = int(
            httpx.get(f"{cfg.opensearch_url}/{index}/_count", timeout=30.0).json()["count"]
        )
        data_lines = sum(
            1 for _ in (OS_ARTIFACTS / f"{index}.data.json").open() if _.strip()
        )
        if data_lines != count:
            raise SystemExit(
                f"{index}: dumped {data_lines} rows but the index reports {count} docs"
            )
        exported[index] = {"doc_count": count, "files": files}
        print(f"    {count} docs")

    return exported


def verify_dump(cfg: Config, dump: Path) -> None:
    """Load the dump into a throwaway data dir to prove it is loadable.

    Same principle as `bake.py::verify_baked` β€” check the artifact actually
    works from a fresh process, not just that a file got written. A dump can
    fail this while still existing on disk at a plausible size β€” see the
    stop-ordering comment in `export_neo4j` β€” and `bake` finding out is
    hours and several stages too late.

    Runs as the host uid, unlike the real dump/load steps: those touch the
    named `neo4j-data` volume, already owned by the in-container uid 7474,
    so they must run as root. This scratch dir is fresh and ours, so running
    as root here would just leave root-owned files behind that the host
    user can't clean up afterwards.
    """
    import tempfile

    with tempfile.TemporaryDirectory() as scratch:
        result = subprocess.run(
            [
                "docker", "run", "--rm",
                "--user", _uid_gid(),
                "-v", f"{dump.parent}:/dumps:ro",
                "-v", f"{scratch}:/data",
                f"neo4j:{cfg.get('NEO4J_VERSION', '5.23.0-community')}",
                "neo4j-admin", "database", "load", "neo4j",
                "--from-path=/dumps", "--overwrite-destination=true",
            ],
            capture_output=True, text=True, check=False,
        )
        if result.returncode != 0:
            raise SystemExit(
                f"neo4j dump failed to verify-load β€” likely corrupted by AGS "
                f"and neo4j being stopped concurrently (see the stop-ordering "
                f"comment in export_neo4j):\n{result.stdout}\n{result.stderr}"
            )


def export_neo4j(cfg: Config) -> Dict:
    NEO4J_ARTIFACTS.mkdir(parents=True, exist_ok=True)

    # Community edition cannot stop the default database from Cypher (no
    # manual `db.checkpoint()` either β€” that procedure doesn't exist outside
    # Enterprise), so the dump has to be taken offline via a plain container
    # stop, relying on Neo4j's own graceful-shutdown checkpoint.
    #
    # AGS *must* be given its own `stop` call, fully awaited, before neo4j's
    # `stop` call starts β€” not one `stop asset-graph-service neo4j` command
    # naming both. `docker compose stop` sends SIGTERM to every named service
    # concurrently regardless of argument order, so a single combined call
    # can (and, reproduced directly, does) stop AGS and neo4j at the same
    # moment β€” if AGS is mid-write when neo4j's shutdown checkpoint starts,
    # the resulting dump is corrupted (truncated trailing zstd frame,
    # `Not a valid Neo4j archive`) while still landing on disk at a
    # plausible size, silently, caught only by a `neo4j-admin database load`
    # deep in a later `bake`. Two sequential calls make the ordering real
    # instead of advisory; `verify_dump` below is the safety net regardless.
    # `docker compose stop`'s default grace period (~10s) is nowhere near
    # enough for Neo4j's own graceful-shutdown checkpoint on a real corpus β€”
    # verified live: a ~69k-node/81k-relationship graph took ~103s to stop
    # cleanly. Short of that, compose SIGKILLs it, which produces exactly the
    # "plausible size, silently corrupted" dump the comment above warns
    # about, just from a different cause (timeout, not concurrent-stop
    # ordering) β€” caught by `verify_dump` below either way, but avoidable.
    print("  stopping asset-graph-service ...")
    _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
          "stop", "--timeout", "300", "asset-graph-service"], env=cfg.compose_env())
    # 300 s wasn't enough either on 2026-10-16: the full simready-assets graph
    # (~149k nodes, ~12k property indexes) was SIGKILLed mid-checkpoint and the
    # dump refused with "Active logical log detected". Recover by starting
    # neo4j once (it replays the log) and rerunning export.
    stop_timeout = cfg.get_int("NEO4J_STOP_TIMEOUT", 1800)
    print(f"  stopping neo4j (up to {stop_timeout}s for its shutdown checkpoint) ...")
    _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
          "stop", "--timeout", str(stop_timeout), "neo4j"], env=cfg.compose_env())

    print("  dumping neo4j ...")
    # Runs as root (the image default), NOT as the host uid: the data volume is
    # owned by the in-container neo4j user (uid 7474), and neo4j-admin refuses
    # with "You do not have permission to dump the database" under --user.
    # The dump is chowned to the host uid afterwards so the artifact is readable
    # and removable outside the container.
    #
    # rm -f the previous dump ourselves, rather than passing
    # --overwrite-destination=true: that flag has a confirmed upstream bug
    # (neo4j/neo4j#13604) where overwriting a pre-existing *larger* archive
    # doesn't fully truncate it, leaving old bytes at the tail of the new one
    # β€” `Not a valid Neo4j archive` / `ZstdIOException: Unknown frame
    # descriptor` on load, reproduced here on every retry (each one dumping
    # over the previous attempt's own leftover file). The Neo4j maintainers'
    # own workaround (same issue, 2025-10-27) is to delete the destination
    # first and never pass --overwrite-destination β€” a genuinely fresh
    # destination file can't hit this bug.
    _run([
        "docker", "run", "--rm",
        "--entrypoint", "bash",
        "-v", f"{COMPOSE_PROJECT}_neo4j-data:/data",
        "-v", f"{NEO4J_ARTIFACTS}:/dumps",
        f"neo4j:{cfg.get('NEO4J_VERSION', '5.23.0-community')}",
        "-c",
        "set -e; rm -f /dumps/neo4j.dump; neo4j-admin database dump neo4j --to-path=/dumps; "
        f"chown -R {_uid_gid()} /dumps",
    ])

    dump = NEO4J_ARTIFACTS / "neo4j.dump"
    if not dump.exists():
        raise SystemExit(f"neo4j-admin produced no dump at {dump}")

    print("  verifying the dump loads cleanly ...")
    verify_dump(cfg, dump)

    compressed = gzip_file(dump)

    print("  restarting neo4j and asset-graph-service ...")
    _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
          "start", "neo4j", "asset-graph-service"], env=cfg.compose_env())

    return {
        "file": compressed.name,
        "bytes": compressed.stat().st_size,
        "sha256": sha256_of(compressed),
    }


def package_sizes_problem(manifest: Dict, path: Path) -> str:
    """Why `path` is not this run's package_sizes.json ("" when it is): its (repo, revision) groups must be exactly
    the manifest's hf_repos. On 2026-10-20 a test overwrote .build/package_sizes.json with its fixture
    (nvidia/repo-a@main) and export shipped it with run 20261020-a3cb7f2-captions; this is the check that run
    lacked. Only a storage_api run has one."""
    if manifest.get("storage_backend") != "storage_api":
        return ""
    if not path.exists():
        return f"{path.name} is missing: re-run `enumerate`"
    try:
        groups = json.loads(path.read_text()).get("groups") or []
    except (OSError, ValueError, AttributeError) as e:
        return f"{path.name} is unreadable ({e}): re-run `enumerate`"
    have = {(g.get("repo_id"), g.get("revision")) for g in groups if isinstance(g, dict)}
    want = {(repo_id, revision) for _host, _type, repo_id, revision in manifest.get("hf_repos") or []}
    if have != want:
        extra, missing = sorted(have - want), sorted(want - have)
        return (f"{path.name} is not this run's: {len(extra)} (repo, revision) group(s) the run doesn't list "
                f"{extra[:3]}, {len(missing)} of its own missing {missing[:3]}. Re-run `enumerate` with this "
                f"run's RUN_ID, or restore the file from the run's S3 prefix.")
    return ""


def run(cfg: Config) -> int:
    manifest = load_manifest()
    problem = package_sizes_problem(manifest, BUILD_DIR / "package_sizes.json")
    if problem:
        print(f"export: {problem}")
        return 1
    ARTIFACTS.mkdir(parents=True, exist_ok=True)

    print("Exporting OpenSearch ...")
    manifest["opensearch_artifacts"] = export_opensearch(cfg, manifest)
    from .image_overrides import check_dump  # every doc its own nonzero vector, >= 98 % distinct
    main_dump = OS_ARTIFACTS / f"{cfg.main_index}.data.json.gz"
    if main_dump.exists() and check_dump(main_dump) != 0:
        print(f"export: {main_dump.name} fails the vector check β€” not exporting a broken index")
        return 1

    # `wait` recorded image_cache_docs before `image-overrides` ran, and each override adds an image document
    # (the package's own thumbnail document stays): 2597 assets -> 2605 image docs on 2026-10-16. scripts/smoke.sh
    # checks the baked image against this number, so take it from what was actually exported.
    image_cache = manifest["opensearch_artifacts"].get(cfg.image_cache_index)
    if image_cache is not None:
        manifest["image_cache_docs"] = image_cache["doc_count"]

    print("\nExporting Neo4j ...")
    manifest["neo4j_artifact"] = export_neo4j(cfg)

    # Copy the inputs alongside the data so an artifact set is self-describing.
    # hf-storage-api-catalog.yaml only exists for a storage_api-backend
    # corpus (pipeline/enumerate_assets.py, stage 0) β€” folded in here so it
    # rides along with the rest of this run's artifact set through the same
    # upload/download machinery (pipeline/upload.py, pipeline/hf_space_seed.py)
    # rather than needing a separate S3 sync call. See
    # docs/hf-space-deployment.md Β§4 item 3 for why this is a
    # frozen index-time artifact, not something restored by re-listing the
    # HF dataset at Space startup.
    # package_sizes.json (storage_api too): the Space's /package_sizes.json sums, from enumerate's own listing.
    for name in ("corpus.json", "index-report.json", "assets.jsonl", "hf-storage-api-catalog.yaml",
                 "package_sizes.json"):
        src = BUILD_DIR / name
        if src.exists():
            shutil.copy2(src, ARTIFACTS / name)
        else:
            (ARTIFACTS / name).unlink(missing_ok=True)  # .build/artifacts/ outlives runs; never ship an older one's

    save_manifest(manifest)
    shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")

    print(f"\nartifacts in {ARTIFACTS.relative_to(REPO_ROOT)}:")
    for path in sorted(ARTIFACTS.rglob("*")):
        if path.is_file():
            print(f"  {path.relative_to(ARTIFACTS)}  ({path.stat().st_size:,} bytes)")
    return 0