Skip to content

Commit 4e047b3

Browse files
committed
0.3.1: sync locking, dry-run API, env var interpolation
- Per-KB asyncio.Lock prevents overlapping syncs from scheduler, webhook, and API triggers running simultaneously - POST /sync/{id}?dry_run=true previews changes without uploading - ${VAR} and ${VAR:-default} interpolation in all .oikb.yaml strings for GitOps workflows where secrets come from the runtime
1 parent 3a8396e commit 4e047b3

6 files changed

Lines changed: 91 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.3.1] - 2025-05-21
8+
9+
### Added
10+
11+
- Per-KB sync locking — prevents overlapping syncs to the same Knowledge Base. If a webhook fires while a scheduled sync is running, the duplicate is skipped with a log message.
12+
- Dry-run via API — `POST /sync/{id}?dry_run=true` previews changes without uploading, returns added/modified/deleted counts.
13+
- Environment variable interpolation in `.oikb.yaml``${VAR}` and `${VAR:-default}` syntax in all string values. Enables GitOps workflows where secrets come from the runtime.
14+
715
## [0.3.0] - 2025-05-21
816

917
### Added

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ Resolved in order (highest priority wins):
170170
2. **Environment variables** (`OPEN_WEBUI_URL`, `OPEN_WEBUI_API_KEY`)
171171
3. **Config file** (`~/.config/oikb/config.yaml`)
172172

173+
All string values in `.oikb.yaml` support `${VAR}` and `${VAR:-default}` interpolation:
174+
175+
```yaml
176+
sources:
177+
- name: docs
178+
source: github:${GITHUB_ORG}/docs
179+
kb-id: ${KB_DOCS_ID}
180+
token: ${GITHUB_TOKEN}
181+
notify:
182+
url: ${SLACK_WEBHOOK:-https://hooks.slack.com/default}
183+
```
184+
173185
## History
174186

175187
```bash

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oikb"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
description = "Sync anything to Open WebUI Knowledge Bases"
55
readme = "README.md"
66
authors = [

src/oikb/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""oikb — Open WebUI Knowledge Base CLI."""
22

3-
__version__ = "0.3.0"
3+
__version__ = "0.3.1"

src/oikb/cli.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import sys
77
from pathlib import Path
8+
from typing import Any
89

910
import click
1011

@@ -272,6 +273,28 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
272273
return FilesystemConnector(source)
273274

274275

276+
def _interpolate_env(obj: Any) -> Any:
277+
"""Recursively interpolate ${VAR} and ${VAR:-default} in string values."""
278+
import re
279+
280+
_ENV_RE = re.compile(r"\$\{([^}]+)\}")
281+
282+
def _replace(match: re.Match) -> str:
283+
expr = match.group(1)
284+
if ":-" in expr:
285+
var, default = expr.split(":-", 1)
286+
return os.environ.get(var, default)
287+
return os.environ.get(expr, match.group(0))
288+
289+
if isinstance(obj, str):
290+
return _ENV_RE.sub(_replace, obj)
291+
if isinstance(obj, dict):
292+
return {k: _interpolate_env(v) for k, v in obj.items()}
293+
if isinstance(obj, list):
294+
return [_interpolate_env(item) for item in obj]
295+
return obj
296+
297+
275298
def _load_oikb_yaml() -> list[dict] | None:
276299
"""Load .oikb.yaml from the current directory if it exists."""
277300
import yaml
@@ -286,6 +309,9 @@ def _load_oikb_yaml() -> list[dict] | None:
286309
if not data:
287310
return None
288311

312+
# Interpolate environment variables in all string values.
313+
data = _interpolate_env(data)
314+
289315
# Prefer sources: (new), fall back to sync: (legacy).
290316
entries = data.get("sources") or data.get("sync")
291317
return entries or None
@@ -789,6 +815,7 @@ def daemon(port: int, no_server: bool, config_file: str | None, log_format: str
789815
import yaml
790816
with open(config_file) as f:
791817
data = yaml.safe_load(f)
818+
data = _interpolate_env(data) if data else data
792819
entries = (data.get("sources") or data.get("sync", [])) if data else []
793820
else:
794821
entries = _load_oikb_yaml()

src/oikb/daemon.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ async def verify_api_key(
4040
app = FastAPI(
4141
title="oikb",
4242
description="Sync engine for Open WebUI Knowledge Bases. Trigger syncs, check status, and query history.",
43-
version="0.3.0",
43+
version="0.3.1",
4444
)
4545

4646
# Runtime state populated by start_daemon().
4747
_scheduler_state: dict[str, dict[str, Any]] = {}
48+
_sync_locks: dict[str, asyncio.Lock] = {}
4849
_history: SyncHistory | None = None
4950
_entries: list[dict] = []
5051
_shutdown_event: asyncio.Event | None = None
@@ -128,10 +129,13 @@ async def history_endpoint(
128129
summary="Trigger an immediate sync by alias or KB ID",
129130
dependencies=[Depends(verify_api_key)],
130131
)
131-
async def trigger_sync(identifier: str):
132-
"""Triggers an immediate sync matching the given alias or Knowledge Base ID. The sync runs asynchronously in the background. Use get_sync_status to check progress."""
132+
async def trigger_sync(identifier: str, dry_run: bool = False):
133+
"""Triggers an immediate sync matching the given alias or Knowledge Base ID. The sync runs asynchronously in the background. Use get_sync_status to check progress. Set dry_run=true to preview changes without uploading."""
133134
for entry in _entries:
134135
if entry.get("name") == identifier or entry.get("kb-id") == identifier:
136+
if dry_run:
137+
result = await _run_entry(entry, dry_run=True)
138+
return {"dry_run": True, "name": entry.get("name"), "kb_id": entry.get("kb-id"), "result": result}
135139
asyncio.create_task(_run_entry(entry))
136140
return {"triggered": True, "name": entry.get("name"), "kb_id": entry.get("kb-id")}
137141
return {"triggered": False, "error": f"No entry matching '{identifier}'"}
@@ -179,8 +183,29 @@ async def _send_notification(entry: dict, payload: dict) -> None:
179183
log.warning(f"Notification failed for {source}: {exc}")
180184

181185

182-
async def _run_entry(entry: dict) -> None:
183-
"""Run a single sync for an entry."""
186+
async def _run_entry(entry: dict, dry_run: bool = False) -> dict | None:
187+
"""Run a single sync for an entry.
188+
189+
Uses a per-KB lock to prevent overlapping syncs to the same
190+
Knowledge Base (e.g. webhook fires while a scheduled sync is running).
191+
"""
192+
kb_id = entry["kb-id"]
193+
194+
# Get or create a lock for this KB.
195+
if kb_id not in _sync_locks:
196+
_sync_locks[kb_id] = asyncio.Lock()
197+
lock = _sync_locks[kb_id]
198+
199+
if lock.locked():
200+
log.info(f"Skipping {entry.get('source', '?')} — sync already running for {kb_id}")
201+
return {"skipped": True, "reason": "sync already running"} if dry_run else None
202+
203+
async with lock:
204+
return await _run_entry_locked(entry, dry_run=dry_run)
205+
206+
207+
async def _run_entry_locked(entry: dict, dry_run: bool = False) -> dict | None:
208+
"""Inner sync logic, called under the per-KB lock."""
184209
from oikb.cli import _make_client, _resolve_connector
185210
from oikb.sync import run_sync
186211

@@ -218,16 +243,27 @@ async def _run_entry(entry: dict) -> None:
218243
max_size=parse_size(ms),
219244
)
220245

221-
result = run_sync(
246+
result = await asyncio.to_thread(
247+
run_sync,
222248
client=client,
223249
connector=connector,
224250
kb_id=kb_id,
251+
dry_run=dry_run,
225252
quiet=True,
226253
manifest_filter=mf,
227254
concurrency=entry.get("concurrency", 1),
228255
)
229256
client.close()
230257

258+
if dry_run:
259+
return {
260+
"added": result.added,
261+
"modified": result.modified,
262+
"deleted": result.deleted,
263+
"unmodified": result.unmodified,
264+
"summary": result.summary(),
265+
}
266+
231267
duration_s = time.time() - started_at
232268
duration_ms = int(duration_s * 1000)
233269
status = "success" if not result.errors else "partial"

0 commit comments

Comments
 (0)