-
Notifications
You must be signed in to change notification settings - Fork 0
feat: enforce per-org cache storage quotas #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,17 @@ def _initialise(self) -> None: | |
| """ | ||
| ) | ||
| ) | ||
| conn.execute( | ||
| text( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS org_usage ( | ||
| org_id INTEGER PRIMARY KEY, | ||
| total_bytes INTEGER NOT NULL, | ||
| updated_at DOUBLE PRECISION NOT NULL | ||
| ) | ||
| """ | ||
| ) | ||
| ) | ||
| conn.execute( | ||
| text( | ||
| """ | ||
|
|
@@ -249,6 +260,40 @@ def get_blob_org_id(self, digest: str) -> Optional[int]: | |
| row = result.fetchone() | ||
| return row[0] if row else None | ||
|
|
||
| def add_org_bytes(self, org_id: int, delta: int) -> int: | ||
| now = time.time() | ||
| with self._engine.begin() as conn: | ||
| conn.execute( | ||
| text( | ||
| """ | ||
| INSERT INTO org_usage (org_id, total_bytes, updated_at) | ||
| VALUES (:org_id, CASE WHEN :delta < 0 THEN 0 ELSE :delta END, :updated_at) | ||
| ON CONFLICT(org_id) DO UPDATE SET | ||
| total_bytes = CASE | ||
| WHEN org_usage.total_bytes + :delta < 0 THEN 0 | ||
| ELSE org_usage.total_bytes + :delta | ||
| END, | ||
| updated_at = :updated_at | ||
| """ | ||
| ), | ||
| {"org_id": org_id, "delta": delta, "updated_at": now}, | ||
| ) | ||
| result = conn.execute( | ||
| text("SELECT total_bytes FROM org_usage WHERE org_id=:org_id"), | ||
| {"org_id": org_id}, | ||
| ) | ||
| value = result.scalar_one() | ||
| return int(value) | ||
|
|
||
| def get_org_bytes(self, org_id: int) -> int: | ||
| with self._engine.connect() as conn: | ||
| result = conn.execute( | ||
| text("SELECT total_bytes FROM org_usage WHERE org_id=:org_id"), | ||
| {"org_id": org_id}, | ||
| ) | ||
| row = result.fetchone() | ||
| return int(row[0]) if row else 0 | ||
|
|
||
|
|
||
| class DockerCacheState: | ||
| def __init__(self, settings: DockerCacheSettings, metrics: DockerCacheMetrics): | ||
|
|
@@ -331,12 +376,20 @@ def ensure_storage_limit(self) -> None: | |
| path = self.blob_path(digest) | ||
| if path.exists(): | ||
| path.unlink(missing_ok=True) | ||
| blob_org = self.metrics.get_blob_org_id(digest) | ||
| self.metrics.delete_blob(digest) | ||
| if blob_org is not None: | ||
| self.update_org_usage(blob_org, -size) | ||
| total -= size | ||
| EVICTION_COUNTER.inc() | ||
| self.logger.info("blob_evicted", digest=digest, reclaimed_bytes=size) | ||
| TOTAL_BLOB_BYTES_GAUGE.set(float(self.metrics.total_blob_bytes())) | ||
|
|
||
| def update_org_usage(self, org_id: Optional[int], delta: int) -> int: | ||
| if org_id is None or delta == 0: | ||
| return 0 | ||
| return self.metrics.add_org_bytes(int(org_id), delta) | ||
|
|
||
|
|
||
| def get_state(request: Request) -> DockerCacheState: | ||
| state = getattr(request.app.state, "cache_state", None) | ||
|
|
@@ -541,6 +594,22 @@ async def finalize_blob_upload( | |
| final_session = await state.finalize_upload(upload_id) | ||
| target_path = state.blob_path(expected_digest) | ||
| target_path.parent.mkdir(parents=True, exist_ok=True) | ||
| quota = state.settings.org_storage_quota_bytes | ||
| new_total = state.update_org_usage(token.organization_id, final_session.size) | ||
| if quota is not None and new_total > quota: | ||
| state.update_org_usage(token.organization_id, -final_session.size) | ||
| final_session.file_path.unlink(missing_ok=True) | ||
|
Comment on lines
+597
to
+601
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| state.logger.warning( | ||
| "docker_org_quota_exceeded", | ||
| org_id=token.organization_id, | ||
| wrote_bytes=final_session.size, | ||
| new_total=new_total, | ||
| quota=quota, | ||
| ) | ||
| raise HTTPException( | ||
| status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, | ||
| detail=f"Org {token.organization_id} exceeded docker cache quota", | ||
| ) | ||
| if target_path.exists(): | ||
| target_path.touch() # update mtime for eviction ordering | ||
| final_session.file_path.unlink(missing_ok=True) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PUT handler increments the org’s byte counter only after the blob has been written, and when the quota is exceeded it merely subtracts the counter and returns
413but never removes the already-written object. This allows rejected writes to remain stored and retrievable, so disk usage can exceed the configured quota while metrics claim otherwise. Consider deleting the object (e.g. via the backend) before raising the exception so quota enforcement actually prevents the artifact from being cached.Useful? React with 👍 / 👎.