Skip to content

Commit 62b4055

Browse files
committed
don't overexpose persistence dir in config, minor fixes
1 parent 43d1b51 commit 62b4055

27 files changed

Lines changed: 384 additions & 187 deletions

‎.gitignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,12 @@ benchmarks/*/results/
223223
benchmarks/*/compat/*.so
224224

225225
# Runtime working directory of `serviette demo` (regenerable: corpus copy,
226-
# generated config, DuckDB store, persistence)
226+
# generated config, DuckDB store, workdir)
227227
serviette-demo/
228228

229+
# Default `workdir_path` (persistence state, fingerprint)
230+
serviette-workdir/
231+
229232
# Internal planning document — kept local, never committed (purged from
230233
# history on 2026-07-09).
231234
docs/ROADMAP.md

‎benchmarks/frames/configs/base-e5small-full.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ splitter:
3030
indexer:
3131
workers: 8
3232
spawn_first_port: 12000
33+
workdir_path: ${FRAMES_DATA}/workdir-e5small-full
3334
persistence:
3435
enabled: true
3536
backend: filesystem
36-
path: ${FRAMES_DATA}/persist-e5small-full
3737
server:
3838
port: 8987
3939
serve_frontend: false

‎benchmarks/frames/configs/base-e5small.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ splitter:
3030
# thread/port settings of the full-dump run).
3131
indexer:
3232
workers: 8
33+
workdir_path: ${FRAMES_DATA}/workdir-e5small
3334
persistence:
3435
enabled: true
3536
backend: filesystem
36-
path: ${FRAMES_DATA}/persist-e5small
3737
server:
3838
port: 8987
3939
serve_frontend: false

‎benchmarks/realtime-data-indexing/config.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ indexer:
4545
# Product default: persistence on. Lives inside the container (fresh each
4646
# run, since run_bench recreates containers), so runs stay cold-start honest
4747
# while exercising the same code path users get — including the disk-backed
48-
# parse cache under <path>/runtime_calls.
48+
# parse cache under <workdir>/persistence/runtime_calls.
49+
workdir_path: /tmp/serviette-workdir
4950
persistence:
5051
enabled: true
51-
path: /tmp/persistence
5252

5353
server:
5454
host: 0.0.0.0

‎docs/README.md‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,9 @@ string, which would only surface later as a provider 401).
200200
| `splitter.chunk_overlap` | int | `50` | indexer | Overlap (used by `recursive`). |
201201
| `indexer.fetch_retries` | int | `3` | indexer | Retries (exponential backoff from 1s) of a document's byte fetch before it is given up on: indexed as empty and ERROR-logged. Remote sources fail transiently under bulk backfills. |
202202
| `indexer.workers` | int | `1` | indexer | Worker **processes** (sharded via `pathway spawn`). Raise for large backfills — each worker carries its own embedding stack (~1 GB with local embeddings); the benchmarks run with `8`. |
203+
| `workdir_path` | str | `./serviette-workdir` | indexer | serviette's working directory — everything it keeps between runs: the configuration fingerprint at its top level, the Pathway persistence state (and the parse cache, `runtime_calls/`) under `persistence/`. Relative to the process's working directory. Silent default — the wizard does not ask. |
203204
| `persistence.enabled` | bool | `true` | indexer | See [Persistence](#5-persistence). |
204205
| `persistence.backend` | `filesystem` | `filesystem` | indexer | Persistence backend. |
205-
| `persistence.path` | str | `./persistence` | indexer | serviette's data directory: the configuration fingerprint at its top level, the Pathway persistence state (and the parse cache, `runtime_calls/`) under `PStorage/`. Silent default — the wizard does not ask. |
206206
| `server.host` / `server.port` | str / int | `127.0.0.1` / `8989` | server | Bind address. Loopback by default; set `0.0.0.0` explicitly to listen on all interfaces (containers, remote access) — see [Security](#security--exposing-the-server). |
207207
| `server.serve_frontend` | bool | `true` | server | Serve the chat UI on `/` from the same port (API stays under `/api/v1`). |
208208
| `server.cors_origins` | list[str] | `[]` (disabled) | server | Opt-in CORS allowlist for third-party browser frontends calling the API directly from another origin. serviette's own UIs never need it. Prefer exact origins over `*`. |
@@ -367,14 +367,26 @@ the last run, so:
367367
- **documents removed while the indexer was down are correctly retracted** from
368368
the vector DB on the next run.
369369

370-
The engine's state lives under `<persistence.path>/PStorage/` (the top level of
371-
`persistence.path` is reserved for serviette's own files, such as the
372-
configuration fingerprint); the same `PStorage/` also hosts the **parse cache**
373-
(`runtime_calls/`, via `pw.udfs.DefaultCache` — diskcache, LRU-bounded by
370+
The engine's state lives under `<workdir_path>/persistence/` (the top level of
371+
the working directory is reserved for serviette's own files, such as the
372+
configuration fingerprint — the engine logs an ERROR for every foreign entry
373+
in the directory it is given, so the two never share a folder); the same
374+
`persistence/` also hosts the **parse cache** (`runtime_calls/`, via
375+
`pw.udfs.DefaultCache` — diskcache, LRU-bounded by
374376
`indexer.parse_cache_size_gb`, default 8): extracted document text stays warm
375377
across restarts, so unchanged documents are neither re-downloaded nor
376378
re-parsed. Disabling persistence also disables the parse cache.
377379

380+
The **configuration fingerprint** (`<workdir_path>/serviette-fingerprint.json`)
381+
records the chunking-relevant settings the working directory was indexed with
382+
(splitter, parser routing, embedder identity, library versions, chunk-id
383+
scheme). A change is reported as a diff on startup and the indexer refuses to
384+
run unless confirmed (interactively, or with
385+
`SERVIETTE_ACCEPT_FINGERPRINT_CHANGES=1`), because rows already in the vector
386+
DB keep their old chunk ids and would be left behind as orphans. The check
387+
runs with persistence on or off: a restart without persistence re-indexes
388+
everything, but does not remove those rows either.
389+
378390
A document whose bytes cannot be fetched (after `indexer.fetch_retries`
379391
retries) or parsed does not stop the indexer: it is indexed as **empty** and
380392
logged at ERROR level with its path. That result sticks for this version of

‎serviette/config/schema.py‎

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ class IndexerConfig(BaseModel):
584584
# limits, connection resets — so 0 is only for tests.
585585
fetch_retries: int = Field(default=3, ge=0)
586586
# Advanced. Disk budget for the parse cache (pw.udfs.DefaultCache, stored
587-
# under <persistence dir>/PStorage/runtime_calls, LRU-evicted). Size it at least to
587+
# under <workdir>/persistence/runtime_calls, LRU-evicted). Size it at least to
588588
# the extracted-text volume of the corpus to avoid re-parse churn.
589589
parse_cache_size_gb: int = Field(default=8, ge=1)
590590
# Advanced. If set, the engine keeps the memoization cache of
@@ -610,34 +610,28 @@ class PersistenceConfig(BaseModel):
610610
Carries three things: incremental state across restarts (no re-embedding
611611
of unchanged documents), correct retraction of files deleted while the
612612
indexer was down, and the parse cache (``runtime_calls``). Advanced users
613-
tune or disable it by editing the config directly; disabling also
614-
disables the parse cache.
615-
616-
Layout: ``path`` is serviette's data directory. serviette's own artifacts
617-
(the configuration fingerprint) live at its top level; the Pathway engine
618-
gets the ``PStorage`` subdirectory (:meth:`engine_path`) to itself. The
619-
engine treats every entry of its directory as persisted state and logs an
620-
ERROR for anything it did not write, so the two must not share a folder.
613+
disable it by editing the config directly; disabling also disables the
614+
parse cache.
615+
616+
The state lives in the ``persistence`` subdirectory of the working
617+
directory (``workdir_path``, see :meth:`ServietteConfig.persistence_dir`);
618+
there is no separate path to configure.
621619
"""
622620

623621
model_config = ConfigDict(extra="forbid")
624622

625-
ENGINE_SUBDIR: ClassVar[str] = "PStorage"
626-
627623
enabled: bool = True
628624
backend: Literal["filesystem"] = "filesystem"
629-
# Relative to the indexer's working directory; writable out of the box.
630-
path: str = "./persistence"
631-
632-
def data_path(self) -> Path:
633-
"""serviette's data directory (fingerprint and other own artifacts)."""
634-
635-
return Path(self.path)
636625

637-
def engine_path(self) -> Path:
638-
"""The directory handed to the Pathway persistence backend."""
639-
640-
return self.data_path() / self.ENGINE_SUBDIR
626+
@model_validator(mode="before")
627+
@classmethod
628+
def _no_path_here(cls, data: Any) -> Any:
629+
if isinstance(data, dict) and "path" in data:
630+
raise ValueError(
631+
"persistence.path is not a setting: the persistence state lives "
632+
"in the working directory — set the top-level workdir_path instead."
633+
)
634+
return data
641635

642636

643637
class ServerConfig(BaseModel):
@@ -722,6 +716,13 @@ class ServietteConfig(BaseModel):
722716
parser: list[ParserRule] | None = None
723717
indexer: IndexerConfig = Field(default_factory=IndexerConfig)
724718

719+
# serviette's working directory: everything it keeps on disk between
720+
# runs goes under here, each concern in its own subdirectory — the
721+
# Pathway persistence state (``persistence/``, together with the parse
722+
# cache), and the configuration fingerprint at the top level. Relative
723+
# paths resolve against the process's working directory, like every other
724+
# path in the config. Silent default — the wizard does not ask.
725+
workdir_path: str = "./serviette-workdir"
725726
persistence: PersistenceConfig = Field(default_factory=PersistenceConfig)
726727
server: ServerConfig = Field(default_factory=ServerConfig)
727728
up: UpConfig = Field(default_factory=UpConfig)
@@ -730,6 +731,23 @@ class ServietteConfig(BaseModel):
730731
rag: RagConfig | None = None
731732
frontend: FrontendConfig | None = None
732733

734+
# -- working directory layout ---------------------------------------------
735+
736+
PERSISTENCE_SUBDIR: ClassVar[str] = "persistence"
737+
738+
def workdir(self) -> Path:
739+
return Path(self.workdir_path)
740+
741+
def persistence_dir(self) -> Path:
742+
"""The directory handed to the Pathway persistence backend.
743+
744+
A subdirectory of its own because the engine treats every entry of the
745+
directory it is given as persisted state and logs an ERROR for
746+
anything it did not write.
747+
"""
748+
749+
return self.workdir() / self.PERSISTENCE_SUBDIR
750+
733751
# -- per-component requirements -------------------------------------------
734752

735753
def require(self, *fields: str) -> None:

‎serviette/demo/__init__.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,8 @@ def build_demo_config(
105105
"path": str(demo_dir / "embeddings.duckdb"),
106106
"table": "demo_embeddings",
107107
},
108-
"persistence": {
109-
"enabled": True,
110-
"backend": "filesystem",
111-
"path": str(demo_dir / "persistence"),
112-
},
108+
"workdir_path": str(demo_dir / "workdir"),
109+
"persistence": {"enabled": True, "backend": "filesystem"},
113110
"server": {"host": "127.0.0.1", "port": port},
114111
}
115112
if embedder == "sentence_transformer":
@@ -167,7 +164,7 @@ def _reset_index_if_embedder_changed(demo_dir: Path, embedder: str) -> None:
167164
embedder,
168165
)
169166
(demo_dir / "embeddings.duckdb").unlink(missing_ok=True)
170-
shutil.rmtree(demo_dir / "persistence", ignore_errors=True)
167+
shutil.rmtree(demo_dir / "workdir", ignore_errors=True)
171168

172169

173170
def prepare_demo_dir(

‎serviette/frontend/main.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ def create_app(config: ServietteConfig, *, client: httpx.AsyncClient | None = No
5656
async def lifespan(_: FastAPI):
5757
nonlocal client
5858
if client is None:
59-
client = httpx.AsyncClient(base_url=fe.api_url, timeout=120.0)
59+
# trust_env=False: the API is a sibling service (same host or
60+
# network); routing its calls through the environment's
61+
# http_proxy turns a working setup into "Could not reach the
62+
# API" wherever a corporate proxy is configured.
63+
client = httpx.AsyncClient(base_url=fe.api_url, timeout=120.0, trust_env=False)
6064
try:
6165
yield
6266
finally:

‎serviette/indexer/fingerprint.py‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
keep orphaned rows.
1010
1111
To catch that, the indexer stores the full risk-relevant objects (not just a
12-
hash) in ``<persistence dir>/serviette-fingerprint.json`` (next to the engine's
13-
own ``PStorage`` subdirectory, never inside it — the engine logs an ERROR for
14-
every foreign entry in its directory):
12+
hash) in ``<workdir>/serviette-fingerprint.json`` (at the top level of the
13+
working directory, never inside the engine's ``persistence/`` subdirectory —
14+
the engine logs an ERROR for every foreign entry in its directory):
1515
1616
- the splitter config,
1717
- the embedder identity: type, model, ``document_prefix`` and every extra
@@ -24,10 +24,12 @@
2424
deterministic UDF too, so rows written under an older scheme cannot be
2525
retracted by a newer one.
2626
27-
On startup with persistence enabled the stored objects are compared with the
28-
current ones. Any difference is reported as an explicit diff with a
29-
human-readable explanation of the risk, and the indexer refuses to start
30-
unless the user confirms — interactively on a TTY, or via
27+
On startup the stored objects are compared with the current ones. The check
28+
does not depend on persistence being enabled: without it a restart re-indexes
29+
every document, and rows written under the old settings (other chunk ids)
30+
stay in the vector DB just the same. Any difference is reported as an
31+
explicit diff with a human-readable explanation of the risk, and the indexer
32+
refuses to start unless the user confirms — interactively on a TTY, or via
3133
``SERVIETTE_ACCEPT_FINGERPRINT_CHANGES=1`` in non-interactive deployments.
3234
Confirmation updates the stored fingerprint.
3335
"""
@@ -183,11 +185,9 @@ def _confirm(diff_lines: list[str]) -> bool:
183185

184186

185187
def check_fingerprint(config: ServietteConfig) -> None:
186-
"""Verify (and maintain) the persisted fingerprint; may abort startup."""
188+
"""Verify (and maintain) the stored fingerprint; may abort startup."""
187189

188-
if not config.persistence.enabled:
189-
return # no persisted state to be inconsistent with
190-
directory = config.persistence.data_path()
190+
directory = config.workdir()
191191
directory.mkdir(parents=True, exist_ok=True)
192192
path = directory / _FILENAME
193193

@@ -208,8 +208,8 @@ def check_fingerprint(config: ServietteConfig) -> None:
208208
return
209209

210210
logger.critical(
211-
"The indexing configuration differs from the one this persistence "
212-
"directory (%s) was built with:\n%s",
211+
"The indexing configuration differs from the one this working "
212+
"directory (%s) was indexed with:\n%s",
213213
directory,
214214
"\n".join(" " + line for line in diff_lines),
215215
)
@@ -218,7 +218,8 @@ def check_fingerprint(config: ServietteConfig) -> None:
218218
"Refusing to start: the configuration change above can corrupt "
219219
"incremental updates of already-indexed documents. Either revert "
220220
"the change, re-index from scratch (drop the vector collection "
221-
f"and {directory}), or set {_ACCEPT_ENV}=1 / confirm interactively "
221+
f"and the working directory {directory}), or set {_ACCEPT_ENV}=1 / "
222+
"confirm interactively "
222223
"to accept the risks."
223224
)
224225
path.write_text(json.dumps(current, indent=2, sort_keys=True))

0 commit comments

Comments
 (0)