aether-cache 0.1.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.
- aether_cache/__init__.py +3 -0
- aether_cache/__main__.py +5 -0
- aether_cache/cli.py +240 -0
- aether_cache/config.py +44 -0
- aether_cache/connect.py +195 -0
- aether_cache/engine/aether-engine.jar +0 -0
- aether_cache/engine.py +253 -0
- aether_cache/server.py +464 -0
- aether_cache-0.1.0.dist-info/METADATA +353 -0
- aether_cache-0.1.0.dist-info/RECORD +13 -0
- aether_cache-0.1.0.dist-info/WHEEL +5 -0
- aether_cache-0.1.0.dist-info/entry_points.txt +2 -0
- aether_cache-0.1.0.dist-info/top_level.txt +1 -0
aether_cache/engine.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Finding Java, and starting, stopping and checking the Java engine in the background."""
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import redis
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
JAR = Path(__file__).resolve().parent / "engine" / "aether-engine.jar"
|
|
20
|
+
MIN_JAVA = 11
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EngineError(Exception):
|
|
24
|
+
"""Something the user has to fix before the engine can run; the message says what."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------- Java ----------
|
|
28
|
+
|
|
29
|
+
def java_install_hint() -> str:
|
|
30
|
+
system = platform.system()
|
|
31
|
+
if system == "Darwin":
|
|
32
|
+
how = "brew install openjdk@21 (or download it from https://adoptium.net)"
|
|
33
|
+
elif system == "Windows":
|
|
34
|
+
how = "winget install EclipseAdoptium.Temurin.21.JRE (or download it from https://adoptium.net)"
|
|
35
|
+
else:
|
|
36
|
+
how = "sudo apt install openjdk-21-jre-headless (or your distro's equivalent, or https://adoptium.net)"
|
|
37
|
+
return f"Aether Cache needs Java {MIN_JAVA} or newer. Install it with:\n {how}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_java() -> Tuple[str, int]:
|
|
41
|
+
"""(path to a working java, its major version). Raises EngineError if there's no usable Java.
|
|
42
|
+
Runs `java -version` rather than trusting the PATH: macOS ships a /usr/bin/java stub that only
|
|
43
|
+
says "Unable to locate a Java Runtime"."""
|
|
44
|
+
candidates = []
|
|
45
|
+
if os.environ.get("JAVA_HOME"):
|
|
46
|
+
candidates.append(str(Path(os.environ["JAVA_HOME"]) / "bin" / ("java.exe" if os.name == "nt" else "java")))
|
|
47
|
+
if shutil.which("java"):
|
|
48
|
+
candidates.append(shutil.which("java"))
|
|
49
|
+
too_old = None
|
|
50
|
+
for java in candidates:
|
|
51
|
+
try:
|
|
52
|
+
out = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=15)
|
|
53
|
+
except (OSError, subprocess.SubprocessError):
|
|
54
|
+
continue
|
|
55
|
+
match = re.search(r'version "(\d+)(?:\.(\d+))?', out.stderr + out.stdout)
|
|
56
|
+
if out.returncode != 0 or not match:
|
|
57
|
+
continue
|
|
58
|
+
major = int(match.group(1))
|
|
59
|
+
if major == 1 and match.group(2): # old-style "1.8.0" means Java 8
|
|
60
|
+
major = int(match.group(2))
|
|
61
|
+
if major >= MIN_JAVA:
|
|
62
|
+
return java, major
|
|
63
|
+
too_old = major
|
|
64
|
+
if too_old:
|
|
65
|
+
raise EngineError(f"Found Java {too_old}, which is too old.\n" + java_install_hint())
|
|
66
|
+
raise EngineError("Java isn't installed.\n" + java_install_hint())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------- talking to the engine ----------
|
|
70
|
+
|
|
71
|
+
def client(timeout: Optional[float] = None) -> redis.Redis:
|
|
72
|
+
# protocol=2 and no lib_name/lib_version: the engine speaks RESP2 and doesn't implement
|
|
73
|
+
# HELLO or CLIENT SETINFO, which newer redis-py clients send by default
|
|
74
|
+
with warnings.catch_warnings(): # newer redis-py warns that lib_name is deprecated; harmless here
|
|
75
|
+
warnings.simplefilter("ignore")
|
|
76
|
+
return redis.Redis(host=config.HOST, port=config.PORT, protocol=2, decode_responses=True,
|
|
77
|
+
lib_name=None, lib_version=None,
|
|
78
|
+
socket_connect_timeout=timeout, socket_timeout=timeout)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def probe() -> Optional[str]:
|
|
82
|
+
""""aether" if our engine answers on the port, "other" if some other program does, None if nothing does."""
|
|
83
|
+
c = client(timeout=2)
|
|
84
|
+
try:
|
|
85
|
+
c.ping()
|
|
86
|
+
except (redis.ConnectionError, redis.TimeoutError, OSError):
|
|
87
|
+
return None
|
|
88
|
+
except redis.ResponseError:
|
|
89
|
+
return "other"
|
|
90
|
+
try:
|
|
91
|
+
c.execute_command("MEM.LIST", "__aether_probe__") # only Aether Cache knows this command
|
|
92
|
+
return "aether"
|
|
93
|
+
except redis.ResponseError:
|
|
94
|
+
return "other"
|
|
95
|
+
except (redis.ConnectionError, redis.TimeoutError, OSError):
|
|
96
|
+
return None
|
|
97
|
+
finally:
|
|
98
|
+
c.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def port_taken_message() -> str:
|
|
102
|
+
return (f"Port {config.PORT} is already used by another program (maybe a real Redis). "
|
|
103
|
+
f"Pick a different port for Aether Cache: set AETHER_PORT (e.g. export AETHER_PORT={config.PORT + 10}), "
|
|
104
|
+
f"then run `aether-cache setup` again so your AI tools use it too.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------- starting and stopping ----------
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def _start_lock():
|
|
111
|
+
"""Several AI sessions can start at once and all try to launch the engine; only one may."""
|
|
112
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
with open(config.HOME / "start.lock", "a+") as f:
|
|
114
|
+
f.seek(0)
|
|
115
|
+
if os.name == "nt":
|
|
116
|
+
import msvcrt
|
|
117
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
|
118
|
+
else:
|
|
119
|
+
import fcntl
|
|
120
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
121
|
+
try:
|
|
122
|
+
yield
|
|
123
|
+
finally:
|
|
124
|
+
f.seek(0)
|
|
125
|
+
if os.name == "nt":
|
|
126
|
+
import msvcrt
|
|
127
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
128
|
+
else:
|
|
129
|
+
import fcntl
|
|
130
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _migrate_old_data() -> Optional[str]:
|
|
134
|
+
"""Earlier versions (called memstore) kept data in ~/.memstore. Copy it over once, leaving the original."""
|
|
135
|
+
old = Path.home() / ".memstore"
|
|
136
|
+
if old.is_dir() and not config.DATA_DIR.exists() and not os.environ.get("AETHER_HOME"):
|
|
137
|
+
shutil.copytree(old, config.DATA_DIR)
|
|
138
|
+
return f"Copied your existing memories from {old} to {config.DATA_DIR}."
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_pid() -> Optional[int]:
|
|
143
|
+
try:
|
|
144
|
+
return int(config.PID_FILE.read_text().strip())
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _log_tail(lines: int = 15) -> str:
|
|
150
|
+
try:
|
|
151
|
+
return "\n".join(config.LOG_FILE.read_text(errors="replace").splitlines()[-lines:])
|
|
152
|
+
except OSError:
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def start(wait: float = 60) -> str:
|
|
157
|
+
"""Starts the engine in the background (if it isn't already running) and waits until it answers."""
|
|
158
|
+
state = probe()
|
|
159
|
+
if state == "aether":
|
|
160
|
+
return f"Engine already running on {config.HOST}:{config.PORT}."
|
|
161
|
+
if state == "other":
|
|
162
|
+
raise EngineError(port_taken_message())
|
|
163
|
+
if config.HOST not in ("127.0.0.1", "localhost", "::1"):
|
|
164
|
+
raise EngineError(f"The engine at {config.HOST}:{config.PORT} isn't reachable, and it's on another "
|
|
165
|
+
f"machine, so it can't be started from here.")
|
|
166
|
+
if not JAR.is_file():
|
|
167
|
+
raise EngineError(f"The engine file is missing ({JAR}). Reinstall Aether Cache.")
|
|
168
|
+
|
|
169
|
+
with _start_lock():
|
|
170
|
+
state = probe() # another session may have started it while we waited for the lock
|
|
171
|
+
if state == "aether":
|
|
172
|
+
return f"Engine already running on {config.HOST}:{config.PORT}."
|
|
173
|
+
if state == "other":
|
|
174
|
+
raise EngineError(port_taken_message())
|
|
175
|
+
java, _ = find_java()
|
|
176
|
+
note = _migrate_old_data()
|
|
177
|
+
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
command = [java, "-Xmx1g", "-jar", str(JAR),
|
|
179
|
+
"--port", str(config.PORT), "--bind", "127.0.0.1",
|
|
180
|
+
"--dir", str(config.DATA_DIR), "--appendonly", "yes",
|
|
181
|
+
# every memory write reaches the disk before it's acknowledged; memory writes are rare,
|
|
182
|
+
# so the cost doesn't matter, and nothing is ever lost
|
|
183
|
+
"--appendfsync", "always"]
|
|
184
|
+
detach = {}
|
|
185
|
+
if os.name == "nt":
|
|
186
|
+
detach["creationflags"] = (subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
187
|
+
| subprocess.CREATE_NO_WINDOW)
|
|
188
|
+
else:
|
|
189
|
+
detach["start_new_session"] = True # keeps running after the terminal or AI session closes
|
|
190
|
+
with open(config.LOG_FILE, "ab") as log:
|
|
191
|
+
log.write(f"\n--- starting engine {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode())
|
|
192
|
+
log.flush()
|
|
193
|
+
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
|
|
194
|
+
cwd=str(config.HOME), **detach)
|
|
195
|
+
config.PID_FILE.write_text(str(process.pid))
|
|
196
|
+
|
|
197
|
+
deadline = time.time() + wait
|
|
198
|
+
while time.time() < deadline:
|
|
199
|
+
if process.poll() is not None:
|
|
200
|
+
raise EngineError(f"The engine stopped right after starting. Last lines of {config.LOG_FILE}:\n"
|
|
201
|
+
+ _log_tail())
|
|
202
|
+
state = probe()
|
|
203
|
+
if state == "aether":
|
|
204
|
+
started = f"Engine started on {config.HOST}:{config.PORT} (data in {config.DATA_DIR})."
|
|
205
|
+
return f"{note}\n{started}" if note else started
|
|
206
|
+
if state == "other":
|
|
207
|
+
process.terminate()
|
|
208
|
+
raise EngineError(port_taken_message())
|
|
209
|
+
time.sleep(0.2)
|
|
210
|
+
raise EngineError(f"The engine didn't answer within {int(wait)} seconds. See {config.LOG_FILE}.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def ensure_running() -> None:
|
|
214
|
+
if probe() != "aether":
|
|
215
|
+
start()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _alive(pid: int) -> bool:
|
|
219
|
+
if os.name == "nt":
|
|
220
|
+
out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True)
|
|
221
|
+
return str(pid) in out.stdout
|
|
222
|
+
try:
|
|
223
|
+
os.kill(pid, 0)
|
|
224
|
+
return True
|
|
225
|
+
except ProcessLookupError:
|
|
226
|
+
return False
|
|
227
|
+
except PermissionError:
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def stop() -> str:
|
|
232
|
+
pid = _read_pid()
|
|
233
|
+
if probe() != "aether":
|
|
234
|
+
config.PID_FILE.unlink(missing_ok=True)
|
|
235
|
+
return "Engine isn't running."
|
|
236
|
+
if pid is None or not _alive(pid):
|
|
237
|
+
return (f"An engine is answering on port {config.PORT}, but it wasn't started by `aether-cache start`, "
|
|
238
|
+
f"so it wasn't stopped. Stop it the way you started it.")
|
|
239
|
+
os.kill(pid, signal.SIGTERM) # safe: with appendfsync always, every acknowledged write is on disk already
|
|
240
|
+
for _ in range(50):
|
|
241
|
+
if not _alive(pid):
|
|
242
|
+
break
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
else:
|
|
245
|
+
if os.name != "nt":
|
|
246
|
+
os.kill(pid, signal.SIGKILL)
|
|
247
|
+
config.PID_FILE.unlink(missing_ok=True)
|
|
248
|
+
return "Engine stopped."
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def running_pid() -> Optional[int]:
|
|
252
|
+
pid = _read_pid()
|
|
253
|
+
return pid if pid is not None and _alive(pid) else None
|