mikeboone Claude Opus 4.8 commited on
Commit
296fba8
·
1 Parent(s): cbd0efa

feat(mcp): concurrent builds (bounded) — remove single-flight lock

Browse files

The 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>

Files changed (1) hide show
  1. 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
- # Serialize-first: one build at a time. Protects the process-global os.environ
110
- # writes (Snowflake account) that inject_admin_settings_to_env performs, and
111
- # matches the decision to lift real concurrency later.
112
- _build_lock = threading.Lock()
 
 
 
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
- _current_build = None # dict while a build runs; None when idle
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 the running build's coarse phase and/or the latest raw progress line."""
140
  with _state_lock:
141
- if _current_build is not None:
 
142
  if phase:
143
- _current_build["phase"] = phase
144
  if detail is not None:
145
- _current_build["detail"] = detail
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
- cur = dict(_current_build) if _current_build else None
229
  last = dict(_last_build) if _last_build else None
230
  count = _build_count
231
- if cur is not None:
232
- cur["elapsed_seconds"] = round(now - cur.pop("started_at", now), 1)
 
233
  return {
234
  "server": "up",
235
  "default_ts_url": os.getenv("TS_ENV_URL_DEFAULT", ""),
236
- "busy": cur is not None,
237
- "current_build": cur,
 
 
 
 
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 _current_build and holds _build_lock, releasing it when this
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, _current_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
- _current_build = None
282
  return out
283
 
284
  controller = None
285
  try:
286
- # (i) controller — acting user = the per-build owner.
287
- os.environ["DEMOPREP_DEV_USER_EMAIL"] = owner
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
- if _current_build is not None:
323
- _current_build["ddl"] = ddl_text
 
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
- # Serialize-first: one build at a time. Non-blocking acquire so a second call gets
425
- # 'busy' immediately instead of queuing.
426
- if not _build_lock.acquire(blocking=False):
427
  return {
428
  "status": "busy",
429
- "errors": ["A build is already running (one at a time). Poll status(); retry when idle."],
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, _current_build
435
  with _state_lock:
436
  _build_count += 1
437
- _current_build = {
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
- _build_lock.release()
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 {