blob: 88676f6666c94c29b73f738dff3cff37e4223807 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Persistent per-organization cache.
The cache is what makes frequent scans of a 10k member organization affordable.
Each organization gets one JSON file holding the last known members, matched
teams and repositories, each stamped with when it was read. A scan then only
re-reads what it has to:
* a cheap "meta" query gives the current member, team and repository totals for
one rate limit point, so a changed total forces a refresh;
* anything whose stamp is older than its configured TTL is refreshed anyway,
which covers the changes GitHub gives us no cheap signal for, such as a 2FA
toggle or a team that gained and lost a member since the last scan;
* every scan.full_scan_every rounds the scanner ignores all of the above and
re-reads everything, which is what reconciles deletions.
These files are JSON rather than YAML because they hold tens of thousands of
records and are rewritten on every scan, and PyYAML's serialiser is slow enough
for that to show.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import pathlib
import re
import tempfile
import time
import typing
from . import config as _config
from .ghtypes import as_dict, as_int
from .snapshot import MemberRecord, RepositoryRecord, TeamRecord
LOGGER = logging.getLogger("orgscanner.cache")
CACHE_VERSION = 2
_SAFE_NAME = re.compile(r"[^A-Za-z0-9_.-]+")
def safe_organization_name(organization: str) -> str:
"""The filename stem used for an organization's cache file.
An organization login cannot contain a path separator, but the scanner
should not hand one to the filesystem on the strength of that alone.
"""
return _SAFE_NAME.sub("_", organization).strip("._") or "org"
# The three kinds of data the cache tracks. Each has a matching set of
# <kind>, <kind>_total and <kind>_updated_at attributes on OrgCache.
CacheKind = typing.Literal["members", "teams", "repositories"]
class CachedTeamRecord(TeamRecord):
"""A team as cached, with the time its member list was last read."""
refreshed_at: float
def cached_team(record: TeamRecord, refreshed_at: float) -> CachedTeamRecord:
"""Stamp a team record with the time it was read."""
return {
"slug": record["slug"],
"name": record["name"],
"id": record["id"],
"privacy": record["privacy"],
"member_count": record["member_count"],
"members": record["members"],
"refreshed_at": refreshed_at,
}
@dataclasses.dataclass
class OrgCache:
"""Everything remembered about one organization between scans."""
organization: str
organization_id: int | None = None
scan_serial: int = 0
last_full_scan: float = 0.0
last_scan: float = 0.0
members: dict[str, MemberRecord] = dataclasses.field(default_factory=dict)
members_total: int = -1
members_updated_at: float = 0.0
teams: dict[str, CachedTeamRecord] = dataclasses.field(default_factory=dict)
teams_total: int = -1
teams_updated_at: float = 0.0
# The patterns that produced the cached team set. If an operator edits them,
# the cached teams answer the wrong question and have to be thrown away.
team_patterns: list[str] = dataclasses.field(default_factory=list)
repositories: dict[str, RepositoryRecord] = dataclasses.field(default_factory=dict)
repositories_total: int = -1
repositories_updated_at: float = 0.0
@property
def is_empty(self) -> bool:
return not (self.members or self.teams or self.repositories)
def records(self, kind: CacheKind) -> dict[str, typing.Any]:
"""The cached records for one kind of data."""
records: dict[str, typing.Any] = getattr(self, kind)
return records
def total(self, kind: CacheKind) -> int:
"""The organization total that was current when kind was last read."""
return int(getattr(self, f"{kind}_total"))
def age(self, kind: CacheKind) -> float:
"""Seconds since kind was last read, or inf if it never was."""
stamp = float(getattr(self, f"{kind}_updated_at"))
if not stamp:
return float("inf")
return max(0.0, time.time() - stamp)
def is_fresh(self, kind: CacheKind, ttl: int, current_total: int | None) -> bool:
"""True if the cached kind can be reused without asking GitHub again."""
if not self.records(kind):
return False
if ttl <= 0: # A zero TTL means "never trust the cache for this"
return False
if self.age(kind) > ttl:
return False
if current_total is not None and self.total(kind) != current_total:
return False
return True
def to_dict(self) -> dict[str, typing.Any]:
data = dataclasses.asdict(self)
data["version"] = CACHE_VERSION
return data
@classmethod
def from_dict(cls, data: typing.Mapping[str, typing.Any], organization: str) -> OrgCache:
if as_int(data.get("version"), -1) != CACHE_VERSION:
# A format change simply invalidates the cache; the next scan is full.
return cls(organization=organization)
known = {field.name for field in dataclasses.fields(cls)}
kwargs = {key: value for key, value in data.items() if key in known}
kwargs["organization"] = organization
cache = cls(**kwargs)
# A hand-edited or truncated file must not take the scanner down.
for kind in typing.get_args(CacheKind):
if not isinstance(getattr(cache, kind), dict):
setattr(cache, kind, {})
setattr(cache, f"{kind}_total", -1)
setattr(cache, f"{kind}_updated_at", 0.0)
if not isinstance(cache.team_patterns, list):
cache.team_patterns = []
return cache
class CacheStore:
"""Loads and saves OrgCache files, and does nothing when caching is off."""
def __init__(self, config: _config.CacheConfig):
self.config = config
@property
def enabled(self) -> bool:
return bool(self.config.enabled and self.config.directory)
def path_for(self, organization: str) -> pathlib.Path:
directory = self.config.directory
assert directory is not None, "Cache directory must be configured"
return directory / f"{safe_organization_name(organization)}.cache.json"
def load(self, organization: str) -> OrgCache:
if not self.enabled:
return OrgCache(organization=organization)
path = self.path_for(organization)
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return OrgCache(organization=organization)
except (OSError, ValueError) as e:
LOGGER.warning("Ignoring unreadable cache %s: %s", path, e)
return OrgCache(organization=organization)
cache = OrgCache.from_dict(as_dict(data), organization)
LOGGER.debug(
"Loaded cache for %s: %d members, %d teams, %d repositories",
organization,
len(cache.members),
len(cache.teams),
len(cache.repositories),
)
return cache
def save(self, cache: OrgCache) -> pathlib.Path | None:
if not self.enabled:
return None
path = self.path_for(cache.organization)
try:
path.parent.mkdir(parents=True, exist_ok=True)
# Write and rename, so a crash mid-write cannot leave half a cache
# behind. A corrupt file would cost us a full rescan.
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
try:
with os.fdopen(handle, "w", encoding="utf-8") as f:
json.dump(cache.to_dict(), f)
os.replace(tmp_name, path)
except BaseException:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
raise
except OSError as e:
LOGGER.warning("Could not write cache %s: %s", path, e)
return None
return path