Skip to content

Commit bd0feb3

Browse files
julietshenclaude
andcommitted
refactor(publisher): move enable/disable decision into a make_publisher factory
Address review: rather than PubSubPublisher disabling itself, build publishers through a make_publisher() factory that returns the existing NullPublisher when publishing is off (PUBSUB_ENABLED false, or GCP credentials absent). Keeps the transport class a plain sender and reuses the noop publisher we already have. - New make_publisher() factory; the ~5 construction sites (cli/sinks.py, validation_result_exporter.py, ui_api/singletons.py) route through it. - Missing credentials emit a one-time configuration.errors metric from the factory; the deliberate PUBSUB_ENABLED opt-out stays silent. - Read the flag via config.get_bool('PUBSUB_ENABLED', True) (positive form) instead of a raw os.environ DISABLE_GCP_PUBSUB read. - Move the shared credential check to lib/utils/gcp_credentials.py. - Revert AsyncPubSubPublisher to a plain transport; it has no callers yet, so a factory/noop there would be dead code until it is wired into a sink. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
1 parent 2d40415 commit bd0feb3

11 files changed

Lines changed: 113 additions & 205 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ For more information about each release including git tags and artifacts, see [R
3131
- Event stream shows sensible defaults so first-load isn't empty ([#297](https://github.com/roostorg/osprey/pull/297) by [@haileyok](https://github.com/haileyok))
3232
- Replace `react-scripts` with `rsbuild`/`rspack` for UI builds ([#235](https://github.com/roostorg/osprey/pull/235) by [@chimosky](https://github.com/chimosky))
3333
- Migrate from npm to pnpm via Corepack ([#252](https://github.com/roostorg/osprey/pull/252) by [@haileyok](https://github.com/haileyok))
34-
- Pub/Sub publishers degrade to a noop when GCP credentials are absent or when `DISABLE_GCP_PUBSUB` is set, so Osprey runs without GCP config instead of failing: `PubSubPublisher` ([#388](https://github.com/roostorg/osprey/pull/388)) and `AsyncPubSubPublisher` ([#411](https://github.com/roostorg/osprey/pull/411)) (by [@julietshen](https://github.com/julietshen))
34+
- Osprey runs without GCP config instead of failing: publishers are built through a `make_publisher` factory that returns a noop publisher when GCP credentials are absent or `PUBSUB_ENABLED` is false ([#388](https://github.com/roostorg/osprey/pull/388) by [@julietshen](https://github.com/julietshen))
3535

3636
### Fixed
3737

docker-compose.override.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
services:
2+
osprey-ui-api:
3+
environment:
4+
- OSPREY_RULES_SUBMISSION_BACKEND=tangled
5+
- OSPREY_TANGLED_HANDLE=julietshen.bsky.social
6+
- OSPREY_TANGLED_REPO=julietshen.bsky.social/osprey-experiment
7+
- OSPREY_TANGLED_REPO_DID=did:plc:55uxnkmuoiwwgyoqjbjo4z5t
8+
- OSPREY_TANGLED_APP_PASSWORD=${OSPREY_TANGLED_APP_PASSWORD}
9+
- OSPREY_RULES_BASE_BRANCH=main
10+
- OSPREY_RULES_PATH_IN_REPO=example_rules
11+
# Other backends' vars kept so switching back is a one-line change:
12+
- OSPREY_GITLAB_PROJECT=julietshen/osprey-proof-of-concept
13+
- OSPREY_GITLAB_TOKEN=${OSPREY_GITLAB_TOKEN}
14+
- OSPREY_RULES_REPO=julietshen/osprey
15+
- OSPREY_GITHUB_TOKEN=${OSPREY_GITHUB_TOKEN}
16+
- OSPREY_RULES_LOCAL_PATH=/osprey/example_rules

osprey_async_worker/src/osprey/async_worker/lib/publisher.py

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from google.api_core.retry import Retry
1111
from google.cloud import pubsub_v1
12-
from osprey.worker.lib.gcp_credentials import gcp_credentials_available, gcp_pubsub_disabled
1312
from osprey.worker.lib.instruments import metrics
1413
from pydantic import BaseModel
1514

@@ -31,12 +30,9 @@ class AsyncPubSubPublisher:
3130
Messages are buffered in an asyncio.Queue and flushed either when
3231
the batch reaches max_messages or after max_latency_seconds.
3332
34-
Degrades to noop mode when the DISABLE_GCP_PUBSUB env var is set, or when GCP
35-
credentials cannot be resolved at construction time (e.g. local dev or adopter
36-
environments without GCP). In noop mode no underlying client is built and the
37-
publish paths return immediately. A one-time warning is logged at construction
38-
so the inert state is visible, and missing credentials (unlike the deliberate
39-
opt-out) also emit a startup `configuration.errors` metric.
33+
This is a plain transport that assumes GCP is configured. A callers-side
34+
factory (as on the sync side) should decide whether to build this or a noop
35+
publisher once the async worker wires it into a sink.
4036
"""
4137

4238
def __init__(
@@ -49,25 +45,6 @@ def __init__(
4945
self._topic_path = f'projects/{project_id}/topics/{topic_id}'
5046
self._metric_tags = [f'project:{project_id}', f'topic:{topic_id}']
5147
self._flush_task: asyncio.Task[None] | None = None
52-
if gcp_pubsub_disabled():
53-
self._enabled = False
54-
logger.warning(
55-
'DISABLE_GCP_PUBSUB is set, AsyncPubSubPublisher disabled (project=%s, topic=%s)',
56-
project_id,
57-
topic_id,
58-
)
59-
return
60-
self._enabled = gcp_credentials_available()
61-
if not self._enabled:
62-
logger.warning(
63-
'GCP credentials not detected, AsyncPubSubPublisher running in noop mode (project=%s, topic=%s)',
64-
project_id,
65-
topic_id,
66-
)
67-
# Startup-only signal: missing credentials is a misconfiguration, unlike the
68-
# deliberate DISABLE_GCP_PUBSUB opt-out above, which is silent.
69-
metrics.increment('configuration.errors', tags=self._metric_tags + ['reason:gcp_credentials_missing'])
70-
return
7148
self._client = pubsub_v1.PublisherClient(
7249
batch_settings=pubsub_v1.types.BatchSettings(max_messages=1),
7350
)
@@ -156,8 +133,6 @@ def publish(self, data: BaseModel) -> None:
156133

157134
def publish_bytes(self, data: bytes) -> None:
158135
"""Queue raw bytes for async batched publishing."""
159-
if not self._enabled:
160-
return
161136
self._ensure_started()
162137
try:
163138
self._queue.put_nowait(data)
@@ -167,8 +142,6 @@ def publish_bytes(self, data: bytes) -> None:
167142

168143
async def stop(self) -> None:
169144
"""Flush remaining messages and stop."""
170-
if not self._enabled:
171-
return
172145
if self._flush_task is not None:
173146
self._flush_task.cancel()
174147
try:

osprey_async_worker/src/osprey/async_worker/tests/test_publisher.py

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,13 @@
22

33
from unittest.mock import MagicMock, patch
44

5-
import pytest
65
from google.api_core.exceptions import NotFound
7-
from osprey.async_worker.lib import publisher as publisher_module
86
from osprey.async_worker.lib.publisher import _PUBLISH_RETRY, AsyncPubSubPublisher
97

108

119
def _make_publisher():
12-
"""Return an enabled AsyncPubSubPublisher with a mocked PublisherClient."""
13-
with (
14-
patch('osprey.async_worker.lib.publisher.gcp_credentials_available', return_value=True),
15-
patch('osprey.async_worker.lib.publisher.pubsub_v1.PublisherClient'),
16-
):
10+
"""Return an AsyncPubSubPublisher with a mocked PublisherClient."""
11+
with patch('osprey.async_worker.lib.publisher.pubsub_v1.PublisherClient'):
1712
publisher = AsyncPubSubPublisher(project_id='proj', topic_id='topic')
1813
publisher._client = MagicMock()
1914
return publisher
@@ -53,59 +48,3 @@ def test_permanent_failure_metric_fires(mock_metrics):
5348
assert len(failure_calls) == 1
5449
assert failure_calls[0][0][0] == 'async_pubsub_publisher.publish.failure'
5550
assert f'error:{exc.__class__.__name__}' in failure_calls[0][1]['tags']
56-
57-
58-
def test_noops_when_creds_absent(caplog: pytest.LogCaptureFixture) -> None:
59-
with (
60-
patch('osprey.async_worker.lib.publisher.gcp_credentials_available', return_value=False),
61-
patch('osprey.async_worker.lib.publisher.pubsub_v1.PublisherClient') as client_cls,
62-
patch('osprey.async_worker.lib.publisher.metrics') as mock_metrics,
63-
):
64-
with caplog.at_level('WARNING', logger=publisher_module.logger.name):
65-
publisher = AsyncPubSubPublisher(project_id='proj', topic_id='topic')
66-
assert publisher._enabled is False
67-
assert not client_cls.called
68-
assert not hasattr(publisher, '_client')
69-
assert 'noop mode' in caplog.text
70-
assert 'project=proj' in caplog.text
71-
assert 'topic=topic' in caplog.text
72-
mock_metrics.increment.assert_called_once_with(
73-
'configuration.errors',
74-
tags=['project:proj', 'topic:topic', 'reason:gcp_credentials_missing'],
75-
)
76-
77-
78-
def test_disabled_via_env(monkeypatch, caplog: pytest.LogCaptureFixture) -> None:
79-
monkeypatch.setenv('DISABLE_GCP_PUBSUB', 'true')
80-
with (
81-
patch('osprey.async_worker.lib.publisher.gcp_credentials_available') as cred_check,
82-
patch('osprey.async_worker.lib.publisher.pubsub_v1.PublisherClient') as client_cls,
83-
patch('osprey.async_worker.lib.publisher.metrics') as mock_metrics,
84-
):
85-
with caplog.at_level('WARNING', logger=publisher_module.logger.name):
86-
publisher = AsyncPubSubPublisher(project_id='proj', topic_id='topic')
87-
assert publisher._enabled is False
88-
assert not client_cls.called
89-
# The opt-out short-circuits before probing credentials.
90-
assert not cred_check.called
91-
assert 'DISABLE_GCP_PUBSUB' in caplog.text
92-
# A deliberate opt-out is not a misconfiguration, so no config-error metric.
93-
mock_metrics.increment.assert_not_called()
94-
95-
96-
@patch('osprey.async_worker.lib.publisher.metrics')
97-
def test_publish_bytes_short_circuits_silently_when_disabled(mock_metrics) -> None:
98-
with patch('osprey.async_worker.lib.publisher.gcp_credentials_available', return_value=False):
99-
publisher = AsyncPubSubPublisher(project_id='proj', topic_id='topic')
100-
mock_metrics.increment.reset_mock() # ignore the startup configuration.errors metric
101-
publisher.publish_bytes(b'data')
102-
103-
mock_metrics.increment.assert_not_called()
104-
105-
106-
async def test_stop_short_circuits_when_disabled() -> None:
107-
with patch('osprey.async_worker.lib.publisher.gcp_credentials_available', return_value=False):
108-
publisher = AsyncPubSubPublisher(project_id='proj', topic_id='topic')
109-
await publisher.stop()
110-
111-
assert not hasattr(publisher, '_client')

osprey_worker/src/osprey/worker/cli/sinks.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from osprey.worker.lib.config import Config
3838
from osprey.worker.lib.osprey_engine import bootstrap_engine, bootstrap_engine_with_helpers, get_sources_provider
3939
from osprey.worker.lib.osprey_shared.logging import get_logger
40-
from osprey.worker.lib.publisher import PubSubPublisher
40+
from osprey.worker.lib.publisher import make_publisher
4141
from osprey.worker.lib.singletons import CONFIG, LABELS_PROVIDER
4242
from osprey.worker.lib.storage import postgres
4343
from osprey.worker.lib.storage.bigtable import osprey_bigtable
@@ -263,7 +263,7 @@ def run_bulk_label_sink(pooled: bool) -> None:
263263

264264
analytics_pubsub_project_id = config.get_str('PUBSUB_DATA_PROJECT_ID', 'osprey-dev')
265265
analytics_pubsub_topic_id = config.get_str('PUBSUB_ANALYTICS_EVENT_TOPIC_ID', 'osprey-analytics')
266-
analytics_publisher = PubSubPublisher(analytics_pubsub_project_id, analytics_pubsub_topic_id)
266+
analytics_publisher = make_publisher(analytics_pubsub_project_id, analytics_pubsub_topic_id)
267267

268268
def factory() -> BulkLabelSink:
269269
# NOTE: It's very important the input stream is created per-webhook sink
@@ -304,11 +304,11 @@ def rollback_bulk_label_effects(ctx: click.Context, task_id: int, include_ids_fr
304304

305305
analytics_pubsub_project_id = config.get_str('PUBSUB_DATA_PROJECT_ID', 'osprey-dev')
306306
analytics_pubsub_topic_id = config.get_str('PUBSUB_ANALYTICS_EVENT_TOPIC_ID', 'osprey-analytics')
307-
analytics_publisher = PubSubPublisher(analytics_pubsub_project_id, analytics_pubsub_topic_id)
307+
analytics_publisher = make_publisher(analytics_pubsub_project_id, analytics_pubsub_topic_id)
308308

309309
osprey_webhook_pubsub_project = config.get_str('PUBSUB_OSPREY_WEBHOOKS_PROJECT_ID', 'osprey-dev')
310310
osprey_webhook_pubsub_topic = config.get_str('PUBSUB_OSPREY_WEBHOOKS_TOPIC_ID', 'osprey-webhooks')
311-
webhooks_publisher = PubSubPublisher(osprey_webhook_pubsub_project, osprey_webhook_pubsub_topic)
311+
webhooks_publisher = make_publisher(osprey_webhook_pubsub_project, osprey_webhook_pubsub_topic)
312312

313313
task = BulkLabelTask.get_one(task_id)
314314
if task is None:

osprey_worker/src/osprey/worker/lib/data_exporters/validation_result_exporter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
ValidateExperiments,
66
)
77
from osprey.worker.lib.data_exporters.models import ospreyExperimentMetadataUpdate
8-
from osprey.worker.lib.publisher import BasePublisher, PubSubPublisher
8+
from osprey.worker.lib.publisher import BasePublisher, make_publisher
99
from osprey.worker.lib.singletons import CONFIG
1010

1111

@@ -56,4 +56,4 @@ def get_validation_result_exporter() -> BaseValidationResultExporter:
5656

5757
pubsub_project_id = config.get_str('PUBSUB_DATA_PROJECT_ID', 'osprey-dev')
5858
pubsub_topic_id = config.get_str('PUBSUB_ANALYTICS_EVENT_TOPIC_ID', 'osprey-analytics')
59-
return ExperimentValidationResultExporter(publisher=PubSubPublisher(pubsub_project_id, pubsub_topic_id))
59+
return ExperimentValidationResultExporter(publisher=make_publisher(pubsub_project_id, pubsub_topic_id))

osprey_worker/src/osprey/worker/lib/publisher.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from typing import TypeVar
44

55
from google.cloud import pubsub_v1
6-
from osprey.worker.lib.gcp_credentials import gcp_credentials_available, gcp_pubsub_disabled
76
from osprey.worker.lib.instruments import metrics
87
from osprey.worker.lib.pubsub.publisher_client import BatchPubsubPublisherClient
8+
from osprey.worker.lib.utils.gcp_credentials import gcp_credentials_available
99
from pydantic import BaseModel
1010

1111
_PydanticModelT = TypeVar('_PydanticModelT', bound=BaseModel)
@@ -46,12 +46,9 @@ def stop(self) -> None:
4646
class PubSubPublisher(BasePublisher):
4747
"""Publishes Pydantic models to a Google Cloud Pub/Sub topic.
4848
49-
Degrades to noop mode when the DISABLE_GCP_PUBSUB env var is set, or when GCP
50-
credentials cannot be resolved at construction time (e.g. local dev or adopter
51-
environments without GCP). In noop mode no underlying client is built and
52-
publish() and stop() return immediately. A one-time warning is logged at
53-
construction so the inert state is visible, and missing credentials (unlike the
54-
deliberate opt-out) also emit a startup `configuration.errors` metric.
49+
This is a plain transport: it assumes GCP is configured and builds its client
50+
eagerly. Callers that may run without GCP should build publishers via
51+
make_publisher(), which hands back a NullPublisher when publishing is off.
5552
"""
5653

5754
def __init__(
@@ -68,34 +65,15 @@ def __init__(
6865
topic_id=topic_id,
6966
)
7067
self._project = project_id
71-
self._raise_on_error = raise_on_error
72-
self._tags = [f'project:{self._project}', f'topic:{self._topic_name}']
73-
if gcp_pubsub_disabled():
74-
self._enabled = False
75-
logger.warning(
76-
'DISABLE_GCP_PUBSUB is set, PubSubPublisher disabled (project=%s, topic=%s)',
77-
project_id,
78-
topic_id,
79-
)
80-
return
81-
self._enabled = gcp_credentials_available()
82-
if not self._enabled:
83-
logger.warning(
84-
'GCP credentials not detected, PubSubPublisher running in noop mode (project=%s, topic=%s)',
85-
project_id,
86-
topic_id,
87-
)
88-
# Startup-only signal: missing credentials is a misconfiguration, unlike the
89-
# deliberate DISABLE_GCP_PUBSUB opt-out above, which is silent.
90-
metrics.increment('configuration.errors', tags=self._tags + ['reason:gcp_credentials_missing'])
91-
return
9268
batch_settings = pubsub_v1.types.BatchSettings(
9369
max_bytes=max_bytes, # default 1MB
9470
max_messages=max_messages, # default 100 messages
9571
max_latency=max_latency,
9672
)
9773
# self._publisher = pubsub_v1.PublisherClient(batch_settings=batch_settings)
9874
self._publisher = BatchPubsubPublisherClient(batch_settings=batch_settings)
75+
self._raise_on_error = raise_on_error
76+
self._tags = [f'project:{self._project}', f'topic:{self._topic_name}']
9977

10078
def prepare_data(self, data: _PydanticModelT) -> bytes:
10179
"""
@@ -105,8 +83,6 @@ def prepare_data(self, data: _PydanticModelT) -> bytes:
10583
return data.json(exclude_none=True).encode()
10684

10785
def publish(self, data: _PydanticModelT, attributes: dict[str, str] | None = None) -> None:
108-
if not self._enabled:
109-
return
11086
if attributes is None:
11187
attributes = {}
11288

@@ -124,8 +100,6 @@ def publish(self, data: _PydanticModelT, attributes: dict[str, str] | None = Non
124100
metrics.increment(f'{self.__class__.__name__}.publisher.success', tags=self._tags)
125101

126102
def stop(self) -> None:
127-
if not self._enabled:
128-
return
129103
self._publisher.stop()
130104

131105

@@ -140,3 +114,41 @@ def publish(self, data: str, attributes: dict[str, str] | None = None) -> None:
140114
attributes = {}
141115

142116
super().publish(data, attributes) # type: ignore[type-var]
117+
118+
119+
def make_publisher(
120+
project_id: str,
121+
topic_id: str,
122+
raise_on_error: bool = False,
123+
max_bytes: int = 2000000,
124+
max_messages: int = 250,
125+
max_latency: float = 1.0,
126+
) -> BasePublisher:
127+
"""Build a Pub/Sub publisher, or a NullPublisher when publishing is off.
128+
129+
Publishing is off when PUBSUB_ENABLED is false, or when GCP credentials cannot
130+
be resolved (e.g. local dev or adopter environments without GCP). Missing
131+
credentials are a misconfiguration, so they also emit a one-time
132+
configuration.errors metric; the deliberate PUBSUB_ENABLED opt-out is silent.
133+
"""
134+
# Imported here to avoid pulling the config/singletons stack into this low-level module.
135+
from osprey.worker.lib.singletons import CONFIG
136+
137+
if not CONFIG.instance().get_bool('PUBSUB_ENABLED', True):
138+
logger.warning('PUBSUB_ENABLED is false, publishing disabled (project=%s, topic=%s)', project_id, topic_id)
139+
return NullPublisher()
140+
if not gcp_credentials_available():
141+
logger.warning('GCP credentials not detected, publishing disabled (project=%s, topic=%s)', project_id, topic_id)
142+
metrics.increment(
143+
'configuration.errors',
144+
tags=[f'project:{project_id}', f'topic:{topic_id}', 'reason:gcp_credentials_missing'],
145+
)
146+
return NullPublisher()
147+
return PubSubPublisher(
148+
project_id,
149+
topic_id,
150+
raise_on_error=raise_on_error,
151+
max_bytes=max_bytes,
152+
max_messages=max_messages,
153+
max_latency=max_latency,
154+
)

osprey_worker/src/osprey/worker/lib/tests/test_gcp_credentials.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import pytest
55
from google.auth.exceptions import DefaultCredentialsError
6-
from osprey.worker.lib import gcp_credentials
7-
from osprey.worker.lib.gcp_credentials import gcp_credentials_available, gcp_pubsub_disabled
6+
from osprey.worker.lib.utils import gcp_credentials
7+
from osprey.worker.lib.utils.gcp_credentials import gcp_credentials_available
88

99

1010
@pytest.fixture(autouse=True)
@@ -30,12 +30,3 @@ def test_result_is_cached() -> None:
3030
gcp_credentials_available()
3131
gcp_credentials_available()
3232
assert default_mock.call_count == 1
33-
34-
35-
def test_pubsub_disabled_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
36-
monkeypatch.delenv('DISABLE_GCP_PUBSUB', raising=False)
37-
assert gcp_pubsub_disabled() is False
38-
monkeypatch.setenv('DISABLE_GCP_PUBSUB', 'true')
39-
assert gcp_pubsub_disabled() is True
40-
monkeypatch.setenv('DISABLE_GCP_PUBSUB', 'false')
41-
assert gcp_pubsub_disabled() is False

0 commit comments

Comments
 (0)