Spaces:
Running
Running
feat(mcp): concurrent builds (bounded) — remove single-flight lock
Browse filesThe build pipeline is already concurrency-safe (app/QA have run concurrent builds
for months); the MCP's single-flight _build_lock was an unnecessary v1 guard.
- Replace _build_lock with a BoundedSemaphore (MCP_MAX_CONCURRENT_BUILDS, default 3):
builds run in parallel; only a call past the cap gets "busy".
- Track multiple builds: _current_build -> _active_builds{run_id: info}; _set_progress
targets a run_id; status returns active_builds[] + active_count + capacity (plus a
back-compat current_build when exactly one runs).
- Remove the vestigial os.environ["DEMOPREP_DEV_USER_EMAIL"]=owner write — owner is
per-instance, so concurrent builds don't race on it (audited).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mcp_server.py +44 -34
mcp_server.py
CHANGED
|
@@ -106,10 +106,13 @@ def _resolve_ts_target(ts_url: str) -> tuple[str, str]:
|
|
| 106 |
from mcp.server.fastmcp import FastMCP
|
| 107 |
from mcp.server.transport_security import TransportSecuritySettings
|
| 108 |
|
| 109 |
-
#
|
| 110 |
-
#
|
| 111 |
-
#
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
# Lightweight hit log so we can watch incoming calls (who + what args). Writes to
|
| 115 |
# stderr (the server log) and, if MCP_HIT_LOG is set, appends to that file too.
|
|
@@ -131,18 +134,19 @@ def _log_hit(msg: str) -> None:
|
|
| 131 |
_SERVER_STARTED = time.time()
|
| 132 |
_state_lock = threading.Lock()
|
| 133 |
_build_count = 0
|
| 134 |
-
|
| 135 |
_last_build = None # summary of the most recent completed build
|
| 136 |
|
| 137 |
|
| 138 |
-
def _set_progress(phase: str | None = None, detail: str | None = None) -> None:
|
| 139 |
-
"""Update
|
| 140 |
with _state_lock:
|
| 141 |
-
|
|
|
|
| 142 |
if phase:
|
| 143 |
-
|
| 144 |
if detail is not None:
|
| 145 |
-
|
| 146 |
|
| 147 |
|
| 148 |
def _progress_text(item) -> str:
|
|
@@ -225,16 +229,21 @@ def status() -> dict:
|
|
| 225 |
_log_hit("CALL status")
|
| 226 |
now = time.time()
|
| 227 |
with _state_lock:
|
| 228 |
-
|
| 229 |
last = dict(_last_build) if _last_build else None
|
| 230 |
count = _build_count
|
| 231 |
-
|
| 232 |
-
|
|
|
|
| 233 |
return {
|
| 234 |
"server": "up",
|
| 235 |
"default_ts_url": os.getenv("TS_ENV_URL_DEFAULT", ""),
|
| 236 |
-
"busy":
|
| 237 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
"last_build": last,
|
| 239 |
"builds_started": count,
|
| 240 |
"uptime_seconds": round(now - _SERVER_STARTED, 1),
|
|
@@ -244,7 +253,7 @@ def status() -> dict:
|
|
| 244 |
def _run_build(run_id: str, brief: str, company_name: str, use_case: str, company_url: str,
|
| 245 |
ts_target_url: str, ts_auth_key: str, owner: str) -> dict:
|
| 246 |
"""Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
|
| 247 |
-
already registered
|
| 248 |
returns). Mirrors tests/newvision_sample_runner.py but injects `brief` in place of
|
| 249 |
the research phase. Returns a structured dict and never raises — failures come back
|
| 250 |
as status 'failed' / 'partial'."""
|
|
@@ -269,7 +278,7 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
|
|
| 269 |
"errors": ([error] if error else dc.get("errors", [])),
|
| 270 |
"elapsed_seconds": round(time.time() - started, 1),
|
| 271 |
}
|
| 272 |
-
global _last_build
|
| 273 |
with _state_lock:
|
| 274 |
_last_build = {
|
| 275 |
"run_id": out["run_id"], "status": out["status"], "schema": out["schema"],
|
|
@@ -278,13 +287,13 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
|
|
| 278 |
"ddl": out["ddl"],
|
| 279 |
"finished_at": time.strftime("%H:%M:%S"),
|
| 280 |
}
|
| 281 |
-
|
| 282 |
return out
|
| 283 |
|
| 284 |
controller = None
|
| 285 |
try:
|
| 286 |
-
# (i) controller — acting user = the per-build owner
|
| 287 |
-
|
| 288 |
controller = ChatDemoInterface(user_email=owner)
|
| 289 |
|
| 290 |
# (ii) settings: model + fixed TS env (exact key names per the wiring trace)
|
|
@@ -312,23 +321,24 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
|
|
| 312 |
controller.generic_use_case_context = ""
|
| 313 |
|
| 314 |
# (iv) DDL — returns a (response, ddl) tuple; NOT a generator.
|
| 315 |
-
_set_progress(phase="building dataset + DDL", detail="")
|
| 316 |
resp, ddl_text = controller.run_ddl_creation()
|
| 317 |
if not ddl_text or "CREATE TABLE" not in ddl_text.upper():
|
| 318 |
return result("failed", error=f"DDL generation failed: {str(resp)[:500]}")
|
| 319 |
# Surface the DDL immediately — it exists ~5 min in, well before the ~15-min
|
| 320 |
# TS deploy — so a caller polling status() gets the schema as soon as it's ready.
|
| 321 |
with _state_lock:
|
| 322 |
-
|
| 323 |
-
|
|
|
|
| 324 |
|
| 325 |
# (v) Snowflake load, then ThoughtSpot. Both are generators — draining them
|
| 326 |
# IS what runs the work. Decoupled from validation_mode: drain the Snowflake
|
| 327 |
# generator, read the schema it set, then run the TS deploy ourselves.
|
| 328 |
-
_set_progress(phase="loading Snowflake", detail="")
|
| 329 |
for _item in controller.run_deployment_streaming():
|
| 330 |
_t = _progress_text(_item)
|
| 331 |
-
_set_progress(phase=_phase_from_text(_t), detail=_t)
|
| 332 |
schema = getattr(controller, "_deployed_schema_name", None)
|
| 333 |
if not schema:
|
| 334 |
return result(
|
|
@@ -337,13 +347,13 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
|
|
| 337 |
error="Snowflake load did not complete (no deployed schema).",
|
| 338 |
)
|
| 339 |
|
| 340 |
-
_set_progress(phase="deploying ThoughtSpot model + liveboard", detail="")
|
| 341 |
# Real-time status: on_progress fires from the deploy thread for EVERY
|
| 342 |
# progress line (incl. the [async] import + model/liveboard steps), so
|
| 343 |
# status.current_build.detail tracks live instead of freezing between yields.
|
| 344 |
def _dp(m):
|
| 345 |
_t = _progress_text(m)
|
| 346 |
-
_set_progress(phase=_phase_from_text(_t), detail=_t)
|
| 347 |
for _item in controller._run_thoughtspot_deployment(schema, company_name, use_case, on_progress=_dp):
|
| 348 |
_dp(_item)
|
| 349 |
|
|
@@ -421,20 +431,20 @@ def build_demo_from_brief(
|
|
| 421 |
except RuntimeError as e:
|
| 422 |
return {"status": "failed", "errors": [str(e)]}
|
| 423 |
|
| 424 |
-
#
|
| 425 |
-
# 'busy'
|
| 426 |
-
if not
|
| 427 |
return {
|
| 428 |
"status": "busy",
|
| 429 |
-
"errors": ["
|
| 430 |
}
|
| 431 |
|
| 432 |
run_id = uuid.uuid4().hex[:12]
|
| 433 |
b, c, u, url = brief.strip(), company_name.strip(), (use_case or "Custom Analytics").strip(), (company_url or "").strip()
|
| 434 |
-
global _build_count
|
| 435 |
with _state_lock:
|
| 436 |
_build_count += 1
|
| 437 |
-
|
| 438 |
"run_id": run_id, "company_name": c, "use_case": u,
|
| 439 |
"ts_url": ts_target_url,
|
| 440 |
"started_at": time.time(), "phase": "starting",
|
|
@@ -446,7 +456,7 @@ def build_demo_from_brief(
|
|
| 446 |
try:
|
| 447 |
_run_build(run_id, b, c, u, url, ts_target_url, ts_auth_key, owner)
|
| 448 |
finally:
|
| 449 |
-
|
| 450 |
|
| 451 |
threading.Thread(target=_worker, daemon=True, name=f"build-{run_id}").start()
|
| 452 |
return {
|
|
|
|
| 106 |
from mcp.server.fastmcp import FastMCP
|
| 107 |
from mcp.server.transport_security import TransportSecuritySettings
|
| 108 |
|
| 109 |
+
# Bounded concurrency: the build pipeline is already concurrency-safe (the app/QA
|
| 110 |
+
# have run concurrent builds for months), so this is only a resource guardrail — a
|
| 111 |
+
# semaphore caps how many run at once so a caller can't OOM the Space by firing
|
| 112 |
+
# dozens; a call past the cap gets "busy". Owner/env/creds are per-instance, so
|
| 113 |
+
# concurrent builds share no mutable process state.
|
| 114 |
+
_MAX_CONCURRENT_BUILDS = max(1, int(os.getenv("MCP_MAX_CONCURRENT_BUILDS", "3")))
|
| 115 |
+
_build_sem = threading.BoundedSemaphore(_MAX_CONCURRENT_BUILDS)
|
| 116 |
|
| 117 |
# Lightweight hit log so we can watch incoming calls (who + what args). Writes to
|
| 118 |
# stderr (the server log) and, if MCP_HIT_LOG is set, appends to that file too.
|
|
|
|
| 134 |
_SERVER_STARTED = time.time()
|
| 135 |
_state_lock = threading.Lock()
|
| 136 |
_build_count = 0
|
| 137 |
+
_active_builds = {} # run_id -> live build dict; supports multiple concurrent builds
|
| 138 |
_last_build = None # summary of the most recent completed build
|
| 139 |
|
| 140 |
|
| 141 |
+
def _set_progress(run_id: str, phase: str | None = None, detail: str | None = None) -> None:
|
| 142 |
+
"""Update a running build's coarse phase and/or latest raw progress line."""
|
| 143 |
with _state_lock:
|
| 144 |
+
b = _active_builds.get(run_id)
|
| 145 |
+
if b is not None:
|
| 146 |
if phase:
|
| 147 |
+
b["phase"] = phase
|
| 148 |
if detail is not None:
|
| 149 |
+
b["detail"] = detail
|
| 150 |
|
| 151 |
|
| 152 |
def _progress_text(item) -> str:
|
|
|
|
| 229 |
_log_hit("CALL status")
|
| 230 |
now = time.time()
|
| 231 |
with _state_lock:
|
| 232 |
+
active = [dict(b) for b in _active_builds.values()]
|
| 233 |
last = dict(_last_build) if _last_build else None
|
| 234 |
count = _build_count
|
| 235 |
+
for b in active:
|
| 236 |
+
b["elapsed_seconds"] = round(now - b.pop("started_at", now), 1)
|
| 237 |
+
active.sort(key=lambda b: b.get("elapsed_seconds", 0), reverse=True)
|
| 238 |
return {
|
| 239 |
"server": "up",
|
| 240 |
"default_ts_url": os.getenv("TS_ENV_URL_DEFAULT", ""),
|
| 241 |
+
"busy": len(active) > 0,
|
| 242 |
+
"active_count": len(active),
|
| 243 |
+
"capacity": _MAX_CONCURRENT_BUILDS,
|
| 244 |
+
"active_builds": active,
|
| 245 |
+
# Back-compat for single-build pollers: present only when exactly one runs.
|
| 246 |
+
"current_build": active[0] if len(active) == 1 else None,
|
| 247 |
"last_build": last,
|
| 248 |
"builds_started": count,
|
| 249 |
"uptime_seconds": round(now - _SERVER_STARTED, 1),
|
|
|
|
| 253 |
def _run_build(run_id: str, brief: str, company_name: str, use_case: str, company_url: str,
|
| 254 |
ts_target_url: str, ts_auth_key: str, owner: str) -> dict:
|
| 255 |
"""Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
|
| 256 |
+
already registered the build in _active_builds and holds a _build_sem slot (released when this
|
| 257 |
returns). Mirrors tests/newvision_sample_runner.py but injects `brief` in place of
|
| 258 |
the research phase. Returns a structured dict and never raises — failures come back
|
| 259 |
as status 'failed' / 'partial'."""
|
|
|
|
| 278 |
"errors": ([error] if error else dc.get("errors", [])),
|
| 279 |
"elapsed_seconds": round(time.time() - started, 1),
|
| 280 |
}
|
| 281 |
+
global _last_build
|
| 282 |
with _state_lock:
|
| 283 |
_last_build = {
|
| 284 |
"run_id": out["run_id"], "status": out["status"], "schema": out["schema"],
|
|
|
|
| 287 |
"ddl": out["ddl"],
|
| 288 |
"finished_at": time.strftime("%H:%M:%S"),
|
| 289 |
}
|
| 290 |
+
_active_builds.pop(run_id, None)
|
| 291 |
return out
|
| 292 |
|
| 293 |
controller = None
|
| 294 |
try:
|
| 295 |
+
# (i) controller — acting user = the per-build owner (per-instance; no
|
| 296 |
+
# process-global write, so concurrent builds never race on the owner).
|
| 297 |
controller = ChatDemoInterface(user_email=owner)
|
| 298 |
|
| 299 |
# (ii) settings: model + fixed TS env (exact key names per the wiring trace)
|
|
|
|
| 321 |
controller.generic_use_case_context = ""
|
| 322 |
|
| 323 |
# (iv) DDL — returns a (response, ddl) tuple; NOT a generator.
|
| 324 |
+
_set_progress(run_id, phase="building dataset + DDL", detail="")
|
| 325 |
resp, ddl_text = controller.run_ddl_creation()
|
| 326 |
if not ddl_text or "CREATE TABLE" not in ddl_text.upper():
|
| 327 |
return result("failed", error=f"DDL generation failed: {str(resp)[:500]}")
|
| 328 |
# Surface the DDL immediately — it exists ~5 min in, well before the ~15-min
|
| 329 |
# TS deploy — so a caller polling status() gets the schema as soon as it's ready.
|
| 330 |
with _state_lock:
|
| 331 |
+
b = _active_builds.get(run_id)
|
| 332 |
+
if b is not None:
|
| 333 |
+
b["ddl"] = ddl_text
|
| 334 |
|
| 335 |
# (v) Snowflake load, then ThoughtSpot. Both are generators — draining them
|
| 336 |
# IS what runs the work. Decoupled from validation_mode: drain the Snowflake
|
| 337 |
# generator, read the schema it set, then run the TS deploy ourselves.
|
| 338 |
+
_set_progress(run_id, phase="loading Snowflake", detail="")
|
| 339 |
for _item in controller.run_deployment_streaming():
|
| 340 |
_t = _progress_text(_item)
|
| 341 |
+
_set_progress(run_id, phase=_phase_from_text(_t), detail=_t)
|
| 342 |
schema = getattr(controller, "_deployed_schema_name", None)
|
| 343 |
if not schema:
|
| 344 |
return result(
|
|
|
|
| 347 |
error="Snowflake load did not complete (no deployed schema).",
|
| 348 |
)
|
| 349 |
|
| 350 |
+
_set_progress(run_id, phase="deploying ThoughtSpot model + liveboard", detail="")
|
| 351 |
# Real-time status: on_progress fires from the deploy thread for EVERY
|
| 352 |
# progress line (incl. the [async] import + model/liveboard steps), so
|
| 353 |
# status.current_build.detail tracks live instead of freezing between yields.
|
| 354 |
def _dp(m):
|
| 355 |
_t = _progress_text(m)
|
| 356 |
+
_set_progress(run_id, phase=_phase_from_text(_t), detail=_t)
|
| 357 |
for _item in controller._run_thoughtspot_deployment(schema, company_name, use_case, on_progress=_dp):
|
| 358 |
_dp(_item)
|
| 359 |
|
|
|
|
| 431 |
except RuntimeError as e:
|
| 432 |
return {"status": "failed", "errors": [str(e)]}
|
| 433 |
|
| 434 |
+
# Bounded concurrency: grab a build slot (non-blocking). Only when ALL slots are
|
| 435 |
+
# in use does a call get 'busy' — otherwise it runs alongside the others.
|
| 436 |
+
if not _build_sem.acquire(blocking=False):
|
| 437 |
return {
|
| 438 |
"status": "busy",
|
| 439 |
+
"errors": [f"All {_MAX_CONCURRENT_BUILDS} build slots are in use. Poll status(); retry when one frees."],
|
| 440 |
}
|
| 441 |
|
| 442 |
run_id = uuid.uuid4().hex[:12]
|
| 443 |
b, c, u, url = brief.strip(), company_name.strip(), (use_case or "Custom Analytics").strip(), (company_url or "").strip()
|
| 444 |
+
global _build_count
|
| 445 |
with _state_lock:
|
| 446 |
_build_count += 1
|
| 447 |
+
_active_builds[run_id] = {
|
| 448 |
"run_id": run_id, "company_name": c, "use_case": u,
|
| 449 |
"ts_url": ts_target_url,
|
| 450 |
"started_at": time.time(), "phase": "starting",
|
|
|
|
| 456 |
try:
|
| 457 |
_run_build(run_id, b, c, u, url, ts_target_url, ts_auth_key, owner)
|
| 458 |
finally:
|
| 459 |
+
_build_sem.release()
|
| 460 |
|
| 461 |
threading.Thread(target=_worker, daemon=True, name=f"build-{run_id}").start()
|
| 462 |
return {
|