Skip to content

fix(prompts): share one request between concurrent fetches of the same prompt - #1059

Open
jurajmajerik wants to merge 5 commits into
mainfrom
prompts-single-flight
Open

jurajmajerik wants to merge 5 commits into
mainfrom
prompts-single-flight

Conversation

@jurajmajerik

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

An app with many threads, such as a worker with 50 activity slots, sends one request per thread when a prompt is not in the cache or its TTL has expired. That burst repeats every TTL and is enough to hit the per-minute prompt fetch limit.

Now one thread makes the request and the others wait for its result. With capture_errors=True, a shared failure is reported once instead of once per thread.

Same change as PostHog/posthog-js#5266 for the JS client. Part of #106807 in PostHog/posthog.

💚 How did you test it?

Two new tests in test_prompts.py: ten threads on a cold cache make one request, and ten threads on a failing fetch make one request and one capture_exception call. Both fail on the old code with 11 and 10 requests.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

@jurajmajerik
jurajmajerik requested a review from a team as a code owner October 14, 2026 08:06

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 4ad9304: 3 Should fix.

Comment thread posthog/ai/prompts.py
Comment on lines +679 to +682
finally:
with self._in_flight_lock:
self._in_flight.pop(cache_key, None)
pending.done.set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Publish cache state before removing the in-flight entry

When a leader finishes fetching but has not yet updated _cache in _get_internal, _fetch_prompt_once removes its in-flight entry. Another concurrent caller sees both a cache miss and no pending fetch, so it starts a duplicate request. This leaves a gap in the new request sharing on cold caches and expiry; publish the cache or cooldown state before removing the entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 327fa79

Comment thread posthog/ai/prompts.py Outdated
Comment on lines +674 to +677
if isinstance(error, Exception):
self._maybe_capture_error(
error, name=name, version=version, label=label
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Release waiters before invoking error-tracking callbacks

With capture_errors=True, a capture_exception callback such as before_send that calls get() for the same prompt enters pending.done.wait() on the leader's own unfinished fetch. The leader cannot reach finally, so the callback and all waiting callers hang indefinitely, even with a fallback. Complete the shared failure before invoking error-tracking callbacks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 327fa79

Comment thread posthog/ai/prompts.py
Comment on lines +662 to +663
if not is_leader:
pending.done.wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Reset prompt flight state after a fork

When a process forks while another thread fetches a prompt, the child inherits _in_flight but loses the leader thread. Its next get() for that key blocks forever in pending.done.wait(), even with a fallback; the client's existing fork hook does not reset this state. Replace the lock and clear inherited flights in the child before accepting requests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 327fa79

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-10-14T08:36:17.906833+00:00
Duration: 260077ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 517ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 513ms
Required Headers.Has Authorization Bearer Header ✅ 512ms
Required Headers.Has Content Type Json ✅ 513ms
Required Headers.Has Posthog Sdk Info Format ✅ 513ms
Required Headers.Has Posthog Attempt Header ✅ 512ms
Required Headers.Has Posthog Request Id ✅ 512ms
Required Headers.Has Posthog Request Timestamp ✅ 512ms
Required Headers.Has User Agent ✅ 512ms
Body Format.Body Has Created At And Batch ✅ 512ms
Body Format.No Api Key In Body ✅ 512ms
Body Format.No Sent At In Body ✅ 511ms
Event Format.Event Has Required Root Fields ✅ 512ms
Event Format.Event Uuid Is Valid ✅ 512ms
Event Format.Event Timestamp Is Rfc3339 ✅ 512ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 518ms
Event Format.Distinct Id Is String ✅ 512ms
Event Format.Distinct Id At Root Not Properties ✅ 512ms
Event Format.Custom Properties Preserved ✅ 513ms
Event Format.Set Properties Preserved ✅ 512ms
Event Format.Set Once Properties Preserved ✅ 512ms
Event Format.Groups Properties Preserved ✅ 511ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 513ms
Event Format.Event Has Required Root Fields Batch ✅ 515ms
Event Format.Event Uuid Is Valid Batch ✅ 515ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 516ms
Event Format.Distinct Id Is String Batch ✅ 515ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 515ms
Event Format.Custom Properties Preserved Batch ✅ 516ms
Event Format.Set Properties Preserved Batch ✅ 516ms
Event Format.Set Once Properties Preserved Batch ✅ 516ms
Event Format.Groups Properties Preserved Batch ✅ 517ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 515ms
Batch Behavior.Multiple Events In Single Batch ✅ 521ms
Batch Behavior.Batch Envelope Smoke ✅ 518ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 508ms
Batch Behavior.Flush At Triggers Batch ✅ 1012ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 513ms
Deduplication.Generates Unique Uuids ✅ 518ms
Deduplication.Different Events Same Content Different Uuids ✅ 516ms
Deduplication.Preserves Uuid On Retry ✅ 6521ms
Deduplication.Preserves Timestamp On Retry ✅ 6522ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6522ms
Deduplication.No Duplicate Events In Batch ✅ 520ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 512ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13523ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6520ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3025ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6524ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 512ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 517ms
Response Format Validation.Success No Retry After When All Ok ✅ 518ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1517ms
Response Format Validation.Success No Retry After When Drop Only ✅ 514ms
Response Format Validation.Response Echoes Request Id ✅ 513ms
Retry Behavior.Retries On 408 ✅ 6523ms
Retry Behavior.Retries On 500 ✅ 6519ms
Retry Behavior.Retries On 503 ✅ 8521ms
Retry Behavior.Retries On 504 ✅ 6517ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3520ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11525ms
Retry Behavior.Does Not Retry On 400 ✅ 2516ms
Retry Behavior.Does Not Retry On 401 ✅ 2514ms
Retry Behavior.Does Not Retry On 402 ✅ 2516ms
Retry Behavior.Does Not Retry On 413 ✅ 2515ms
Retry Behavior.Does Not Retry On 415 ✅ 2517ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2515ms
Retry Behavior.Implements Backoff ✅ 22523ms
Retry Behavior.Max Retries Respected ✅ 22529ms
Partial Batch Handling.Handles 200 Full Success ✅ 2515ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3517ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3518ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3516ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6520ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6525ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6525ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6523ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6526ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8522ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3519ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3520ms
Compression.Sends Gzip Content Encoding ✅ 514ms
Compression.No Content Encoding When Disabled ✅ 512ms
Compression.Compressed Body Is Decompressible ✅ 512ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2515ms
Event Options.Cookieless Mode Override ✅ 514ms
Event Options.Disable Skew Correction Override ✅ 511ms
Event Options.Process Person Profile Override ✅ 513ms
Event Options.Product Tour Id Override ✅ 512ms
Event Options.Unset Options Omitted ✅ 512ms
Event Options.Options Override In Batch ✅ 516ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 512ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 512ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 513ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 512ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 511ms
Identity.Capture Ai Event Has Uuid ✅ 513ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 512ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 516ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 15ms
Request Payload.Flags Request Uses V2 Query Param ✅ 13ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 14ms
Request Payload.Flags Request Omits Authorization Header ✅ 13ms
Request Payload.Token In Flags Body Matches Init ✅ 11ms
Request Payload.Groups Round Trip ✅ 12ms
Request Payload.Groups Default To Empty Object ✅ 11ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 12ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 11ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 11ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 511ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 18ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 12ms
Retry Behavior.Retries Flags On 502 ✅ 316ms
Retry Behavior.Retries Flags On 504 ✅ 316ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 515ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 72ms
Versioned Boolean Matching.Matching Version 1 ✅ 75ms
Versioned Boolean Matching.Matching Version 2 ✅ 69ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 42ms

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 327fa79: 3 Should fix.

Comment thread posthog/ai/prompts.py
raise
finally:
with self._in_flight_lock:
self._in_flight.pop(cache_key, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Preserve the replacement flight when a timed-out leader finishes

When a fetch exceeds the 30-second waiter timeout, another caller replaces its flight and starts fetching. If the original leader then finishes, this unconditional pop() removes the replacement's entry while its request is still running. Subsequent expired-cache or zero-TTL reads start additional requests; remove the entry only when it still refers to pending.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 246c2c0

Comment thread posthog/test/ai/test_prompts.py Outdated
Comment on lines +488 to +492
if nested_results:
return
nested_results.append(
prompts.get("test-prompt", with_metadata=True, fallback="nested")
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Set the callback recursion guard before fetching the nested prompt

With the mocked fetch always failing, the nested prompts.get() invokes capture_exception again before nested_results.append() executes. The guard therefore remains false until recursion exhaustion, and unwinding appends many results, failing the assertion expecting one nested result. Set a separate guard before entering the nested fetch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in efa529d

Comment thread posthog/ai/prompts.py Outdated
Comment on lines +294 to +298
if hasattr(os, "register_at_fork"):
weak_self = weakref.ref(self)
os.register_at_fork(
after_in_child=lambda: Prompts._reset_in_flight_after_fork(weak_self)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Register one fork callback for a weak registry of prompt instances

On platforms supporting register_at_fork, every Prompts instance permanently registers a new closure. The weak reference lets the instance disappear, but Python retains the callback and its closure for the process lifetime. Applications repeatedly constructing instances accumulate memory and an ever-growing callback list executed on every fork; register one callback over a weak instance registry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 246c2c0

@jurajmajerik jurajmajerik changed the title fix(ai): share one request between concurrent fetches of the same prompt fix(prompts): share one request between concurrent fetches of the same prompt Oct 14, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 246c2c0: 2 Should fix.

Comment thread posthog/ai/prompts.py
Comment on lines +656 to +661
with self._in_flight_lock:
pending = self._in_flight.get(cache_key)
is_leader = pending is None
if pending is None:
pending = _InFlightFetch()
self._in_flight[cache_key] = pending

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Recheck cache freshness before electing a new fetch leader

When a caller passes _get_internal's cache check and pauses before acquiring this lock, another caller can fetch, populate the cache, and remove its flight. The paused caller then creates a new flight and sends a duplicate request despite the fresh cache. Publishing the cache first leaves this race open; recheck freshness under the election lock while preserving zero-TTL behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 7863971

Comment thread posthog/ai/prompts.py Outdated
Comment on lines +664 to +668
if not pending.done.wait(_IN_FLIGHT_WAIT_SECONDS):
with self._in_flight_lock:
if self._in_flight.get(cache_key) is pending:
del self._in_flight[cache_key]
return self._fetch_prompt_once(name, version, label)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Avoid replacing live fetches after the fixed waiter timeout

When the server returns 429 with Retry-After: 60, _get_session()'s urllib3 retries sleep beyond the 30-second waiter limit. Waiters delete the live flight and launch another request every 30 seconds, amplifying traffic while the server asks the client to back off. The transport has no 30-second total deadline; align replacement with an actual bounded fetch lifetime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 7863971

@jurajmajerik

Copy link
Copy Markdown
Contributor Author

Simplified after the review rounds: the timed wait and replacement-fetch logic are gone, waiters just wait for the leader, a single fork hook resets inherited state in a child, and the cache freshness check also runs under the election lock so no caller starts a second request for a prompt that was just fetched.

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks good; no qualifying findings. Verdict: correct.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants