Skip to main content
← Back to list
01Issue
BugOpenSwamp CLIPublic
AssigneesNone

Relationships

#3361 S3-backed serve: a poll that pulls one file invalidates the whole data catalog, so the next query re-reads every version

Opened by vcjdeboer · 10/13/2026

Summary

On an S3-backed swamp serve, any runtime-data poll that pulls at least one file invalidates the whole data catalog (src/serve/runtime_data_poller.ts, around lines 128-132; also a step's lock pull, src/serve/deps.ts around 396-414). The next DataQueryService.query then waits for a full backfill that reads the metadata of every version of every data item (src/domain/data/data_query_service.ts around 658-670, backfillAsync from about line 1619). Worker capability.queryData goes through that path (src/serve/capability_service.ts around 355-370).

So one changed file makes the next query cost O(all versions), and on a busy serve that happens every poll cycle.

Version: swamp 20261012.111134.0-sha.8a1aa395, @swamp/s3-datastore 2026.10.01.1.

Observed

One serve, one remote worker, about 7,200 data versions, during active workflow runs:

  • about 0.8 positive runtime-data polls per minute, each pulling 1-4 files;
  • worker capability.queryData calls logged as "Handler slow": about 5 s each while the instance had CPU, 10-19 s once it was CPU-throttled (42 slow calls in one 80-minute window).

We have since given the instance more CPU, which hides most of it, but the cost grows with the number of stored versions.

Reproduction (synthetic data, no S3 needed)

Save as catalog_invalidation_repro_test.ts in a swamp checkout at 8a1aa395 and run: deno test --allow-read --allow-write --allow-env --allow-sys --allow-ffi catalog_invalidation_repro_test.ts

import { assertEquals } from "@std/assert";
import { join } from "@std/path";
import { ensureDirSync } from "@std/fs";
import { stringify as yaml } from "@std/yaml";
import { CatalogStore } from "./src/infrastructure/persistence/catalog_store.ts";
import { FileSystemUnifiedDataRepository } from "./src/infrastructure/persistence/unified_data_repository.ts";
import { DataQueryService } from "./src/domain/data/data_query_service.ts";

const VERSIONS_PER_NAME = 6;

function fixture(totalVersions: number) {
  const dir = Deno.makeTempDirSync({ prefix: "catalog-repro-" });
  const names = Math.ceil(totalVersions / VERSIONS_PER_NAME);
  for (let i = 0; i < names; i++) {
    const modelIndex = Math.floor(i / 24);
    const modelName = `model-${String(modelIndex).padStart(3, "0")}`;
    const modelId = `00000000-0000-4000-8000-${String(modelIndex).padStart(12, "0")}`;
    const name = `record-${String(i).padStart(4, "0")}`;
    const id = crypto.randomUUID();
    const base = join(dir, ".swamp/data/synthetic", modelId, name);
    for (let v = 1; v <= VERSIONS_PER_NAME; v++) {
      const vd = join(base, String(v));
      ensureDirSync(vd);
      Deno.writeTextFileSync(join(vd, "raw"), JSON.stringify({ i, v }));
      Deno.writeTextFileSync(join(vd, "metadata.yaml"), yaml({
        name, id, version: v, contentType: "application/json",
        lifetime: "infinite", garbageCollection: 50, streaming: false,
        tags: { type: "resource", specName: "execution", modelName },
        ownerDefinition: { ownerType: "model-method", ownerRef: modelId },
        createdAt: "2026-01-01T00:00:00.000Z",
      }));
    }
    Deno.writeTextFileSync(join(base, "latest"), String(VERSIONS_PER_NAME));
  }
  const catalog = new CatalogStore(join(dir, ".swamp/data/_catalog.db"));
  const repo = new FileSystemUnifiedDataRepository(dir, undefined, catalog);
  const service = new DataQueryService(catalog, repo);
  const work = { globalWalks: 0, metadataReads: 0 };
  const walk = repo.findAllGlobal.bind(repo);
  const read = repo.findByName.bind(repo);
  repo.findAllGlobal = (...a) => { work.globalWalks++; return walk(...a); };
  repo.findByName = (...a) => { work.metadataReads++; return read(...a); };
  return { dir, catalog, service, work };
}

for (const total of [1200, 7200]) {
  Deno.test(`after invalidate(), one query re-reads all ${total} versions`, async () => {
    const f = fixture(total);
    try {
      const predicate = 'modelName == "model-000" && name == "record-0000"';
      await f.service.query(predicate); // populate
      f.catalog.invalidate(); // what a runtime-data poll that pulled >= 1 file does

      let before = { ...f.work };
      let t = performance.now();
      await f.service.query(predicate);
      console.log(`${total} versions: query after invalidate ${(performance.now() - t).toFixed(1)} ms`);
      assertEquals(f.work.globalWalks - before.globalWalks, 1);
      assertEquals(f.work.metadataReads - before.metadataReads, total); // whole catalog

      f.catalog.invalidate();
      before = { ...f.work };
      t = performance.now();
      const latest = await f.service.getLatestRecord("model-000", "record-0000");
      console.log(`${total} versions: getLatestRecord after invalidate ${(performance.now() - t).toFixed(1)} ms`);
      assertEquals(latest?.version, VERSIONS_PER_NAME);
      assertEquals(f.work.globalWalks - before.globalWalks, 0); // no backfill needed
    } finally {
      f.catalog.close();
      Deno.removeSync(f.dir, { recursive: true });
    }
  });
}

Result on a laptop (both tests pass; the assertions count work, timings are only indicative):

1200 versions: query after invalidate 243.7 ms   | getLatestRecord after invalidate 1.1 ms
7200 versions: query after invalidate 1333.1 ms  | getLatestRecord after invalidate 1.0 ms

A query after invalidation does one global walk and reads every version's metadata; an exact latest read does neither.

Expected / request

A pull that changed a few paths should not cost a rebuild over all versions. Options:

  • refresh the catalog only for the changed paths (the pull currently returns a count, not the path set);
  • or serve exact model/name reads (as worker queryData often is) without waiting for a full backfill.

Related: #2998 (every fast-path miss re-downloads every index shard). Does the v3 datastore rework already change this?

02Bog Flow
◉OPEN○TRIAGED○IN PROGRESS○SHIPPED

Open

10/13/2026, 2:58:26 PM

No activity in this phase yet.

03Sludge Pulse

Sign in to post a ripple.