buildgrid 0.3.5__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- buildgrid/server/app/cli.py +4 -4
- buildgrid/server/app/settings/parser.py +46 -17
- buildgrid/server/app/settings/schema.yml +16 -2
- buildgrid/server/bots/service.py +4 -10
- buildgrid/server/cas/storage/index/index_abc.py +0 -7
- buildgrid/server/cas/storage/index/sql.py +91 -215
- buildgrid/server/cas/storage/redis_fmb_cache.py +220 -0
- buildgrid/server/enums.py +5 -0
- buildgrid/server/metrics_names.py +0 -2
- buildgrid/server/scheduler/impl.py +298 -70
- buildgrid/server/sql/alembic/versions/3737630fc9cf_remove_deleted_column_from_sql_cas_index.py +43 -0
- buildgrid/server/sql/models.py +0 -2
- buildgrid/server/sql/utils.py +3 -3
- buildgrid/server/utils/bots.py +1 -1
- buildgrid/server/version.py +1 -1
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/METADATA +2 -2
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/RECORD +21 -19
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/WHEEL +1 -1
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/entry_points.txt +0 -0
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {buildgrid-0.3.5.dist-info → buildgrid-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Copyright (C) 2026 Bloomberg LP
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# <http://www.apache.org/licenses/LICENSE-2.0>
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
A storage provider that uses redis to maintain a cache of existence for some underlying storage.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from datetime import timedelta
|
|
20
|
+
from typing import Callable, IO, TypeVar
|
|
21
|
+
|
|
22
|
+
import redis
|
|
23
|
+
|
|
24
|
+
from buildgrid._protos.build.bazel.remote.execution.v2.remote_execution_pb2 import Digest
|
|
25
|
+
from buildgrid._protos.google.rpc import code_pb2
|
|
26
|
+
from buildgrid._protos.google.rpc.status_pb2 import Status
|
|
27
|
+
from buildgrid.server.cas.storage.storage_abc import StorageABC
|
|
28
|
+
from buildgrid.server.decorators import timed
|
|
29
|
+
from buildgrid.server.enums import BlobExistence
|
|
30
|
+
from buildgrid.server.logging import buildgrid_logger
|
|
31
|
+
from buildgrid.server.metrics_names import METRIC
|
|
32
|
+
from buildgrid.server.redis.provider import RedisProvider
|
|
33
|
+
|
|
34
|
+
LOGGER = buildgrid_logger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RedisFMBCache(StorageABC):
|
|
41
|
+
TYPE = "RedisFMBCache"
|
|
42
|
+
|
|
43
|
+
def __init__(self, redis: RedisProvider, storage: StorageABC, ttl_secs: int, prefix: str | None = None) -> None:
|
|
44
|
+
self._redis = redis
|
|
45
|
+
self._storage = storage
|
|
46
|
+
self._prefix = "C"
|
|
47
|
+
if prefix == "C":
|
|
48
|
+
LOGGER.error("Prefix 'C' is reserved as the default prefix and cannot be used")
|
|
49
|
+
raise ValueError("Prefix 'C' is reserved as the default prefix and cannot be used")
|
|
50
|
+
elif prefix:
|
|
51
|
+
self._prefix = prefix
|
|
52
|
+
self._ttl = timedelta(seconds=ttl_secs)
|
|
53
|
+
|
|
54
|
+
def start(self) -> None:
|
|
55
|
+
self._storage.start()
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
self._storage.stop()
|
|
59
|
+
|
|
60
|
+
def _construct_key(self, digest: Digest) -> str:
|
|
61
|
+
"""Helper to get the redis key name for a particular digest"""
|
|
62
|
+
# The tag prefix serves to distinguish between our keys and
|
|
63
|
+
# actual blobs if the same redis is used for both index and storage
|
|
64
|
+
return f"{self._prefix}:{digest.hash}_{digest.size_bytes}"
|
|
65
|
+
|
|
66
|
+
def _deconstruct_key(self, keystr: str) -> Digest | None:
|
|
67
|
+
"""Helper to attempt to recover a Digest from a redis key"""
|
|
68
|
+
try:
|
|
69
|
+
tag, rest = keystr.split(":", 1)
|
|
70
|
+
if tag != self._prefix:
|
|
71
|
+
return None
|
|
72
|
+
hash, size_bytes = rest.rsplit("_", 1)
|
|
73
|
+
return Digest(hash=hash, size_bytes=int(size_bytes))
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _safe_redis_ro(self, func: Callable[["redis.Redis[bytes]"], T]) -> T | None:
|
|
78
|
+
try:
|
|
79
|
+
return self._redis.execute_ro(func)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _safe_redis_rw(self, func: Callable[["redis.Redis[bytes]"], T]) -> T | None:
|
|
84
|
+
try:
|
|
85
|
+
return self._redis.execute_rw(func)
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
@timed(METRIC.STORAGE.STAT_DURATION, type=TYPE)
|
|
90
|
+
def has_blob(self, digest: Digest) -> bool:
|
|
91
|
+
blob_present = self._safe_redis_ro(lambda r: r.get(self._construct_key(digest)))
|
|
92
|
+
|
|
93
|
+
# If we don't have a key cached for this digest, check the underlying storage
|
|
94
|
+
if blob_present is None:
|
|
95
|
+
storage_has_blob = self._storage.has_blob(digest)
|
|
96
|
+
value = BlobExistence.EXISTS.value if storage_has_blob else BlobExistence.DOES_NOT_EXIST.value
|
|
97
|
+
# Cache the result
|
|
98
|
+
self._safe_redis_rw(lambda r: r.set(self._construct_key(digest), value, ex=self._ttl))
|
|
99
|
+
return storage_has_blob
|
|
100
|
+
|
|
101
|
+
return bool(int(blob_present))
|
|
102
|
+
|
|
103
|
+
@timed(METRIC.STORAGE.READ_DURATION, type=TYPE)
|
|
104
|
+
def get_blob(self, digest: Digest) -> IO[bytes] | None:
|
|
105
|
+
if blob := self._storage.get_blob(digest):
|
|
106
|
+
self._safe_redis_rw(lambda r: r.set(self._construct_key(digest), BlobExistence.EXISTS.value, ex=self._ttl))
|
|
107
|
+
return blob
|
|
108
|
+
|
|
109
|
+
self._safe_redis_rw(
|
|
110
|
+
lambda r: r.set(self._construct_key(digest), BlobExistence.DOES_NOT_EXIST.value, ex=self._ttl)
|
|
111
|
+
)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@timed(METRIC.STORAGE.DELETE_DURATION, type=TYPE)
|
|
115
|
+
def delete_blob(self, digest: Digest) -> None:
|
|
116
|
+
self._safe_redis_rw(lambda r: r.delete(self._construct_key(digest)))
|
|
117
|
+
self._storage.delete_blob(digest)
|
|
118
|
+
|
|
119
|
+
# Delete the key again, in case we raced with an insertion and deleted the
|
|
120
|
+
# storage out from under it. This lets us err on the side of having an uncached
|
|
121
|
+
# existence check rather than an incorrect cache.
|
|
122
|
+
self._safe_redis_rw(lambda r: r.delete(self._construct_key(digest)))
|
|
123
|
+
|
|
124
|
+
@timed(METRIC.STORAGE.WRITE_DURATION, type=TYPE)
|
|
125
|
+
def commit_write(self, digest: Digest, write_session: IO[bytes]) -> None:
|
|
126
|
+
self._storage.commit_write(digest, write_session)
|
|
127
|
+
self._safe_redis_rw(lambda r: r.set(self._construct_key(digest), BlobExistence.EXISTS.value, ex=self._ttl))
|
|
128
|
+
|
|
129
|
+
@timed(METRIC.STORAGE.BULK_DELETE_DURATION, type=TYPE)
|
|
130
|
+
def bulk_delete(self, digests: list[Digest]) -> list[str]:
|
|
131
|
+
# Delete any digests in the list that we have cached
|
|
132
|
+
keys = [self._construct_key(digest) for digest in digests]
|
|
133
|
+
self._safe_redis_rw(lambda r: r.delete(*keys))
|
|
134
|
+
|
|
135
|
+
failed_deletes = self._storage.bulk_delete(digests)
|
|
136
|
+
|
|
137
|
+
# Redo the deletion in case we raced an insert. Worst-case this leaves us with an
|
|
138
|
+
# uncached key
|
|
139
|
+
self._safe_redis_rw(lambda r: r.delete(*keys))
|
|
140
|
+
return failed_deletes
|
|
141
|
+
|
|
142
|
+
@timed(METRIC.STORAGE.BULK_STAT_DURATION, type=TYPE)
|
|
143
|
+
def missing_blobs(self, digests: list[Digest]) -> list[Digest]:
|
|
144
|
+
def check_existence(r: "redis.Redis[bytes]") -> list[bytes]:
|
|
145
|
+
pipe = r.pipeline(transaction=False)
|
|
146
|
+
for digest in digests:
|
|
147
|
+
pipe.get(self._construct_key(digest))
|
|
148
|
+
return pipe.execute()
|
|
149
|
+
|
|
150
|
+
results = self._safe_redis_ro(check_existence)
|
|
151
|
+
missing_digests = []
|
|
152
|
+
uncached_digests = []
|
|
153
|
+
|
|
154
|
+
# Map digests to pipeline results, then work out the subsets which are uncached
|
|
155
|
+
# (needing to be checked in the underlying storage) and missing (cached as not present)
|
|
156
|
+
if results is not None:
|
|
157
|
+
for digest, result in zip(digests, results):
|
|
158
|
+
if result == BlobExistence.DOES_NOT_EXIST.value:
|
|
159
|
+
missing_digests.append(digest)
|
|
160
|
+
elif result is None:
|
|
161
|
+
uncached_digests.append(digest)
|
|
162
|
+
|
|
163
|
+
# If the redis call failed, just fall back to the underlying storage for all digests
|
|
164
|
+
else:
|
|
165
|
+
uncached_digests = digests
|
|
166
|
+
|
|
167
|
+
if uncached_digests:
|
|
168
|
+
missing_digests.extend(self._storage.missing_blobs(uncached_digests))
|
|
169
|
+
|
|
170
|
+
def populate_cache(r: "redis.Redis[bytes]") -> None:
|
|
171
|
+
pipe = r.pipeline()
|
|
172
|
+
for digest in uncached_digests:
|
|
173
|
+
key = self._construct_key(digest)
|
|
174
|
+
if digest in missing_digests:
|
|
175
|
+
pipe.set(key, BlobExistence.DOES_NOT_EXIST.value, ex=self._ttl)
|
|
176
|
+
else:
|
|
177
|
+
pipe.set(key, BlobExistence.EXISTS.value, ex=self._ttl)
|
|
178
|
+
pipe.execute()
|
|
179
|
+
|
|
180
|
+
self._safe_redis_rw(populate_cache)
|
|
181
|
+
return missing_digests
|
|
182
|
+
|
|
183
|
+
@timed(METRIC.STORAGE.BULK_WRITE_DURATION, type=TYPE)
|
|
184
|
+
def bulk_update_blobs(self, blobs: list[tuple[Digest, bytes]]) -> list[Status]:
|
|
185
|
+
result_map: dict[str, Status] = {}
|
|
186
|
+
missing_blob_pairs: list[tuple[Digest, bytes]] = []
|
|
187
|
+
missing_blobs = self.missing_blobs([digest for digest, _ in blobs])
|
|
188
|
+
for digest, blob in blobs:
|
|
189
|
+
if digest not in missing_blobs:
|
|
190
|
+
result_map[digest.hash] = Status(code=code_pb2.OK)
|
|
191
|
+
else:
|
|
192
|
+
missing_blob_pairs.append((digest, blob))
|
|
193
|
+
results = self._storage.bulk_update_blobs(missing_blob_pairs)
|
|
194
|
+
|
|
195
|
+
def cache_existence(r: "redis.Redis[bytes]") -> None:
|
|
196
|
+
pipe = r.pipeline()
|
|
197
|
+
for digest, result in zip(missing_blobs, results):
|
|
198
|
+
result_map[digest.hash] = result
|
|
199
|
+
if result.code == code_pb2.OK:
|
|
200
|
+
key = self._construct_key(digest)
|
|
201
|
+
pipe.set(key, BlobExistence.EXISTS.value, ex=self._ttl)
|
|
202
|
+
pipe.execute()
|
|
203
|
+
|
|
204
|
+
self._safe_redis_rw(cache_existence)
|
|
205
|
+
return [result_map[digest.hash] for digest, _ in blobs]
|
|
206
|
+
|
|
207
|
+
@timed(METRIC.STORAGE.BULK_READ_DURATION, type=TYPE)
|
|
208
|
+
def bulk_read_blobs(self, digests: list[Digest]) -> dict[str, bytes]:
|
|
209
|
+
fetched_digests = self._storage.bulk_read_blobs(digests)
|
|
210
|
+
|
|
211
|
+
fetched_digest_hashes = set(digest_hash for (digest_hash, _) in fetched_digests.items())
|
|
212
|
+
digests_not_in_storage: list[Digest] = []
|
|
213
|
+
for expected_digest in digests:
|
|
214
|
+
if expected_digest.hash not in fetched_digest_hashes:
|
|
215
|
+
digests_not_in_storage.append(expected_digest)
|
|
216
|
+
|
|
217
|
+
if digests_not_in_storage:
|
|
218
|
+
self._safe_redis_rw(lambda r: r.delete(*[self._construct_key(digest) for digest in digests_not_in_storage]))
|
|
219
|
+
|
|
220
|
+
return fetched_digests
|
buildgrid/server/enums.py
CHANGED
|
@@ -64,8 +64,6 @@ class METRIC:
|
|
|
64
64
|
SIZE_CALCULATION_DURATION = "storage.sql_index.size_calculation.duration.ms"
|
|
65
65
|
DELETE_N_BYTES_DURATION = "storage.sql_index.delete_n_bytes.duration.ms"
|
|
66
66
|
BULK_DELETE_INDEX_DURATION = "storage.sql_index.bulk_delete_index.duration.ms"
|
|
67
|
-
MARK_DELETED_DURATION = "storage.sql_index.mark_deleted.duration.ms"
|
|
68
|
-
PREMARKED_DELETED_COUNT = "storage.sql_index.premarked_deleted.count"
|
|
69
67
|
|
|
70
68
|
class REPLICATED:
|
|
71
69
|
REQUIRED_REPLICATION_COUNT = "storage.replicated.required_replication.count"
|