Skip to content

Commit

Permalink
feat: Render integration
Browse files Browse the repository at this point in the history
* Add Render support

Adopt pytest

Update tests

Update CI script

* Add ability to run sample apps as if on Render

* Cleanup

* Rename initialise -> initialize

* Use absolute tolerances when asserting approximate results
  • Loading branch information
karls authored Mar 17, 2023
1 parent ed56895 commit cd62c24
Show file tree
Hide file tree
Showing 47 changed files with 540 additions and 270 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ jobs:
- name: Install library
run: poetry install --all-extras --no-interaction

- name: Test with unittest
run: poetry run python -m unittest discover --buffer --start-directory tests
- name: Test with pytest
run: poetry run pytest
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,5 +330,5 @@ $ poetry shell
Run tests with

```sh
$ python -m unittest discover -s tests
$ pytest
```
3 changes: 2 additions & 1 deletion judoscale/celery/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import time
from typing import Mapping

from celery import Celery
from celery import __version__ as celery_version
Expand All @@ -15,7 +16,7 @@ def before_publish(*args, properties={}, **kwargs):
properties["published_at"] = time.time()


def judoscale_celery(celery: Celery, extra_config: dict = {}) -> None:
def judoscale_celery(celery: Celery, extra_config: Mapping = {}) -> None:
celery.conf.task_send_sent_event = True

judoconfig.merge(extra_config)
Expand Down
67 changes: 57 additions & 10 deletions judoscale/core/config.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,69 @@
import logging
import os
from typing import Mapping

from judoscale.core.logger import logger


class RuntimeContainer:
def __init__(self, service_name, instance, service_type):
self.service_name = service_name
self.instance = instance
self.service_type = service_type

@property
def is_web_instance(self):
return self.service_name == "web" or self.service_type == "web"

@property
def is_redundant_instance(self):
return self.instance.isdigit() and self.instance != "1"

def __str__(self):
return f"{self.service_name}.{self.instance}"


class Config:
def __init__(self):
self.log_level = os.environ.get("LOG_LEVEL", "INFO")
self.dyno = os.environ.get("DYNO", "none.0")
self.dyno_name, self.dyno_num = self.dyno.split(".")
self.dyno_num = int(self.dyno_num)
def __init__(
self, runtime_container: RuntimeContainer, api_base_url: str, log_level: str
):
self.runtime_container = runtime_container
self.log_level = log_level
self.report_interval_seconds = 10
self.api_base_url = os.environ.get("JUDOSCALE_URL")
self.api_base_url = api_base_url
self._prepare_logging()

def merge(self, settings: dict):
@classmethod
def initialize(cls, env: Mapping = os.environ):
if env.get("DYNO"):
return cls.for_heroku(env)
elif env.get("RENDER_INSTANCE_ID"):
return cls.for_render(env)
else:
return cls(None, "", "INFO")

@classmethod
def for_heroku(cls, env: Mapping):
service_name, instance = env.get("DYNO").split(".")
service_type = "web" if service_name == "web" else "other"

runtime_container = RuntimeContainer(service_name, instance, service_type)
api_base_url = env.get("JUDOSCALE_URL")
log_level = env.get("LOG_LEVEL", "INFO").upper()
return cls(runtime_container, api_base_url, log_level)

@classmethod
def for_render(cls, env: Mapping):
service_id = env.get("RENDER_SERVICE_ID")
instance = env.get("RENDER_INSTANCE_ID").replace(f"{service_id}-", "")
service_type = env.get("RENDER_SERVICE_TYPE")

runtime_container = RuntimeContainer(service_id, instance, service_type)
api_base_url = f"https://adapter.judoscale.com/api/{service_id}"
log_level = env.get("LOG_LEVEL", "INFO").upper()
return cls(runtime_container, api_base_url, log_level)

def merge(self, settings: Mapping):
for key, value in settings.items():
setattr(self, key.lower(), value)
self._prepare_logging()
Expand All @@ -36,7 +85,5 @@ def _prepare_logging(self):
stdout_handler.setFormatter(logging.Formatter(fmt))
logger.addHandler(stdout_handler)

logger.propagate = False


config = Config()
config = Config.initialize()
4 changes: 2 additions & 2 deletions judoscale/core/metrics_collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def __init__(self, config: Config):

@property
def should_collect(self):
return self.config.dyno_name == "web"
return self.config.runtime_container.is_web_instance

def add(self, metric: Metric):
"""
Expand All @@ -52,4 +52,4 @@ def __init__(self, config: Config):

@property
def should_collect(self):
return self.config.dyno_num == 1
return not self.config.runtime_container.is_redundant_instance
6 changes: 3 additions & 3 deletions judoscale/core/metrics_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, max_flush_interval: int = 60):
# if they are never being flushed (if the reporter has failed for some
# reason).
self.max_flush_interval: int = max_flush_interval
self.last_flush_time: float = time.time()
self.last_flush_time: float = time.monotonic()

def add(self, metric: Metric) -> None:
"""
Expand All @@ -25,7 +25,7 @@ def add(self, metric: Metric) -> None:
If the max flush interval has been exceeded, the metric will not be
added to the store.
"""
if self.last_flush_time > time.time() - self.max_flush_interval:
if self.last_flush_time > time.monotonic() - self.max_flush_interval:
self.store.append(metric)
else:
logger.debug("Max flush interval exceeded - Not adding metric to store.")
Expand All @@ -34,7 +34,7 @@ def flush(self) -> List[Metric]:
"""
Return all metrics in the store and clear the store.
"""
self.last_flush_time = time.time()
self.last_flush_time = time.monotonic()
result = []
# This operation needs to be atomic since the main thread is appending
# to the store
Expand Down
13 changes: 7 additions & 6 deletions judoscale/core/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from judoscale.core.adapter import Adapter, AdapterInfo
from judoscale.core.adapter_api_client import api_client
from judoscale.core.config import config
from judoscale.core.config import Config, config
from judoscale.core.logger import logger
from judoscale.core.metric import Metric
from judoscale.core.metrics_collectors import Collector
Expand All @@ -19,7 +19,8 @@ class Reporter:
them to the Judoscale API.
"""

def __init__(self):
def __init__(self, config: Config):
self.config = config
self._thread = None
self._running = False
self._stopevent = threading.Event()
Expand Down Expand Up @@ -78,7 +79,7 @@ def pid(self) -> int:
def _run_loop(self):
while self.is_running:
self._report_metrics()
time.sleep(config.report_interval_seconds)
time.sleep(self.config.report_interval_seconds)

if self._stopevent.is_set():
break
Expand All @@ -100,13 +101,13 @@ def _report_metrics(self) -> None:

def _build_report(self, metrics: List[Metric]):
return {
"dyno": config.dyno,
"container": str(self.config.runtime_container),
"pid": self.pid,
"config": config.for_report(),
"config": self.config.for_report(),
"adapters": dict(adapter.as_tuple for adapter in self.adapters),
"metrics": [metric.as_tuple for metric in metrics],
}


reporter = Reporter()
reporter = Reporter(config=config)
signal.signal(signal.SIGTERM, reporter.signal_handler)
4 changes: 3 additions & 1 deletion judoscale/rq/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Mapping

import rq
from redis import Redis

Expand All @@ -7,7 +9,7 @@
from judoscale.rq.collector import RQMetricsCollector


def judoscale_rq(redis: Redis, extra_config: dict = {}) -> None:
def judoscale_rq(redis: Redis, extra_config: Mapping = {}) -> None:
judoconfig.merge(extra_config)
collector = RQMetricsCollector(config=judoconfig, redis=redis)
adapter = Adapter(
Expand Down
100 changes: 99 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ rq = { version = ">=1.0.0,<2.0.0", optional = true }
black = "^22.12.0"
isort = "^5.11.2"
flake8 = { version = "^6.0.0", python = ">=3.8.1,<4.0.0" }
pytest = "^7.2.2"

[tool.poetry.extras]
django = ["django"]
Expand All @@ -40,6 +41,11 @@ rq = ["rq"]
[tool.isort]
profile = "black"

[tool.pytest.ini_options]
filterwarnings = [
"ignore:SelectableGroups dict interface is deprecated:DeprecationWarning"
]

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
File renamed without changes.
7 changes: 7 additions & 0 deletions sample-apps/django_celery_sample/Procfile.render
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# The proxy server adds the X_REQUEST_START header for queue time calculations
proxy: npx judoscale-adapter-proxy-server

# Run with --noreload to avoid multiple server processes (and multiple Judoscale reporters)
web: RENDER_SERVICE_ID=srv-123 RENDER_INSTANCE_ID=srv-123-abc-456 RENDER_SERVICE_TYPE=web poetry run gunicorn django_celery_sample.wsgi:application --preload
worker: RENDER_SERVICE_ID=wrk-123 RENDER_INSTANCE_ID=wrk-123-abc-456 RENDER_SERVICE_TYPE=worker poetry run celery -A django_celery_sample worker -c 1 --loglevel=INFO -Q low
worker_high: RENDER_SERVICE_ID=wrk-456 RENDER_INSTANCE_ID=wrk-456-abc-789 RENDER_SERVICE_TYPE=worker poetry run celery -A django_celery_sample worker -c 1 --loglevel=INFO -Q high
Loading

0 comments on commit cd62c24

Please sign in to comment.