Skip to content

Commit 10d094e

Browse files
authored
fix(prompts): share one request between concurrent fetches of the same prompt (#1059)
1 parent a49648e commit 10d094e

3 files changed

Lines changed: 240 additions & 32 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
`Prompts.get()` now makes one request when several threads ask for the same prompt at once, instead of one request per thread. The other threads wait for that result. With `capture_errors=True`, a shared failure is reported once instead of once per thread.

‎posthog/ai/prompts.py‎

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
import copy
88
import logging
99
import math
10+
import os
1011
import re
12+
import threading
1113
import time
1214
import urllib.parse
1315
import warnings
16+
import weakref
1417
from dataclasses import dataclass
1518
from typing import Any, Dict, List, Literal, Optional, Union, overload
1619

@@ -37,6 +40,33 @@
3740
PromptSource = Literal["api", "cache", "stale_cache", "code_fallback"]
3841

3942

43+
class _InFlightFetch:
44+
"""One network request that several callers wait on."""
45+
46+
def __init__(self) -> None:
47+
self.done = threading.Event()
48+
self.result: Optional[CachedPrompt] = None
49+
self.error: Optional[BaseException] = None
50+
51+
52+
# Every live Prompts instance, so one fork hook can reset them all. Registered
53+
# once here, because os.register_at_fork keeps each callback for the process
54+
# lifetime and an app that builds many instances would accumulate them.
55+
_instances: "weakref.WeakSet[Prompts]" = weakref.WeakSet()
56+
57+
58+
def _reset_in_flight_after_fork() -> None:
59+
# The child has no copy of the thread that was fetching, so an inherited
60+
# in-flight entry would hold every later caller for that key.
61+
for prompts in list(_instances):
62+
prompts._in_flight = {}
63+
prompts._in_flight_lock = threading.Lock()
64+
65+
66+
if hasattr(os, "register_at_fork"):
67+
os.register_at_fork(after_in_child=_reset_in_flight_after_fork)
68+
69+
4070
@dataclass(frozen=True)
4171
class PromptResult:
4272
"""Result of a prompt fetch with metadata about its source.
@@ -270,6 +300,12 @@ def __init__(
270300
else DEFAULT_CACHE_TTL_SECONDS
271301
)
272302
self._cache: Dict[PromptCacheKey, CachedPrompt] = {}
303+
# One request per cache key at a time. Threads that arrive while it is in
304+
# flight wait for that result, so a worker with many concurrent callers does
305+
# not turn every cache miss or expiry into one request per caller.
306+
self._in_flight: Dict[PromptCacheKey, _InFlightFetch] = {}
307+
self._in_flight_lock = threading.Lock()
308+
_instances.add(self)
273309
self._has_warned_deprecation = False
274310
self._client = posthog
275311
self._capture_errors = capture_errors
@@ -568,44 +604,18 @@ def _get_internal(
568604

569605
# Try to fetch from API
570606
try:
571-
data = self._fetch_prompt_from_api(name, version, label)
572-
573-
# An older PostHog server ignores the label param and returns the latest
574-
# version with no label field — surface that instead of failing silently.
575-
if label is not None and data.get("label") != label:
576-
log.warning(
577-
"[PostHog Prompts] Requested label %r for prompt %r but the server "
578-
"resolved %r. It may not support prompt labels yet and returned the "
579-
"latest version instead.",
580-
label,
581-
name,
582-
data.get("label"),
583-
)
584-
585-
config = _extract_config(data)
586-
587-
# Update cache
588-
self._cache[cache_key] = CachedPrompt(
589-
prompt=data["prompt"],
590-
fetched_at=time.time(),
591-
name=data["name"],
592-
version=data["version"],
593-
label=data.get("label"),
594-
config=config,
595-
)
607+
fetched = self._fetch_prompt_once(name, version, label, ttl=ttl)
596608

597609
return PromptResult(
598610
source="api",
599-
prompt=data["prompt"],
600-
name=data["name"],
601-
version=data["version"],
602-
label=data.get("label"),
603-
config=copy.deepcopy(config),
611+
prompt=fetched.prompt,
612+
name=fetched.name,
613+
version=fetched.version,
614+
label=fetched.label,
615+
config=copy.deepcopy(fetched.config),
604616
)
605617

606618
except Exception as error:
607-
self._maybe_capture_error(error, name=name, version=version, label=label)
608-
609619
prompt_reference = _prompt_reference(name, version, label)
610620
# Return stale cache (with warning)
611621
if cached is not None:
@@ -634,6 +644,79 @@ def _get_internal(
634644

635645
raise
636646

647+
def _fetch_prompt_once(
648+
self,
649+
name: str,
650+
version: Optional[int],
651+
label: Optional[str],
652+
*,
653+
ttl: float,
654+
) -> CachedPrompt:
655+
"""Fetch a prompt, sharing the request with other threads that want the same key."""
656+
cache_key = _cache_key(name, version, label)
657+
with self._in_flight_lock:
658+
# Another thread may have finished a fetch between the caller's cache
659+
# check and this lock. Its entry is fresh, so serve it instead of
660+
# starting another request. A zero TTL is never fresh and still fetches.
661+
cached = self._cache.get(cache_key)
662+
if cached is not None and (time.time() - cached.fetched_at) < ttl:
663+
return cached
664+
665+
pending = self._in_flight.get(cache_key)
666+
is_leader = pending is None
667+
if pending is None:
668+
pending = _InFlightFetch()
669+
self._in_flight[cache_key] = pending
670+
671+
if not is_leader:
672+
pending.done.wait()
673+
if pending.error is not None:
674+
raise pending.error
675+
assert pending.result is not None
676+
return pending.result
677+
678+
try:
679+
data = self._fetch_prompt_from_api(name, version, label)
680+
681+
# An older PostHog server ignores the label param and returns the latest
682+
# version with no label field — surface that instead of failing silently.
683+
if label is not None and data.get("label") != label:
684+
log.warning(
685+
"[PostHog Prompts] Requested label %r for prompt %r but the server "
686+
"resolved %r. It may not support prompt labels yet and returned the "
687+
"latest version instead.",
688+
label,
689+
name,
690+
data.get("label"),
691+
)
692+
693+
fetched = CachedPrompt(
694+
prompt=data["prompt"],
695+
fetched_at=time.time(),
696+
name=data["name"],
697+
version=data["version"],
698+
label=data.get("label"),
699+
config=_extract_config(data),
700+
)
701+
# Cached before the in-flight entry is released, so a caller that
702+
# arrives in between finds the entry instead of starting a request.
703+
self._cache[cache_key] = fetched
704+
pending.result = fetched
705+
return fetched
706+
except BaseException as error:
707+
pending.error = error
708+
raise
709+
finally:
710+
with self._in_flight_lock:
711+
self._in_flight.pop(cache_key, None)
712+
pending.done.set()
713+
# Reported after the waiters are released, so an error-tracking callback
714+
# that fetches the same prompt cannot wait on this very request.
715+
if isinstance(pending.error, Exception):
716+
self._maybe_capture_error(
717+
pending.error, name=name, version=version, label=label
718+
)
719+
637720
def compile(self, prompt: str, variables: PromptVariables) -> str:
638721
"""
639722
Replace {{variableName}} placeholders with values.

‎posthog/test/ai/test_prompts.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import threading
12
import unittest
23
import warnings
34
from unittest.mock import MagicMock, patch
@@ -382,6 +383,125 @@ def test_hold_a_cooldown_after_each_failed_refetch(
382383
self.assertEqual(result.source, "api")
383384
self.assertEqual(mock_get.call_count, 4)
384385

386+
def _run_concurrent_gets(self, prompts, calls):
387+
results = [None] * len(calls)
388+
start_together = threading.Barrier(len(calls))
389+
390+
def run(index, kwargs):
391+
start_together.wait(5)
392+
results[index] = prompts.get("test-prompt", with_metadata=True, **kwargs)
393+
394+
threads = [
395+
threading.Thread(target=run, args=(index, kwargs))
396+
for index, kwargs in enumerate(calls)
397+
]
398+
for thread in threads:
399+
thread.start()
400+
for thread in threads:
401+
thread.join(timeout=5)
402+
return results
403+
404+
@patch("posthog.ai.prompts._get_session")
405+
def test_share_one_request_between_concurrent_callers(self, mock_get_session):
406+
mock_get = mock_get_session.return_value.get
407+
release = threading.Event()
408+
409+
def slow_get(url, **kwargs):
410+
release.wait(5)
411+
version = 2 if "version=2" in url else 1
412+
return MockResponse(
413+
json_data={**self.mock_prompt_response, "version": version}
414+
)
415+
416+
mock_get.side_effect = slow_get
417+
prompts = Prompts(self.create_mock_posthog())
418+
419+
calls = [{}] * 10 + [{"version": 2}]
420+
421+
# Release the network once every caller is waiting on it.
422+
timer = threading.Timer(0.2, release.set)
423+
timer.start()
424+
results = self._run_concurrent_gets(prompts, calls)
425+
timer.join()
426+
427+
self.assertEqual(mock_get.call_count, 2)
428+
self.assertEqual({r.source for r in results}, {"api"})
429+
self.assertEqual([r.version for r in results[:10]], [1] * 10)
430+
self.assertEqual(results[10].version, 2)
431+
432+
@patch("posthog.ai.prompts._get_session")
433+
def test_concurrent_callers_share_one_failure_and_capture_it_once(
434+
self, mock_get_session
435+
):
436+
mock_get = mock_get_session.return_value.get
437+
release = threading.Event()
438+
439+
def failing_get(url, **kwargs):
440+
release.wait(5)
441+
raise Exception("Network error")
442+
443+
mock_get.side_effect = failing_get
444+
posthog = self.create_mock_posthog()
445+
prompts = Prompts(posthog, capture_errors=True)
446+
447+
timer = threading.Timer(0.2, release.set)
448+
timer.start()
449+
results = self._run_concurrent_gets(
450+
prompts, [{"fallback": "fallback prompt"}] * 10
451+
)
452+
timer.join()
453+
454+
self.assertEqual(mock_get.call_count, 1)
455+
self.assertEqual({r.source for r in results}, {"code_fallback"})
456+
posthog.capture_exception.assert_called_once()
457+
458+
@patch("posthog.ai.prompts._get_session")
459+
def test_fork_hook_clears_inherited_in_flight_entries(self, mock_get_session):
460+
from posthog.ai.prompts import (
461+
_InFlightFetch,
462+
_cache_key,
463+
_reset_in_flight_after_fork,
464+
)
465+
466+
mock_get = mock_get_session.return_value.get
467+
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
468+
prompts = Prompts(self.create_mock_posthog())
469+
# What a forked child inherits: an entry whose fetching thread no longer exists.
470+
prompts._in_flight[_cache_key("test-prompt", None, None)] = _InFlightFetch()
471+
472+
_reset_in_flight_after_fork()
473+
result = prompts.get("test-prompt", with_metadata=True)
474+
475+
self.assertEqual(result.source, "api")
476+
self.assertEqual(mock_get.call_count, 1)
477+
478+
@patch("posthog.ai.prompts._get_session")
479+
def test_error_callback_that_fetches_the_same_prompt_does_not_deadlock(
480+
self, mock_get_session
481+
):
482+
mock_get = mock_get_session.return_value.get
483+
mock_get.side_effect = Exception("Network error")
484+
posthog = self.create_mock_posthog()
485+
prompts = Prompts(posthog, capture_errors=True)
486+
nested_results = []
487+
entered = threading.Event()
488+
489+
def capture_exception(*args, **kwargs):
490+
# The nested fetch fails too and reports again; only the first report fetches.
491+
if entered.is_set():
492+
return
493+
entered.set()
494+
nested_results.append(
495+
prompts.get("test-prompt", with_metadata=True, fallback="nested")
496+
)
497+
498+
posthog.capture_exception.side_effect = capture_exception
499+
500+
result = prompts.get("test-prompt", with_metadata=True, fallback="outer")
501+
502+
self.assertEqual(result.prompt, "outer")
503+
self.assertEqual([r.prompt for r in nested_results], ["nested"])
504+
385505
@patch("posthog.ai.prompts._get_session")
386506
@patch("posthog.ai.prompts.log")
387507
def test_use_fallback_when_no_cache_and_fetch_fails_with_warning(

0 commit comments

Comments
 (0)