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.
@@ -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
@@ -172,3 +172,8 @@ class JobAssignmentStrategy(Enum):
172
172
  CAPACITY = "capacity"
173
173
  PROACTIVE = "proactive"
174
174
  PREEMPTION = "preemption"
175
+
176
+
177
+ class BlobExistence(Enum):
178
+ EXISTS = b"1"
179
+ DOES_NOT_EXIST = b"0"
@@ -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"