Skip to content

Commit

Permalink
Suport multiple DB connections by naming respective health-provider a…
Browse files Browse the repository at this point in the history
…nd composing them

Issue #96 asked to support for showing health of multiple DB engines/endpoints.

To address this, Pyctuator now supports `ComposeiteHealthProvider` which wraps around
a list of health-providers.

Also, the built-in health-providers now support overrding the default name.

Setting health check for multiple DB engines can be done as follows:
```python
pyctuator.register_health_provider(
    CompositeHealthProvider(
        "db",
        DbHealthProvider(db_engine, "db1"),
        DbHealthProvider(db_engine, "db2"),
    )
)

```
  • Loading branch information
michael.yak committed Oct 6, 2023
1 parent 995413d commit 13f456b
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 6 deletions.
48 changes: 48 additions & 0 deletions pyctuator/health/composite_health_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from dataclasses import dataclass
from typing import Mapping

from pyctuator.health.health_provider import HealthProvider, HealthStatus, Status


@dataclass
class CompositeHealthStatus(HealthStatus):
status: Status
details: Mapping[str, HealthStatus] # type: ignore[assignment]


class CompositeHealthProvider(HealthProvider):

def __init__(self, name: str, *health_providers: HealthProvider) -> None:
super().__init__()
self.name = name
self.health_providers = health_providers

def is_supported(self) -> bool:
return True

def get_name(self) -> str:
return self.name

def get_health(self) -> CompositeHealthStatus:
health_statuses: Mapping[str, HealthStatus] = {
provider.get_name(): provider.get_health()
for provider in self.health_providers
if provider.is_supported()
}

# Health is UP if no provider is registered
if not health_statuses:
return CompositeHealthStatus(Status.UP, health_statuses)

# If there's at least one provider and any of the providers is DOWN, the service is DOWN
service_is_down = any(health_status.status == Status.DOWN for health_status in health_statuses.values())
if service_is_down:
return CompositeHealthStatus(Status.DOWN, health_statuses)

# If there's at least one provider and none of the providers is DOWN and at least one is UP, the service is UP
service_is_up = any(health_status.status == Status.UP for health_status in health_statuses.values())
if service_is_up:
return CompositeHealthStatus(Status.UP, health_statuses)

# else, all providers are unknown so the service is UNKNOWN
return CompositeHealthStatus(Status.UNKNOWN, health_statuses)
10 changes: 7 additions & 3 deletions pyctuator/health/db_health_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,25 @@ class DbHealthStatus(HealthStatus):

class DbHealthProvider(HealthProvider):

def __init__(self, engine: Engine) -> None:
def __init__(self, engine: Engine, name: str = "db") -> None:
super().__init__()
self.engine = engine
self.name = name

def is_supported(self) -> bool:
return importlib.util.find_spec("sqlalchemy") is not None

def get_name(self) -> str:
return "db"
return self.name

def get_health(self) -> DbHealthStatus:
try:
with self.engine.connect() as conn:
if self.engine.dialect.do_ping(conn.connection): # type: ignore[arg-type]
return DbHealthStatus(status=Status.UP, details=DbHealthDetails(self.engine.name))
return DbHealthStatus(
status=Status.UP,
details=DbHealthDetails(self.engine.name)
)

return DbHealthStatus(
status=Status.UNKNOWN,
Expand Down
5 changes: 3 additions & 2 deletions pyctuator/health/redis_health_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ class RedisHealthStatus(HealthStatus):

class RedisHealthProvider(HealthProvider):

def __init__(self, redis: Redis) -> None:
def __init__(self, redis: Redis, name: str = "redis") -> None:
super().__init__()
self.redis = redis
self.name = name

def is_supported(self) -> bool:
return importlib.util.find_spec("redis") is not None

def get_name(self) -> str:
return "redis"
return self.name

def get_health(self) -> RedisHealthStatus:
try:
Expand Down
2 changes: 1 addition & 1 deletion pyctuator/impl/pyctuator_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def get_health(self) -> HealthSummary:
if service_is_down:
return HealthSummary(Status.DOWN, health_statuses)

# IF there's at least one provider and none of the providers is DOWN and at least one is UP, the service is UP
# If there's at least one provider and none of the providers is DOWN and at least one is UP, the service is UP
service_is_up = any(health_status.status == Status.UP for health_status in health_statuses.values())
if service_is_up:
return HealthSummary(Status.UP, health_statuses)
Expand Down
75 changes: 75 additions & 0 deletions tests/health/test_composite_health_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from dataclasses import dataclass

from pyctuator.health.composite_health_provider import CompositeHealthProvider, CompositeHealthStatus
from pyctuator.health.health_provider import HealthProvider, HealthStatus, Status, HealthDetails


@dataclass
class CustomHealthDetails(HealthDetails):
details: str


class CustomHealthProvider(HealthProvider):

def __init__(self, name: str, status: HealthStatus) -> None:
super().__init__()
self.name = name
self.status = status

def is_supported(self) -> bool:
return True

def get_name(self) -> str:
return self.name

def get_health(self) -> HealthStatus:
return self.status


def test_composite_health_provider_no_providers() -> None:
health_provider = CompositeHealthProvider(
"comp1",
)

assert health_provider.get_name() == "comp1"

assert health_provider.get_health() == CompositeHealthStatus(
status=Status.UP,
details={}
)


def test_composite_health_provider_all_up() -> None:
health_provider = CompositeHealthProvider(
"comp2",
CustomHealthProvider("hp1", HealthStatus(Status.UP, CustomHealthDetails("d1"))),
CustomHealthProvider("hp2", HealthStatus(Status.UP, CustomHealthDetails("d2"))),
)

assert health_provider.get_name() == "comp2"

assert health_provider.get_health() == CompositeHealthStatus(
status=Status.UP,
details={
"hp1": HealthStatus(Status.UP, CustomHealthDetails("d1")),
"hp2": HealthStatus(Status.UP, CustomHealthDetails("d2")),
}
)


def test_composite_health_provider_one_down() -> None:
health_provider = CompositeHealthProvider(
"comp3",
CustomHealthProvider("hp1", HealthStatus(Status.UP, CustomHealthDetails("d1"))),
CustomHealthProvider("hp2", HealthStatus(Status.DOWN, CustomHealthDetails("d2"))),
)

assert health_provider.get_name() == "comp3"

assert health_provider.get_health() == CompositeHealthStatus(
status=Status.DOWN,
details={
"hp1": HealthStatus(Status.UP, CustomHealthDetails("d1")),
"hp2": HealthStatus(Status.DOWN, CustomHealthDetails("d2")),
}
)
2 changes: 2 additions & 0 deletions tests/health/test_db_health_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def test_sqlite_health() -> None:
engine = create_engine("sqlite:///:memory:", echo=True)
health_provider = DbHealthProvider(engine)
assert health_provider.get_health() == DbHealthStatus(status=Status.UP, details=DbHealthDetails("sqlite"))
assert health_provider.get_name() == "db"
assert DbHealthProvider(engine, "kuki").get_name() == "kuki"


@pytest.mark.usefixtures("require_sql_alchemy", "require_pymysql", "require_mysql_server")
Expand Down

0 comments on commit 13f456b

Please sign in to comment.