wool 0.1rc7__py3-none-any.whl → 0.1rc9__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.

Potentially problematic release.


This version of wool might be problematic. Click here for more details.

wool/_mempool/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
1
  from wool._mempool._mempool import MemoryPool
2
+ from wool._mempool._service import MemoryPoolService
2
3
 
3
- __all__ = ["MemoryPool"]
4
+ __all__ = ["MemoryPool", "MemoryPoolService"]
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Final
5
+ from typing import Protocol
6
+
7
+ try:
8
+ from typing import Self
9
+ except ImportError:
10
+ from typing_extensions import Self
11
+
12
+ import grpc
13
+
14
+ try:
15
+ from wool._protobuf.mempool import service_pb2 as pb
16
+ from wool._protobuf.mempool import service_pb2_grpc as rpc
17
+ except ImportError as e:
18
+ from wool._protobuf import ProtobufImportError
19
+
20
+ raise ProtobufImportError(e) from e
21
+
22
+
23
+ class EventHandler(Protocol):
24
+ @staticmethod
25
+ def __call__(client: MemoryPoolClient, response: pb.Event) -> None: ...
26
+
27
+
28
+ class MemoryPoolClient:
29
+ _event_stream: Final[grpc.aio.UnaryStreamCall]
30
+ _listener: asyncio.Task
31
+ _session: pb.Session | None
32
+
33
+ def __init__(self, channel: grpc.aio.Channel, event_handler: EventHandler):
34
+ self._stub = rpc.MemoryPoolStub(channel)
35
+ self._event_stream = self._stub.session(pb.SessionRequest())
36
+ self._event_handler = event_handler
37
+ self._session = None
38
+
39
+ async def __aenter__(self) -> Self:
40
+ if self._session:
41
+ raise RuntimeError(
42
+ "Client session may not be re-entered. "
43
+ "Use 'async with MemoryPoolClient(...)' only once."
44
+ )
45
+ response = await self._event_stream.read()
46
+ assert isinstance(response, pb.SessionResponse)
47
+ self._session = response.session
48
+ self._listener = asyncio.create_task(self._listen())
49
+ return self
50
+
51
+ async def __aexit__(self, *_):
52
+ if not self._session:
53
+ raise RuntimeError(
54
+ "Client session has not been entered. "
55
+ "Use 'async with MemoryPoolClient(...)'."
56
+ )
57
+ assert self._listener
58
+ self._listener.cancel()
59
+ try:
60
+ await self._listener
61
+ except asyncio.CancelledError:
62
+ pass
63
+
64
+ def __del__(self):
65
+ try:
66
+ self._event_stream.cancel()
67
+ except Exception:
68
+ pass
69
+
70
+ @property
71
+ def id(self) -> str:
72
+ if not self._session:
73
+ raise RuntimeError(
74
+ "Client session has not been entered. "
75
+ "Use 'async with MemoryPoolClient(...)'."
76
+ )
77
+ return self._session.id
78
+
79
+ async def map(self, ref: str):
80
+ if not self._session:
81
+ raise RuntimeError(
82
+ "Client session has not been entered. "
83
+ "Use 'async with MemoryPoolClient(...)'."
84
+ )
85
+ request = pb.AcquireRequest(
86
+ session=self._session,
87
+ reference=pb.Reference(id=ref),
88
+ )
89
+ await self._stub.map(request)
90
+
91
+ async def get(self, ref: str) -> bytes:
92
+ if not self._session:
93
+ raise RuntimeError(
94
+ "Client session has not been entered. "
95
+ "Use 'async with MemoryPoolClient(...)'."
96
+ )
97
+ request = pb.GetRequest(
98
+ session=self._session,
99
+ reference=pb.Reference(id=ref),
100
+ )
101
+ response: pb.GetResponse = await self._stub.get(request)
102
+ return response.dump
103
+
104
+ async def put(self, dump: bytes, *, mutable: bool = False) -> str:
105
+ if not self._session:
106
+ raise RuntimeError(
107
+ "Client session has not been entered. "
108
+ "Use 'async with MemoryPoolClient(...)'."
109
+ )
110
+ request = pb.PutRequest(
111
+ session=self._session,
112
+ dump=dump,
113
+ mutable=mutable,
114
+ )
115
+ response: pb.PutResponse = await self._stub.put(request)
116
+ return response.reference.id
117
+
118
+ async def post(self, ref: str, dump: bytes) -> bool:
119
+ if not self._session:
120
+ raise RuntimeError(
121
+ "Client session has not been entered. "
122
+ "Use 'async with MemoryPoolClient(...)'."
123
+ )
124
+ request = pb.PostRequest(
125
+ session=self._session,
126
+ reference=pb.Reference(id=ref),
127
+ dump=dump,
128
+ )
129
+ response: pb.PostResponse = await self._stub.post(request)
130
+ return response.updated
131
+
132
+ async def acquire(self, ref: str):
133
+ if not self._session:
134
+ raise RuntimeError(
135
+ "Client session has not been entered. "
136
+ "Use 'async with MemoryPoolClient(...)'."
137
+ )
138
+ request = pb.AcquireRequest(
139
+ session=self._session,
140
+ reference=pb.Reference(id=ref),
141
+ )
142
+ await self._stub.acquire(request)
143
+
144
+ async def release(self, ref: str):
145
+ if not self._session:
146
+ raise RuntimeError(
147
+ "Client session has not been entered. "
148
+ "Use 'async with MemoryPoolClient(...)'."
149
+ )
150
+ request = pb.ReleaseRequest(
151
+ session=self._session,
152
+ reference=pb.Reference(id=ref),
153
+ )
154
+ await self._stub.release(request)
155
+
156
+ async def _listen(self):
157
+ assert self._event_stream
158
+ try:
159
+ while True:
160
+ if (response := await self._event_stream.read()) is None:
161
+ break
162
+ assert isinstance(response, pb.SessionResponse), (
163
+ f"Unexpected event type: {type(response)}"
164
+ )
165
+ self._event_handler(self, response.event)
166
+ except asyncio.CancelledError:
167
+ self._event_stream.cancel()
wool/_mempool/_mempool.py CHANGED
@@ -1,34 +1,141 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import hashlib
3
- import mmap
4
5
  import os
5
6
  import pathlib
6
7
  import shutil
7
- from collections import namedtuple
8
8
  from contextlib import asynccontextmanager
9
- from dataclasses import asdict
10
- from dataclasses import fields
11
- from types import MappingProxyType
9
+ from mmap import mmap
10
+ from typing import BinaryIO
11
+
12
+ try:
13
+ from typing import Self
14
+ except ImportError:
15
+ from typing_extensions import Self
12
16
 
13
17
  import shortuuid
14
18
 
15
19
  from wool._mempool._metadata import MetadataMessage
16
20
 
17
- Metadata = namedtuple(
18
- "Metadata",
19
- [field.name for field in fields(MetadataMessage)],
20
- )
21
21
 
22
+ class SharedObject:
23
+ _id: str
24
+ _mempool: MemoryPool
25
+ _file: BinaryIO
26
+ _mmap: mmap
27
+ _size: int
28
+ _md5: bytes
29
+
30
+ def __init__(self, id: str, *, mempool: MemoryPool):
31
+ self._id = id
32
+ self._mempool = mempool
33
+ self._file = open(self._path / "dump", "r+b")
34
+ self._mmap = mmap(self._file.fileno(), 0)
35
+ self._size = self.metadata.size
36
+ self._md5 = self.metadata.md5
37
+
38
+ def __del__(self):
39
+ try:
40
+ self.close()
41
+ except Exception:
42
+ pass
43
+
44
+ @property
45
+ def id(self) -> str:
46
+ return self._id
47
+
48
+ @property
49
+ def metadata(self) -> SharedObjectMetadata:
50
+ return SharedObjectMetadata(self.id, mempool=self._mempool)
51
+
52
+ @property
53
+ def mmap(self) -> mmap:
54
+ return self._mmap
55
+
56
+ @property
57
+ def _path(self) -> pathlib.Path:
58
+ return pathlib.Path(self._mempool.path, self.id)
59
+
60
+ def close(self):
61
+ self.metadata.close()
62
+ self._mmap.close()
63
+ self._file.close()
64
+
65
+ def refresh(self) -> Self:
66
+ if self._size != self.metadata.size or self._md5 != self.metadata.md5:
67
+ self._mmap.close()
68
+ self._file.close()
69
+ self._file = open(self._path / "dump", "r+b")
70
+ self._mmap = mmap(self._file.fileno(), 0)
71
+ self._size = self.metadata.size
72
+ self._md5 = self.metadata.md5
73
+ return self
74
+
75
+
76
+ class SharedObjectMetadata:
77
+ _id: str
78
+ _mempool: MemoryPool
79
+ _file: BinaryIO
80
+ _mmap: mmap
81
+ _instances: dict[str, SharedObjectMetadata] = {}
82
+
83
+ def __new__(cls, id: str, *, mempool: MemoryPool):
84
+ if id in cls._instances:
85
+ return cls._instances[id]
86
+ return super().__new__(cls)
87
+
88
+ def __init__(self, id: str, mempool: MemoryPool):
89
+ self._id = id
90
+ self._mempool = mempool
91
+ self._file = open(self._path / "meta", "r+b")
92
+ self._mmap = mmap(self._file.fileno(), 0)
93
+ self._instances[id] = self
94
+
95
+ def __del__(self):
96
+ try:
97
+ self.close()
98
+ except Exception:
99
+ pass
100
+
101
+ @property
102
+ def id(self) -> str:
103
+ return self._id
104
+
105
+ @property
106
+ def mutable(self) -> bool:
107
+ return self._metadata.mutable
108
+
109
+ @property
110
+ def size(self) -> int:
111
+ return self._metadata.size
112
+
113
+ @property
114
+ def md5(self) -> bytes:
115
+ return self._metadata.md5
116
+
117
+ @property
118
+ def mmap(self) -> mmap:
119
+ return self._mmap
120
+
121
+ @property
122
+ def _path(self) -> pathlib.Path:
123
+ return pathlib.Path(self._mempool.path, self.id)
22
124
 
23
- class MetadataMapping:
24
- def __init__(self, mapping) -> None:
25
- self._mapping = MappingProxyType(mapping)
125
+ @property
126
+ def _metadata(self) -> MetadataMessage:
127
+ return MetadataMessage.loads(bytes(self._mmap))
26
128
 
27
- def __getitem__(self, key: str) -> Metadata:
28
- return Metadata(**asdict(self._mapping[key]))
129
+ def close(self):
130
+ self._mmap.close()
131
+ self._file.close()
132
+ del self._instances[self.id]
29
133
 
30
134
 
31
135
  class MemoryPool:
136
+ _objects: dict[str, SharedObject]
137
+ _path: pathlib.Path
138
+
32
139
  def __init__(self, path: str | pathlib.Path = pathlib.Path(".mempool")):
33
140
  if isinstance(path, str):
34
141
  self._path = pathlib.Path(path)
@@ -36,89 +143,86 @@ class MemoryPool:
36
143
  self._path = path
37
144
  self._lockdir = self._path / "locks"
38
145
  os.makedirs(self._lockdir, exist_ok=True)
39
- self._files = {}
40
- self._mmaps = {}
41
- self._metadata: dict[str, MetadataMessage] = {}
146
+ self._acquire(f"pid-{os.getpid()}")
147
+ self._objects = dict()
42
148
 
43
- @property
44
- def metadata(self) -> MetadataMapping:
45
- return MetadataMapping(self._metadata)
149
+ def __contains__(self, ref: str) -> bool:
150
+ return ref in self._objects
151
+
152
+ def __del__(self):
153
+ self._release(f"pid-{os.getpid()}")
46
154
 
47
155
  @property
48
156
  def path(self) -> pathlib.Path:
49
157
  return self._path
50
158
 
51
- async def map(self):
52
- for entry in os.scandir(self._path):
53
- if entry.is_dir() and (ref := entry.name) != "locks":
54
- async with self._reflock(ref):
55
- self._map(ref)
159
+ async def map(self, ref: str | None = None):
160
+ if ref is not None and ref not in self._objects:
161
+ if self._locked(f"delete-{ref}"):
162
+ raise RuntimeError(
163
+ f"Reference {ref} is currently locked for deletion"
164
+ )
165
+ async with self._reference_lock(ref):
166
+ self._map(ref)
167
+ else:
168
+ for entry in os.scandir(self._path):
169
+ if entry.is_dir() and (ref := entry.name) != "locks":
170
+ if not self._locked(f"delete-{ref}"):
171
+ async with self._reference_lock(ref):
172
+ self._map(ref)
56
173
 
57
174
  async def put(
58
175
  self, dump: bytes, *, mutable: bool = False, ref: str | None = None
59
176
  ) -> str:
60
- async with self._reflock(ref := ref or str(shortuuid.uuid())):
177
+ ref = ref or str(shortuuid.uuid())
178
+ async with self._reference_lock(ref):
61
179
  self._put(ref, dump, mutable=mutable, exist_ok=False)
62
180
  return ref
63
181
 
64
- async def post(self, ref: str, dump: bytes):
65
- async with self._reflock(ref):
66
- if ref not in self._mmaps:
67
- self._map(ref)
68
- metamap, dumpmap = self._mmaps[ref]
69
- metamap.seek(0)
70
- metadata = self._metadata.setdefault(
71
- ref, MetadataMessage.loads(metamap.read())
182
+ async def post(self, ref: str, dump: bytes) -> bool:
183
+ if self._locked(f"delete-{ref}"):
184
+ raise RuntimeError(
185
+ f"Reference {ref} is currently locked for deletion"
72
186
  )
73
- if not metadata.mutable:
187
+ async with self._reference_lock(ref):
188
+ if ref not in self._objects:
189
+ self._map(ref)
190
+ obj = self._objects[ref]
191
+ if not obj.metadata.mutable:
74
192
  raise ValueError("Cannot modify an immutable reference")
75
- if (size := len(dump)) != metadata.size:
193
+ if (size := len(dump)) != obj.metadata.size:
76
194
  try:
77
- dumpmap.resize(size)
78
- self._post(ref, dump, metadata)
195
+ obj.mmap.resize(size)
196
+ self._post(ref, obj, dump)
79
197
  except SystemError:
80
198
  self._put(ref, dump, mutable=True, exist_ok=True)
81
199
  return True
82
- elif hashlib.md5(dump).digest() != metadata.md5:
83
- self._post(ref, dump, metadata)
200
+ elif hashlib.md5(dump).digest() != obj.metadata.md5:
201
+ self._post(ref, obj, dump)
84
202
  return True
85
203
  else:
86
204
  return False
87
205
 
88
206
  async def get(self, ref: str) -> bytes:
89
- async with self._reflock(ref):
90
- if ref not in self._mmaps:
91
- self._map(ref)
92
- metamap, dumpmap = self._mmaps[ref]
93
- metamap.seek(0)
94
- metadata = MetadataMessage.loads(metamap.read())
95
- cached_metadata = self._metadata.setdefault(ref, metadata)
96
- if metadata.mutable and metadata.size != cached_metadata.size:
97
- # Dump size has changed, so we need to re-map it
207
+ if self._locked(f"delete-{ref}"):
208
+ raise RuntimeError(
209
+ f"Reference {ref} is currently locked for deletion"
210
+ )
211
+ async with self._reference_lock(ref):
212
+ if ref not in self._objects:
98
213
  self._map(ref)
99
- dumpmap.seek(0)
100
- return dumpmap.read()
214
+ return bytes(self._objects[ref].refresh().mmap)
101
215
 
102
216
  async def delete(self, ref: str):
103
- async with self._reflock(ref):
104
- if ref not in self._mmaps:
105
- self._map(ref)
106
- metamap, dumpmap = self._mmaps.pop(ref)
107
- metafile, dumpfile = self._files.pop(ref)
108
- if ref in self._metadata:
109
- del self._metadata[ref]
110
- if metamap:
111
- metamap.close()
112
- if dumpmap:
113
- dumpmap.close()
114
- if metafile:
115
- metafile.close()
116
- if dumpfile:
117
- dumpfile.close()
118
- try:
119
- shutil.rmtree(self.path / ref)
120
- except FileNotFoundError:
121
- pass
217
+ async with self._delete_lock(ref):
218
+ async with self._reference_lock(ref):
219
+ if ref not in self._objects:
220
+ self._map(ref)
221
+ self._objects.pop(ref).close()
222
+ try:
223
+ shutil.rmtree(self.path / ref)
224
+ except FileNotFoundError:
225
+ pass
122
226
 
123
227
  def _put(
124
228
  self,
@@ -145,60 +249,63 @@ class MemoryPool:
145
249
  dumpfile.write(dump)
146
250
 
147
251
  self._map(ref)
148
- self._metadata[ref] = metadata
149
-
150
- def _post(self, ref: str, dump: bytes, metadata: MetadataMessage):
151
- metamap, dumpmap = self._mmaps[ref]
152
- metadata.size = len(dump)
153
- metadata.md5 = hashlib.md5(dump).digest()
154
- metamap.seek(0)
155
- metamap.write(metadata.dumps())
156
- metamap.flush()
157
- dumpmap.seek(0)
158
- dumpmap.write(dump)
159
- dumpmap.flush()
252
+
253
+ def _post(self, ref: str, obj: SharedObject, dump: bytes):
254
+ if not obj.metadata.mutable:
255
+ raise ValueError("Cannot modify an immutable reference")
256
+ metadata = MetadataMessage(
257
+ ref=ref,
258
+ mutable=True,
259
+ size=len(dump),
260
+ md5=hashlib.md5(dump).digest(),
261
+ )
262
+ obj.metadata.mmap[:] = metadata.dumps()
263
+ obj.metadata.mmap.flush()
264
+ obj.mmap.seek(0)
265
+ obj.mmap.write(dump)
160
266
 
161
267
  def _map(self, ref: str):
162
- refpath = pathlib.Path(self._path, f"{ref}")
163
- metafile = open(refpath / "meta", "r+b")
164
- metamap = mmap.mmap(metafile.fileno(), 0, flags=mmap.MAP_SHARED)
165
- dumpfile = open(refpath / "dump", "r+b")
166
- dumpmap = mmap.mmap(dumpfile.fileno(), 0, flags=mmap.MAP_SHARED)
167
- cached_metafile, cached_dumpfile = self._files.pop(ref, (None, None))
168
- if cached_metafile:
169
- cached_metafile.close()
170
- if cached_dumpfile:
171
- cached_dumpfile.close()
172
- cached_metamap, cached_dumpmap = self._mmaps.pop(ref, (None, None))
173
- if cached_metamap is not None and not cached_metamap.closed:
174
- cached_metamap.close()
175
- if cached_dumpmap is not None and not cached_dumpmap.closed:
176
- cached_dumpmap.close()
177
- self._files[ref] = (metafile, dumpfile)
178
- self._mmaps[ref] = (metamap, dumpmap)
179
-
180
- def _lockpath(self, ref: str):
181
- return pathlib.Path(self._lockdir, f"{ref}.lock")
182
-
183
- def _acquire(self, ref: str):
268
+ obj = self._objects.pop(ref, None)
269
+ if obj:
270
+ obj.close()
271
+ self._objects[ref] = SharedObject(id=ref, mempool=self)
272
+
273
+ def _lockpath(self, key: str) -> pathlib.Path:
274
+ return pathlib.Path(self._lockdir, f"{key}.lock")
275
+
276
+ def _acquire(self, key: str) -> bool:
184
277
  try:
185
- os.symlink(f"{ref}", self._lockpath(ref))
278
+ os.symlink(f"{key}", self._lockpath(key))
186
279
  return True
187
280
  except FileExistsError:
188
281
  return False
189
282
 
190
- def _release(self, ref: str):
283
+ def _release(self, key: str):
191
284
  try:
192
- if os.path.islink(lock_path := self._lockpath(ref)):
285
+ if os.path.islink(lock_path := self._lockpath(key)):
193
286
  os.unlink(lock_path)
194
287
  except FileNotFoundError:
195
288
  pass
196
289
 
290
+ def _locked(self, key: str) -> bool:
291
+ return os.path.islink(self._lockpath(key))
292
+
197
293
  @asynccontextmanager
198
- async def _reflock(self, ref: str):
294
+ async def _reference_lock(self, ref: str):
199
295
  try:
200
296
  while not self._acquire(ref):
201
297
  await asyncio.sleep(0)
202
298
  yield
203
299
  finally:
204
300
  self._release(ref)
301
+
302
+ @asynccontextmanager
303
+ async def _delete_lock(self, ref: str):
304
+ key = f"delete-{ref}"
305
+ if not self._acquire(f"delete-{ref}"):
306
+ raise RuntimeError(
307
+ f"Reference {ref} is currently locked for deletion"
308
+ )
309
+ else:
310
+ yield
311
+ self._release(key)
@@ -1,18 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
3
+ from dataclasses import asdict
4
4
  from dataclasses import dataclass
5
5
 
6
6
  try:
7
- from wool._protobuf._mempool._metadata._metadata_pb2 import (
8
- _MetadataMessage,
9
- )
10
- except ImportError:
11
- logging.error(
12
- "Failed to import _MetadataMessage. "
13
- "Ensure protocol buffers are compiled."
14
- )
15
- raise
7
+ from wool._protobuf.mempool import metadata_pb2 as pb
8
+ except ImportError as e:
9
+ from wool._protobuf import ProtobufImportError
10
+
11
+ raise ProtobufImportError(e) from e
16
12
 
17
13
 
18
14
  @dataclass
@@ -24,7 +20,7 @@ class MetadataMessage:
24
20
 
25
21
  @classmethod
26
22
  def loads(cls, data: bytes) -> MetadataMessage:
27
- (metadata := _MetadataMessage()).ParseFromString(data)
23
+ (metadata := pb.MetadataMessage()).ParseFromString(data)
28
24
  return cls(
29
25
  ref=metadata.ref,
30
26
  mutable=metadata.mutable,
@@ -33,9 +29,7 @@ class MetadataMessage:
33
29
  )
34
30
 
35
31
  def dumps(self) -> bytes:
36
- return _MetadataMessage(
37
- ref=self.ref, mutable=self.mutable, size=self.size, md5=self.md5
38
- ).SerializeToString()
32
+ return pb.MetadataMessage(**asdict(self)).SerializeToString()
39
33
 
40
34
 
41
35
  __all__ = ["MetadataMessage"]