lfss 0.9.2__py3-none-any.whl → 0.11.4__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.
- Readme.md +4 -4
- docs/Enviroment_variables.md +4 -2
- docs/Permission.md +4 -4
- docs/Webdav.md +3 -3
- docs/changelog.md +58 -0
- frontend/api.js +66 -4
- frontend/login.js +0 -1
- frontend/popup.js +18 -3
- frontend/scripts.js +46 -39
- frontend/utils.js +98 -1
- lfss/api/__init__.py +7 -4
- lfss/api/connector.py +47 -11
- lfss/cli/cli.py +9 -9
- lfss/cli/log.py +77 -0
- lfss/cli/vacuum.py +69 -19
- lfss/eng/config.py +7 -5
- lfss/eng/connection_pool.py +12 -8
- lfss/eng/database.py +350 -140
- lfss/eng/error.py +6 -2
- lfss/eng/log.py +91 -21
- lfss/eng/thumb.py +20 -23
- lfss/eng/utils.py +50 -29
- lfss/sql/init.sql +9 -4
- lfss/svc/app.py +1 -1
- lfss/svc/app_base.py +8 -3
- lfss/svc/app_dav.py +74 -61
- lfss/svc/app_native.py +95 -59
- lfss/svc/common_impl.py +72 -37
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/METADATA +10 -8
- lfss-0.11.4.dist-info/RECORD +52 -0
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/entry_points.txt +1 -0
- lfss-0.9.2.dist-info/RECORD +0 -50
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/WHEEL +0 -0
lfss/api/connector.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Optional, Literal
|
3
|
-
import
|
2
|
+
from typing import Optional, Literal
|
3
|
+
from collections.abc import Iterator
|
4
|
+
import os, json
|
4
5
|
import requests
|
5
6
|
import requests.adapters
|
6
7
|
import urllib.parse
|
@@ -14,12 +15,13 @@ from lfss.eng.utils import ensure_uri_compnents
|
|
14
15
|
|
15
16
|
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
16
17
|
_default_token = os.environ.get('LFSS_TOKEN', '')
|
18
|
+
num_t = float | int
|
17
19
|
|
18
20
|
class Connector:
|
19
21
|
class Session:
|
20
22
|
def __init__(
|
21
23
|
self, connector: Connector, pool_size: int = 10,
|
22
|
-
retry: int = 1, backoff_factor:
|
24
|
+
retry: int = 1, backoff_factor: num_t = 0.5, status_forcelist: list[int] = [503]
|
23
25
|
):
|
24
26
|
self.connector = connector
|
25
27
|
self.pool_size = pool_size
|
@@ -46,13 +48,21 @@ class Connector:
|
|
46
48
|
def __exit__(self, exc_type, exc_value, traceback):
|
47
49
|
self.close()
|
48
50
|
|
49
|
-
def __init__(self, endpoint=_default_endpoint, token=_default_token):
|
51
|
+
def __init__(self, endpoint=_default_endpoint, token=_default_token, timeout: Optional[num_t | tuple[num_t, num_t]]=None, verify: Optional[bool | str] = None):
|
52
|
+
"""
|
53
|
+
- endpoint: the URL of the LFSS server. Default to $LFSS_ENDPOINT or http://localhost:8000.
|
54
|
+
- token: the access token. Default to $LFSS_TOKEN.
|
55
|
+
- timeout: the timeout for each request, can be either a single value or a tuple of two values (connect, read), refer to requests.Session.request.
|
56
|
+
- verify: either a boolean or a string, to control SSL verification. Default to True, refer to requests.Session.request.
|
57
|
+
"""
|
50
58
|
assert token, "No token provided. Please set LFSS_TOKEN environment variable."
|
51
59
|
self.config = {
|
52
60
|
"endpoint": endpoint,
|
53
61
|
"token": token
|
54
62
|
}
|
55
63
|
self._session: Optional[requests.Session] = None
|
64
|
+
self.timeout = timeout
|
65
|
+
self.verify = verify
|
56
66
|
|
57
67
|
def session( self, pool_size: int = 10, **kwargs):
|
58
68
|
""" avoid creating a new session for each request. """
|
@@ -66,18 +76,22 @@ class Connector:
|
|
66
76
|
path = path[1:]
|
67
77
|
path = ensure_uri_compnents(path)
|
68
78
|
def f(**kwargs):
|
69
|
-
|
79
|
+
search_params_t = [
|
80
|
+
(k, str(v).lower() if isinstance(v, bool) else v)
|
81
|
+
for k, v in search_params.items()
|
82
|
+
] # tuple form
|
83
|
+
url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params_t, doseq=True)
|
70
84
|
headers: dict = kwargs.pop('headers', {})
|
71
85
|
headers.update({
|
72
86
|
'Authorization': f"Bearer {self.config['token']}",
|
73
87
|
})
|
74
88
|
headers.update(extra_headers)
|
75
89
|
if self._session is not None:
|
76
|
-
response = self._session.request(method, url, headers=headers, **kwargs)
|
90
|
+
response = self._session.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
|
77
91
|
response.raise_for_status()
|
78
92
|
else:
|
79
93
|
with requests.Session() as s:
|
80
|
-
response = s.request(method, url, headers=headers, **kwargs)
|
94
|
+
response = s.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
|
81
95
|
response.raise_for_status()
|
82
96
|
return response
|
83
97
|
return f
|
@@ -88,7 +102,7 @@ class Connector:
|
|
88
102
|
|
89
103
|
# Skip ahead by checking if the file already exists
|
90
104
|
if conflict == 'skip-ahead':
|
91
|
-
exists = self.
|
105
|
+
exists = self.get_meta(path)
|
92
106
|
if exists is None:
|
93
107
|
conflict = 'skip'
|
94
108
|
else:
|
@@ -112,7 +126,7 @@ class Connector:
|
|
112
126
|
|
113
127
|
# Skip ahead by checking if the file already exists
|
114
128
|
if conflict == 'skip-ahead':
|
115
|
-
exists = self.
|
129
|
+
exists = self.get_meta(path)
|
116
130
|
if exists is None:
|
117
131
|
conflict = 'skip'
|
118
132
|
else:
|
@@ -144,7 +158,7 @@ class Connector:
|
|
144
158
|
|
145
159
|
# Skip ahead by checking if the file already exists
|
146
160
|
if conflict == 'skip-ahead':
|
147
|
-
exists = self.
|
161
|
+
exists = self.get_meta(path)
|
148
162
|
if exists is None:
|
149
163
|
conflict = 'skip'
|
150
164
|
else:
|
@@ -197,11 +211,22 @@ class Connector:
|
|
197
211
|
assert response.headers['Content-Type'] == 'application/json'
|
198
212
|
return response.json()
|
199
213
|
|
214
|
+
def get_multiple_text(self, *paths: str, skip_content = False) -> dict[str, Optional[str]]:
|
215
|
+
"""
|
216
|
+
Gets text contents of multiple files at once. Non-existing files will return None.
|
217
|
+
- skip_content: if True, the file contents will not be fetched, always be empty string ''.
|
218
|
+
"""
|
219
|
+
response = self._fetch_factory(
|
220
|
+
'GET', '_api/get-multiple',
|
221
|
+
{'path': paths, "skip_content": skip_content}
|
222
|
+
)()
|
223
|
+
return response.json()
|
224
|
+
|
200
225
|
def delete(self, path: str):
|
201
226
|
"""Deletes the file at the specified path."""
|
202
227
|
self._fetch_factory('DELETE', path)()
|
203
228
|
|
204
|
-
def
|
229
|
+
def get_meta(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
205
230
|
"""Gets the metadata for the file at the specified path."""
|
206
231
|
try:
|
207
232
|
response = self._fetch_factory('GET', '_api/meta', {'path': path})()
|
@@ -213,6 +238,9 @@ class Connector:
|
|
213
238
|
if e.response.status_code == 404:
|
214
239
|
return None
|
215
240
|
raise e
|
241
|
+
# shorthand methods for type constraints
|
242
|
+
def get_fmeta(self, path: str) -> Optional[FileRecord]: assert (f:=self.get_meta(path)) is None or isinstance(f, FileRecord); return f
|
243
|
+
def get_dmeta(self, path: str) -> Optional[DirectoryRecord]: assert (d:=self.get_meta(path)) is None or isinstance(d, DirectoryRecord); return d
|
216
244
|
|
217
245
|
def list_path(self, path: str) -> PathContents:
|
218
246
|
"""
|
@@ -276,6 +304,14 @@ class Connector:
|
|
276
304
|
self._fetch_factory('POST', '_api/copy', {'src': src, 'dst': dst})(
|
277
305
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
278
306
|
)
|
307
|
+
|
308
|
+
def bundle(self, path: str) -> Iterator[bytes]:
|
309
|
+
"""Bundle a path into a zip file."""
|
310
|
+
response = self._fetch_factory('GET', '_api/bundle', {'path': path})(
|
311
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'},
|
312
|
+
stream = True
|
313
|
+
)
|
314
|
+
return response.iter_content(chunk_size=1024)
|
279
315
|
|
280
316
|
def whoami(self) -> UserRecord:
|
281
317
|
"""Gets information about the current user."""
|
lfss/cli/cli.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
from pathlib import Path
|
2
|
-
import argparse, typing
|
2
|
+
import argparse, typing, sys
|
3
3
|
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
4
4
|
from lfss.eng.datatype import FileReadPermission, FileSortKey, DirSortKey
|
5
5
|
from lfss.eng.utils import decode_uri_compnents
|
@@ -12,7 +12,7 @@ def parse_permission(s: str) -> FileReadPermission:
|
|
12
12
|
raise ValueError(f"Invalid permission {s}")
|
13
13
|
|
14
14
|
def parse_arguments():
|
15
|
-
parser = argparse.ArgumentParser(description="
|
15
|
+
parser = argparse.ArgumentParser(description="Client-side command line interface, set LFSS_ENDPOINT and LFSS_TOKEN environment variables for authentication.")
|
16
16
|
|
17
17
|
sp = parser.add_subparsers(dest="command", required=True)
|
18
18
|
|
@@ -78,9 +78,9 @@ def main():
|
|
78
78
|
permission=args.permission
|
79
79
|
)
|
80
80
|
if failed_upload:
|
81
|
-
print("\033[91mFailed to upload:\033[0m")
|
81
|
+
print("\033[91mFailed to upload:\033[0m", file=sys.stderr)
|
82
82
|
for path in failed_upload:
|
83
|
-
print(f" {path}")
|
83
|
+
print(f" {path}", file=sys.stderr)
|
84
84
|
else:
|
85
85
|
success, msg = upload_file(
|
86
86
|
connector,
|
@@ -93,7 +93,7 @@ def main():
|
|
93
93
|
permission=args.permission
|
94
94
|
)
|
95
95
|
if not success:
|
96
|
-
print("\033[91mFailed to upload: \033[0m", msg)
|
96
|
+
print("\033[91mFailed to upload: \033[0m", msg, file=sys.stderr)
|
97
97
|
|
98
98
|
elif args.command == "download":
|
99
99
|
is_dir = args.src.endswith("/")
|
@@ -107,9 +107,9 @@ def main():
|
|
107
107
|
overwrite=args.overwrite
|
108
108
|
)
|
109
109
|
if failed_download:
|
110
|
-
print("\033[91mFailed to download:\033[0m")
|
110
|
+
print("\033[91mFailed to download:\033[0m", file=sys.stderr)
|
111
111
|
for path in failed_download:
|
112
|
-
print(f" {path}")
|
112
|
+
print(f" {path}", file=sys.stderr)
|
113
113
|
else:
|
114
114
|
success, msg = download_file(
|
115
115
|
connector,
|
@@ -121,12 +121,12 @@ def main():
|
|
121
121
|
overwrite=args.overwrite
|
122
122
|
)
|
123
123
|
if not success:
|
124
|
-
print("\033[91mFailed to download: \033[0m", msg)
|
124
|
+
print("\033[91mFailed to download: \033[0m", msg, file=sys.stderr)
|
125
125
|
|
126
126
|
elif args.command == "query":
|
127
127
|
for path in args.path:
|
128
128
|
with catch_request_error():
|
129
|
-
res = connector.
|
129
|
+
res = connector.get_meta(path)
|
130
130
|
if res is None:
|
131
131
|
print(f"\033[31mNot found\033[0m ({path})")
|
132
132
|
else:
|
lfss/cli/log.py
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
import argparse
|
3
|
+
import rich.console
|
4
|
+
import logging
|
5
|
+
import sqlite3
|
6
|
+
from lfss.eng.log import eval_logline
|
7
|
+
|
8
|
+
console = rich.console.Console()
|
9
|
+
def levelstr2int(levelstr: str) -> int:
|
10
|
+
import sys
|
11
|
+
if sys.version_info < (3, 11):
|
12
|
+
return logging.getLevelName(levelstr.upper())
|
13
|
+
else:
|
14
|
+
return logging.getLevelNamesMapping()[levelstr.upper()]
|
15
|
+
|
16
|
+
def view(
|
17
|
+
db_file: str,
|
18
|
+
level: Optional[str] = None,
|
19
|
+
offset: int = 0,
|
20
|
+
limit: int = 1000
|
21
|
+
):
|
22
|
+
conn = sqlite3.connect(db_file)
|
23
|
+
cursor = conn.cursor()
|
24
|
+
if level is None:
|
25
|
+
cursor.execute("SELECT * FROM log ORDER BY created DESC LIMIT ? OFFSET ?", (limit, offset))
|
26
|
+
else:
|
27
|
+
level_int = levelstr2int(level)
|
28
|
+
cursor.execute("SELECT * FROM log WHERE level >= ? ORDER BY created DESC LIMIT ? OFFSET ?", (level_int, limit, offset))
|
29
|
+
levelname_color = {
|
30
|
+
'DEBUG': 'blue',
|
31
|
+
'INFO': 'green',
|
32
|
+
'WARNING': 'yellow',
|
33
|
+
'ERROR': 'red',
|
34
|
+
'CRITICAL': 'bold red',
|
35
|
+
'FATAL': 'bold red'
|
36
|
+
}
|
37
|
+
for row in cursor.fetchall():
|
38
|
+
log = eval_logline(row)
|
39
|
+
console.print(f"{log.created} [{levelname_color[log.levelname]}][{log.levelname}] [default]{log.message}")
|
40
|
+
conn.close()
|
41
|
+
|
42
|
+
def trim(db_file: str, keep: int = 1000, level: Optional[str] = None):
|
43
|
+
conn = sqlite3.connect(db_file)
|
44
|
+
cursor = conn.cursor()
|
45
|
+
if level is None:
|
46
|
+
cursor.execute("DELETE FROM log WHERE id NOT IN (SELECT id FROM log ORDER BY created DESC LIMIT ?)", (keep,))
|
47
|
+
else:
|
48
|
+
cursor.execute("DELETE FROM log WHERE levelname = ? and id NOT IN (SELECT id FROM log WHERE levelname = ? ORDER BY created DESC LIMIT ?)", (level.upper(), level.upper(), keep))
|
49
|
+
conn.commit()
|
50
|
+
conn.execute("VACUUM")
|
51
|
+
conn.close()
|
52
|
+
|
53
|
+
def main():
|
54
|
+
parser = argparse.ArgumentParser(description="Log operations utility")
|
55
|
+
subparsers = parser.add_subparsers(title='subcommands', description='valid subcommands', help='additional help')
|
56
|
+
|
57
|
+
parser_show = subparsers.add_parser('view', help='Show logs')
|
58
|
+
parser_show.add_argument('db_file', type=str, help='Database file path')
|
59
|
+
parser_show.add_argument('-l', '--level', type=str, required=False, help='Log level')
|
60
|
+
parser_show.add_argument('--offset', type=int, default=0, help='Starting offset')
|
61
|
+
parser_show.add_argument('--limit', type=int, default=1000, help='Maximum number of entries to display')
|
62
|
+
parser_show.set_defaults(func=view)
|
63
|
+
|
64
|
+
parser_trim = subparsers.add_parser('trim', help='Trim logs')
|
65
|
+
parser_trim.add_argument('db_file', type=str, help='Database file path')
|
66
|
+
parser_trim.add_argument('-l', '--level', type=str, required=False, help='Log level')
|
67
|
+
parser_trim.add_argument('--keep', type=int, default=1000, help='Number of entries to keep')
|
68
|
+
parser_trim.set_defaults(func=trim)
|
69
|
+
|
70
|
+
args = parser.parse_args()
|
71
|
+
if hasattr(args, 'func'):
|
72
|
+
kwargs = vars(args)
|
73
|
+
func = kwargs.pop('func')
|
74
|
+
func(**kwargs)
|
75
|
+
|
76
|
+
if __name__ == '__main__':
|
77
|
+
main()
|
lfss/cli/vacuum.py
CHANGED
@@ -2,10 +2,11 @@
|
|
2
2
|
Vacuum the database and external storage to ensure that the storage is consistent and minimal.
|
3
3
|
"""
|
4
4
|
|
5
|
-
from lfss.eng.config import LARGE_BLOB_DIR
|
6
|
-
import argparse, time
|
5
|
+
from lfss.eng.config import LARGE_BLOB_DIR, THUMB_DB, LOG_DIR
|
6
|
+
import argparse, time, itertools
|
7
7
|
from functools import wraps
|
8
8
|
from asyncio import Semaphore
|
9
|
+
import aiosqlite
|
9
10
|
import aiofiles, asyncio
|
10
11
|
import aiofiles.os
|
11
12
|
from contextlib import contextmanager
|
@@ -13,6 +14,7 @@ from lfss.eng.database import transaction, unique_cursor
|
|
13
14
|
from lfss.svc.request_log import RequestDB
|
14
15
|
from lfss.eng.utils import now_stamp
|
15
16
|
from lfss.eng.connection_pool import global_entrance
|
17
|
+
from lfss.cli.log import trim
|
16
18
|
|
17
19
|
sem: Semaphore
|
18
20
|
|
@@ -32,7 +34,7 @@ def barriered(func):
|
|
32
34
|
return wrapper
|
33
35
|
|
34
36
|
@global_entrance()
|
35
|
-
async def vacuum_main(index: bool = False, blobs: bool = False):
|
37
|
+
async def vacuum_main(index: bool = False, blobs: bool = False, thumbs: bool = False, logs: bool = False, vacuum_all: bool = False):
|
36
38
|
|
37
39
|
# check if any file in the Large Blob directory is not in the database
|
38
40
|
# the reverse operation is not necessary, because by design, the database should be the source of truth...
|
@@ -49,23 +51,68 @@ async def vacuum_main(index: bool = False, blobs: bool = False):
|
|
49
51
|
|
50
52
|
# create a temporary index to speed up the process...
|
51
53
|
with indicator("Clearing un-referenced files in external storage"):
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
54
|
+
try:
|
55
|
+
async with transaction() as c:
|
56
|
+
await c.execute("CREATE INDEX IF NOT EXISTS fmeta_file_id ON fmeta (file_id)")
|
57
|
+
for i, f in enumerate(LARGE_BLOB_DIR.iterdir()):
|
58
|
+
f_id = f.name
|
59
|
+
await ensure_external_consistency(f_id)
|
60
|
+
if (i+1) % 1_000 == 0:
|
61
|
+
print(f"Checked {(i+1)//1000}k files in external storage.", end='\r')
|
62
|
+
finally:
|
63
|
+
async with transaction() as c:
|
64
|
+
await c.execute("DROP INDEX IF EXISTS fmeta_file_id")
|
61
65
|
|
62
|
-
|
63
|
-
|
64
|
-
with
|
66
|
+
if index or vacuum_all:
|
67
|
+
with indicator("VACUUM-index"):
|
68
|
+
async with transaction() as c:
|
69
|
+
await c.execute("DELETE FROM dupcount WHERE count = 0")
|
70
|
+
async with unique_cursor(is_write=True) as c:
|
65
71
|
await c.execute("VACUUM main")
|
66
|
-
|
67
|
-
|
72
|
+
if blobs or vacuum_all:
|
73
|
+
with indicator("VACUUM-blobs"):
|
74
|
+
async with unique_cursor(is_write=True) as c:
|
68
75
|
await c.execute("VACUUM blobs")
|
76
|
+
|
77
|
+
if logs or vacuum_all:
|
78
|
+
with indicator("VACUUM-logs"):
|
79
|
+
for log_file in LOG_DIR.glob("*.log.db"):
|
80
|
+
trim(str(log_file), keep=10_000)
|
81
|
+
|
82
|
+
if thumbs or vacuum_all:
|
83
|
+
try:
|
84
|
+
async with transaction() as c:
|
85
|
+
await c.execute("CREATE INDEX IF NOT EXISTS fmeta_file_id ON fmeta (file_id)")
|
86
|
+
with indicator("VACUUM-thumbs"):
|
87
|
+
if not THUMB_DB.exists():
|
88
|
+
raise FileNotFoundError("Thumbnail database not found.")
|
89
|
+
async with unique_cursor() as db_c:
|
90
|
+
async with aiosqlite.connect(THUMB_DB) as t_conn:
|
91
|
+
batch_size = 10_000
|
92
|
+
for batch_count in itertools.count(start=0):
|
93
|
+
exceeded_rows = list(await (await t_conn.execute(
|
94
|
+
"SELECT file_id FROM thumbs LIMIT ? OFFSET ?",
|
95
|
+
(batch_size, batch_size * batch_count)
|
96
|
+
)).fetchall())
|
97
|
+
if not exceeded_rows:
|
98
|
+
break
|
99
|
+
batch_ids = [row[0] for row in exceeded_rows]
|
100
|
+
for f_id in batch_ids:
|
101
|
+
cursor = await db_c.execute("SELECT file_id FROM fmeta WHERE file_id = ?", (f_id,))
|
102
|
+
if not await cursor.fetchone():
|
103
|
+
print(f"Thumbnail {f_id} not found in database, removing from thumb cache.")
|
104
|
+
await t_conn.execute("DELETE FROM thumbs WHERE file_id = ?", (f_id,))
|
105
|
+
print(f"Checked {batch_count+1} batches of {batch_size} thumbnails.")
|
106
|
+
|
107
|
+
await t_conn.commit()
|
108
|
+
await t_conn.execute("VACUUM")
|
109
|
+
except FileNotFoundError as e:
|
110
|
+
if "Thumbnail database not found." in str(e):
|
111
|
+
print("Thumbnail database not found, skipping.")
|
112
|
+
|
113
|
+
finally:
|
114
|
+
async with transaction() as c:
|
115
|
+
await c.execute("DROP INDEX IF EXISTS fmeta_file_id")
|
69
116
|
|
70
117
|
async def vacuum_requests():
|
71
118
|
with indicator("VACUUM-requests"):
|
@@ -76,15 +123,18 @@ async def vacuum_requests():
|
|
76
123
|
def main():
|
77
124
|
global sem
|
78
125
|
parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
|
126
|
+
parser.add_argument("--all", action="store_true", help="Vacuum all")
|
79
127
|
parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
|
80
128
|
parser.add_argument("-m", "--metadata", action="store_true", help="Vacuum metadata")
|
81
129
|
parser.add_argument("-d", "--data", action="store_true", help="Vacuum blobs")
|
130
|
+
parser.add_argument("-t", "--thumb", action="store_true", help="Vacuum thumbnails")
|
82
131
|
parser.add_argument("-r", "--requests", action="store_true", help="Vacuum request logs to only keep at most recent 1M rows in 7 days")
|
132
|
+
parser.add_argument("-l", "--logs", action="store_true", help="Trim log to keep at most recent 10k rows for each category")
|
83
133
|
args = parser.parse_args()
|
84
134
|
sem = Semaphore(args.jobs)
|
85
|
-
asyncio.run(vacuum_main(index=args.metadata, blobs=args.data))
|
135
|
+
asyncio.run(vacuum_main(index=args.metadata, blobs=args.data, thumbs=args.thumb, logs = args.logs, vacuum_all=args.all))
|
86
136
|
|
87
|
-
if args.requests:
|
137
|
+
if args.requests or args.all:
|
88
138
|
asyncio.run(vacuum_requests())
|
89
139
|
|
90
140
|
if __name__ == '__main__':
|
lfss/eng/config.py
CHANGED
@@ -11,17 +11,19 @@ if not DATA_HOME.exists():
|
|
11
11
|
DATA_HOME = DATA_HOME.resolve().absolute()
|
12
12
|
LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
|
13
13
|
LARGE_BLOB_DIR.mkdir(exist_ok=True)
|
14
|
+
LOG_DIR = DATA_HOME / 'logs'
|
15
|
+
|
16
|
+
DISABLE_LOGGING = os.environ.get('DISABLE_LOGGING', '0') == '1'
|
14
17
|
|
15
18
|
# https://sqlite.org/fasterthanfs.html
|
16
19
|
__env_large_file = os.environ.get('LFSS_LARGE_FILE', None)
|
17
20
|
if __env_large_file is not None:
|
18
21
|
LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
|
19
22
|
else:
|
20
|
-
LARGE_FILE_BYTES =
|
21
|
-
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024
|
22
|
-
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
23
|
+
LARGE_FILE_BYTES = 1 * 1024 * 1024 # 1MB
|
24
|
+
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
|
23
25
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
|
24
26
|
DEBUG_MODE = os.environ.get('LFSS_DEBUG', '0') == '1'
|
25
27
|
|
26
|
-
THUMB_DB = DATA_HOME / 'thumbs.db'
|
27
|
-
THUMB_SIZE = (
|
28
|
+
THUMB_DB = DATA_HOME / 'thumbs.v0-11.db'
|
29
|
+
THUMB_SIZE = (64, 64)
|
lfss/eng/connection_pool.py
CHANGED
@@ -8,7 +8,7 @@ from functools import wraps
|
|
8
8
|
from typing import Callable, Awaitable
|
9
9
|
|
10
10
|
from .log import get_logger
|
11
|
-
from .error import DatabaseLockedError
|
11
|
+
from .error import DatabaseLockedError, DatabaseTransactionError
|
12
12
|
from .config import DATA_HOME
|
13
13
|
|
14
14
|
async def execute_sql(conn: aiosqlite.Connection | aiosqlite.Cursor, name: str):
|
@@ -29,7 +29,7 @@ async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
|
|
29
29
|
|
30
30
|
conn = await aiosqlite.connect(
|
31
31
|
get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
|
32
|
-
timeout =
|
32
|
+
timeout = 10, uri = True
|
33
33
|
)
|
34
34
|
async with conn.cursor() as c:
|
35
35
|
await c.execute(
|
@@ -147,6 +147,14 @@ def global_entrance(n_read: int = 1):
|
|
147
147
|
return wrapper
|
148
148
|
return decorator
|
149
149
|
|
150
|
+
def handle_sqlite_error(e: Exception):
|
151
|
+
if 'database is locked' in str(e):
|
152
|
+
raise DatabaseLockedError from e
|
153
|
+
if 'cannot start a transaction within a transaction' in str(e):
|
154
|
+
get_logger('database', global_instance=True).error(f"Unexpected error: {e}")
|
155
|
+
raise DatabaseTransactionError from e
|
156
|
+
raise e
|
157
|
+
|
150
158
|
@asynccontextmanager
|
151
159
|
async def unique_cursor(is_write: bool = False):
|
152
160
|
if not is_write:
|
@@ -155,9 +163,7 @@ async def unique_cursor(is_write: bool = False):
|
|
155
163
|
try:
|
156
164
|
yield await connection_obj.conn.cursor()
|
157
165
|
except Exception as e:
|
158
|
-
|
159
|
-
raise DatabaseLockedError from e
|
160
|
-
raise e
|
166
|
+
handle_sqlite_error(e)
|
161
167
|
finally:
|
162
168
|
await g_pool.release(connection_obj)
|
163
169
|
else:
|
@@ -166,9 +172,7 @@ async def unique_cursor(is_write: bool = False):
|
|
166
172
|
try:
|
167
173
|
yield await connection_obj.conn.cursor()
|
168
174
|
except Exception as e:
|
169
|
-
|
170
|
-
raise DatabaseLockedError from e
|
171
|
-
raise e
|
175
|
+
handle_sqlite_error(e)
|
172
176
|
finally:
|
173
177
|
await g_pool.release(connection_obj)
|
174
178
|
|