Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
05c0cdc
feat(publisher): degrade PubSubPublisher to noop when GCP credentials…
julietshen Jun 29, 2026
0d5f06c
Merge branch 'main' into julietshen/noop-publisher-fallback
julietshen Jul 6, 2026
f7d7fd6
fix(publisher): address review feedback on noop fallback
julietshen Jul 6, 2026
641717a
docs(changelog): note PubSubPublisher noop fallback
julietshen Jul 7, 2026
3b40c28
test(publisher): force GCP creds in validation-exporter conftest
julietshen Jul 7, 2026
749156e
test(publisher): scope forced-creds patch to function to stop leak
julietshen Jul 9, 2026
f0cee7c
feat(publisher): add DISABLE_GCP_PUBSUB opt-out, drop per-send noop m…
julietshen Jul 10, 2026
b678c6f
feat(async-publisher): degrade AsyncPubSubPublisher to noop without G…
julietshen Jul 7, 2026
e8e2770
docs(changelog): cover both sync and async publisher noop fallback
julietshen Jul 7, 2026
362a860
test(publisher): point exporter conftest at renamed cred helper
julietshen Jul 9, 2026
48cefda
feat(async-publisher): add DISABLE_GCP_PUBSUB opt-out, drop noop metric
julietshen Jul 10, 2026
6cbe134
style(test): collapse test signature to satisfy ruff format
julietshen Jul 10, 2026
2d40415
feat(publisher): emit startup configuration.errors metric on missing …
julietshen Jul 10, 2026
6b84b57
refactor(publisher): move enable/disable decision into a make_publish…
julietshen Jul 10, 2026
e825f47
test: drop vestigial cred patch from exporter conftest
julietshen Jul 10, 2026
b1adab9
refactor(publisher): opt-in OSPREY_PUBSUB_ENABLED, catch creds error …
julietshen Jul 10, 2026
f0b8f6e
refactor(publisher): default OSPREY_PUBSUB_ENABLED to true, restore p…
julietshen Jul 13, 2026
707a180
docs(changelog): scope the entry to the publisher noop, not full GCP-…
julietshen Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions osprey_worker/src/osprey/worker/lib/publisher.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import abc
import logging
from typing import Dict, Optional, TypeVar

import google.auth
from google.auth.exceptions import DefaultCredentialsError
from google.cloud import pubsub_v1
from osprey.worker.lib.instruments import metrics
from osprey.worker.lib.pubsub.publisher_client import BatchPubsubPublisherClient
from pydantic import BaseModel

_PydanticModelT = TypeVar('_PydanticModelT', bound=BaseModel)

logger = logging.getLogger(__name__)


class BasePublisher(abc.ABC):
"""
Expand Down Expand Up @@ -40,6 +45,15 @@ def stop(self) -> None:


class PubSubPublisher(BasePublisher):
"""Publishes Pydantic models to a Google Cloud Pub/Sub topic.

Degrades to noop mode when GCP credentials cannot be resolved at construction
time (e.g. local dev or adopter environments without GCP). In noop mode no
underlying client is built, publish() and stop() return immediately, and a
`PubSubPublisher.publisher.noop` metric is incremented per call so the inert
state is visible in dashboards. A one-time warning is logged at construction.
"""

def __init__(
self,
project_id: str,
Expand All @@ -54,15 +68,23 @@ def __init__(
topic_id=topic_id,
)
self._project = project_id
self._raise_on_error = raise_on_error
self._tags = [f'project:{self._project}', f'topic:{self._topic_name}']
self._enabled = _check_gcp_credentials()
if not self._enabled:
logger.warning(
'GCP credentials not detected — PubSubPublisher running in noop mode (project=%s, topic=%s)',
project_id,
topic_id,
)
return
batch_settings = pubsub_v1.types.BatchSettings(
max_bytes=max_bytes, # default 1MB
max_messages=max_messages, # default 100 messages
max_latency=max_latency,
)
# self._publisher = pubsub_v1.PublisherClient(batch_settings=batch_settings)
self._publisher = BatchPubsubPublisherClient(batch_settings=batch_settings)
self._raise_on_error = raise_on_error
self._tags = [f'project:{self._project}', f'topic:{self._topic_name}']

def prepare_data(self, data: _PydanticModelT) -> bytes:
"""
Expand All @@ -72,6 +94,9 @@ def prepare_data(self, data: _PydanticModelT) -> bytes:
return data.json(exclude_none=True).encode()

def publish(self, data: _PydanticModelT, attributes: Optional[Dict[str, str]] = None) -> None:
if not self._enabled:
metrics.increment(f'{self.__class__.__name__}.publisher.noop', tags=self._tags)

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.

Not sure on the usage of another metric here, and I think the dashboard could probably just surface this as a warning banner or something "Publishing to GCP PubSub is currently disabled due to invalid credentials"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ooooh good idea! I'll work that in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i'm actually going to add that separately, since it's a UI change and i want to keep this and #411 focused on the publisher changes

return
if attributes is None:
attributes = {}

Expand All @@ -89,6 +114,8 @@ def publish(self, data: _PydanticModelT, attributes: Optional[Dict[str, str]] =
metrics.increment(f'{self.__class__.__name__}.publisher.success', tags=self._tags)

def stop(self) -> None:
if not self._enabled:
return
self._publisher.stop()


Expand All @@ -103,3 +130,17 @@ def publish(self, data: str, attributes: Optional[Dict[str, str]] = None) -> Non
attributes = {}

super().publish(data, attributes) # type: ignore[type-var]


_gcp_credentials_available: Optional[bool] = None


def _check_gcp_credentials() -> bool:
global _gcp_credentials_available
if _gcp_credentials_available is None:
try:
google.auth.default()
_gcp_credentials_available = True

@chimosky chimosky Jun 30, 2026

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.

I'm wondering if a better way to do this would be to check for "Google" in /sys/class/dmi/id/product_name, so we can avoid the cost of this network call.

Seeing as we actually aren't using the credentials, only checking for them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

oooh good catch. you're right that we only need to check whether creds exist here, not actually use them. I looked into what that network call really costs:

google.auth.default() looks for credentials in three places, in order: an environment variable pointing at a key file, then a local gcloud login file, and it only calls to the network if both of those come up empty. So the network call only happens when there are no creds anywhere else, which is the exact case we're trying to catch.

it shouldn't slows down a normal startup that has creds, and I only run the check once per process and remember the answer, so it doesn't happen on every publish.

The reason I'd hold off on reading /sys/class/dmi/id/product_name: that tells you whether the machine is a Google Cloud box, which isn't quite the same as "do we have google credentials." Those two usually match, but not always: for example, someone running Osprey outside Google Cloud (say on AWS or their own servers) but still sending to Pub/Sub with a key file has credentials, even though the machine isn't a Google box.

In that case, google.auth.default() still finds the key and publishing works. But if we switched to reading the machine type, it would see "not a Google box," assume there are no credentials, and shut publishing off without any error. Since the whole point of #343 is to stop GCP from failing quietly, I'd rather not add another way for that to happen.

If that startup check does turn out to slow down docker compose up, I think the better fix is to just give it a short time limit so it can't get stuck, vs switch to the hardware check and lose the ability to find key-file creds. Happy to do that if you think it's worth it! What do you think?

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.

The reason I'd hold off on reading /sys/class/dmi/id/product_name: that tells you whether the machine is a Google Cloud box, which isn't quite the same as "do we have google credentials." Those two usually match, but not always: for example, someone running Osprey outside Google Cloud (say on AWS or their own servers) but still sending to Pub/Sub with a key file has credentials, even though the machine isn't a Google box.

Yeah, this makes sense.

In that case, google.auth.default() still finds the key and publishing works. But if we switched to reading the machine type, it would see "not a Google box," assume there are no credentials, and shut publishing off without any error. Since the whole point of #343 is to stop GCP from failing quietly, I'd rather not add another way for that to happen.

If that startup check does turn out to slow down docker compose up, I think the better fix is to just give it a short time limit so it can't get stuck, vs switch to the hardware check and lose the ability to find key-file creds. Happy to do that if you think it's worth it! What do you think?

The network cost might be something we need not worry about tbh, putting a limit sounds like a good idea,

except DefaultCredentialsError:
_gcp_credentials_available = False
return _gcp_credentials_available
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
74 changes: 74 additions & 0 deletions osprey_worker/src/osprey/worker/lib/tests/test_publisher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from typing import Iterator
from unittest.mock import MagicMock, patch

import pytest
from google.auth.exceptions import DefaultCredentialsError
from osprey.worker.lib import publisher
from osprey.worker.lib.publisher import PubSubPublisher, _check_gcp_credentials


@pytest.fixture(autouse=True)
def reset_cred_cache() -> Iterator[None]:
publisher._gcp_credentials_available = None
yield
publisher._gcp_credentials_available = None


def test_check_gcp_credentials_true_when_default_resolves() -> None:
with patch.object(publisher.google.auth, 'default', return_value=(MagicMock(), 'proj')):
assert _check_gcp_credentials() is True


def test_check_gcp_credentials_false_when_default_raises() -> None:
with patch.object(publisher.google.auth, 'default', side_effect=DefaultCredentialsError()):
assert _check_gcp_credentials() is False


def test_check_gcp_credentials_is_cached() -> None:
with patch.object(publisher.google.auth, 'default', return_value=(MagicMock(), 'proj')) as default_mock:
_check_gcp_credentials()
_check_gcp_credentials()
_check_gcp_credentials()
assert default_mock.call_count == 1


def test_pubsub_publisher_constructs_client_when_creds_present() -> None:
with (
patch.object(publisher.google.auth, 'default', return_value=(MagicMock(), 'proj')),
patch.object(publisher, 'BatchPubsubPublisherClient') as client_cls,
):
pub = PubSubPublisher('proj', 'topic')
assert pub._enabled is True
assert client_cls.called


def test_pubsub_publisher_noops_when_creds_absent(caplog: pytest.LogCaptureFixture) -> None:
with (
patch.object(publisher.google.auth, 'default', side_effect=DefaultCredentialsError()),
patch.object(publisher, 'BatchPubsubPublisherClient') as client_cls,
):
with caplog.at_level('WARNING', logger=publisher.logger.name):
pub = PubSubPublisher('proj', 'topic')
assert pub._enabled is False
assert not client_cls.called
assert 'noop mode' in caplog.text
assert 'project=proj' in caplog.text
assert 'topic=topic' in caplog.text


def test_publish_short_circuits_and_emits_noop_metric_when_disabled() -> None:
with (
patch.object(publisher.google.auth, 'default', side_effect=DefaultCredentialsError()),
patch.object(publisher, 'metrics') as metrics_mock,
):
pub = PubSubPublisher('proj', 'topic')
pub.publish(MagicMock())
metric_names = [call.args[0] for call in metrics_mock.increment.call_args_list]
assert metric_names == ['PubSubPublisher.publisher.noop']


def test_stop_short_circuits_when_disabled() -> None:
with patch.object(publisher.google.auth, 'default', side_effect=DefaultCredentialsError()):
pub = PubSubPublisher('proj', 'topic')
pub.stop()
assert not hasattr(pub, '_publisher')
Loading