|
7 | 7 | import copy |
8 | 8 | import logging |
9 | 9 | import math |
| 10 | +import os |
10 | 11 | import re |
| 12 | +import threading |
11 | 13 | import time |
12 | 14 | import urllib.parse |
13 | 15 | import warnings |
| 16 | +import weakref |
14 | 17 | from dataclasses import dataclass |
15 | 18 | from typing import Any, Dict, List, Literal, Optional, Union, overload |
16 | 19 |
|
|
37 | 40 | PromptSource = Literal["api", "cache", "stale_cache", "code_fallback"] |
38 | 41 |
|
39 | 42 |
|
| 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 | + |
40 | 70 | @dataclass(frozen=True) |
41 | 71 | class PromptResult: |
42 | 72 | """Result of a prompt fetch with metadata about its source. |
@@ -270,6 +300,12 @@ def __init__( |
270 | 300 | else DEFAULT_CACHE_TTL_SECONDS |
271 | 301 | ) |
272 | 302 | 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) |
273 | 309 | self._has_warned_deprecation = False |
274 | 310 | self._client = posthog |
275 | 311 | self._capture_errors = capture_errors |
@@ -568,44 +604,18 @@ def _get_internal( |
568 | 604 |
|
569 | 605 | # Try to fetch from API |
570 | 606 | 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) |
596 | 608 |
|
597 | 609 | return PromptResult( |
598 | 610 | 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), |
604 | 616 | ) |
605 | 617 |
|
606 | 618 | except Exception as error: |
607 | | - self._maybe_capture_error(error, name=name, version=version, label=label) |
608 | | - |
609 | 619 | prompt_reference = _prompt_reference(name, version, label) |
610 | 620 | # Return stale cache (with warning) |
611 | 621 | if cached is not None: |
@@ -634,6 +644,79 @@ def _get_internal( |
634 | 644 |
|
635 | 645 | raise |
636 | 646 |
|
| 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 | + |
637 | 720 | def compile(self, prompt: str, variables: PromptVariables) -> str: |
638 | 721 | """ |
639 | 722 | Replace {{variableName}} placeholders with values. |
|
0 commit comments