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/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