lfss 0.7.14__py3-none-any.whl → 0.8.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.
lfss/src/database.py CHANGED
@@ -10,10 +10,13 @@ import aiosqlite, aiofiles
10
10
  import aiofiles.os
11
11
 
12
12
  from .connection_pool import execute_sql, unique_cursor, transaction
13
- from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
13
+ from .datatype import (
14
+ UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
15
+ FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
16
+ )
14
17
  from .config import LARGE_BLOB_DIR, CHUNK_SIZE
15
18
  from .log import get_logger
16
- from .utils import decode_uri_compnents, hash_credential, concurrent_wrap
19
+ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
17
20
  from .error import *
18
21
 
19
22
  class DBObjectBase(ABC):
@@ -156,55 +159,108 @@ class FileConn(DBObjectBase):
156
159
  dirs = [await self.get_path_record(u) for u in dirnames] if not skim else [DirectoryRecord(u) for u in dirnames]
157
160
  return dirs
158
161
 
159
- async def list_path(self, url: str, flat: bool = False) -> PathContents:
160
- """
161
- List all files and directories under the given path
162
- if flat is True, list all files under the path, with out delimiting directories
163
- """
164
- self.logger.debug(f"Listing path {url}, flat={flat}")
165
- if not url.endswith('/'):
166
- url += '/'
167
- if url == '/':
168
- # users cannot be queried using '/', because we store them without '/' prefix,
169
- # so we need to handle this case separately,
170
- if flat:
171
- cursor = await self.cur.execute("SELECT * FROM fmeta")
172
- res = await cursor.fetchall()
173
- files = [self.parse_record(r) for r in res]
174
- return PathContents([], files)
162
+ async def count_path_dirs(self, url: str):
163
+ if not url.endswith('/'): url += '/'
164
+ if url == '/': url = ''
165
+ cursor = await self.cur.execute("""
166
+ SELECT COUNT(*) FROM (
167
+ SELECT DISTINCT SUBSTR(
168
+ url, LENGTH(?) + 1,
169
+ INSTR(SUBSTR(url, LENGTH(?) + 1), '/')
170
+ ) AS dirname
171
+ FROM fmeta WHERE url LIKE ? AND dirname != ''
172
+ )
173
+ """, (url, url, url + '%'))
174
+ res = await cursor.fetchone()
175
+ assert res is not None, "Error: count_path_dirs"
176
+ return res[0]
175
177
 
178
+ async def list_path_dirs(
179
+ self, url: str,
180
+ offset: int = 0, limit: int = int(1e5),
181
+ order_by: DirSortKey = '', order_desc: bool = False,
182
+ skim: bool = True
183
+ ) -> list[DirectoryRecord]:
184
+ if not isValidDirSortKey(order_by):
185
+ raise ValueError(f"Invalid order_by ({order_by})")
186
+
187
+ if not url.endswith('/'): url += '/'
188
+ if url == '/': url = ''
189
+
190
+ sql_qury = """
191
+ SELECT DISTINCT SUBSTR(
192
+ url,
193
+ 1 + LENGTH(?),
194
+ INSTR(SUBSTR(url, 1 + LENGTH(?)), '/')
195
+ ) AS dirname
196
+ FROM fmeta WHERE url LIKE ? AND dirname != ''
197
+ """ \
198
+ + (f"ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}" if order_by else '') \
199
+ + " LIMIT ? OFFSET ?"
200
+ cursor = await self.cur.execute(sql_qury, (url, url, url + '%', limit, offset))
201
+ res = await cursor.fetchall()
202
+ dirs_str = [r[0] for r in res]
203
+ async def get_dir(dir_url):
204
+ if skim:
205
+ return DirectoryRecord(dir_url)
176
206
  else:
177
- return PathContents(await self.list_root_dirs(), [])
178
-
207
+ return await self.get_path_record(dir_url)
208
+ dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
209
+ return dirs
210
+
211
+ async def count_path_files(self, url: str, flat: bool = False):
212
+ if not url.endswith('/'): url += '/'
213
+ if url == '/': url = ''
179
214
  if flat:
180
- cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
181
- res = await cursor.fetchall()
182
- files = [self.parse_record(r) for r in res]
183
- return PathContents([], files)
215
+ cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ?", (url + '%', ))
216
+ else:
217
+ cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
218
+ res = await cursor.fetchone()
219
+ assert res is not None, "Error: count_path_files"
220
+ return res[0]
184
221
 
185
- cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
222
+ async def list_path_files(
223
+ self, url: str,
224
+ offset: int = 0, limit: int = int(1e5),
225
+ order_by: FileSortKey = '', order_desc: bool = False,
226
+ flat: bool = False,
227
+ ) -> list[FileRecord]:
228
+ if not isValidFileSortKey(order_by):
229
+ raise ValueError(f"Invalid order_by {order_by}")
230
+
231
+ if not url.endswith('/'): url += '/'
232
+ if url == '/': url = ''
233
+
234
+ sql_query = "SELECT * FROM fmeta WHERE url LIKE ?"
235
+ if not flat: sql_query += " AND url NOT LIKE ?"
236
+ if order_by: sql_query += f" ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}"
237
+ sql_query += " LIMIT ? OFFSET ?"
238
+ if flat:
239
+ cursor = await self.cur.execute(sql_query, (url + '%', limit, offset))
240
+ else:
241
+ cursor = await self.cur.execute(sql_query, (url + '%', url + '%/%', limit, offset))
186
242
  res = await cursor.fetchall()
187
243
  files = [self.parse_record(r) for r in res]
188
-
189
- # substr indexing starts from 1
190
- cursor = await self.cur.execute(
191
- """
192
- SELECT DISTINCT
193
- SUBSTR(
194
- url,
195
- 1 + LENGTH(?),
196
- INSTR(SUBSTR(url, 1 + LENGTH(?)), '/') - 1
197
- ) AS subdir
198
- FROM fmeta WHERE url LIKE ?
199
- """,
200
- (url, url, url + '%')
244
+ return files
245
+
246
+ async def list_path(self, url: str) -> PathContents:
247
+ """
248
+ List all files and directories under the given path.
249
+ This method is a handy way file browsing, but has limitaions:
250
+ - It does not support pagination
251
+ - It does not support sorting
252
+ - It cannot flatten directories
253
+ - It cannot list directories with details
254
+ """
255
+ MAX_ITEMS = int(1e4)
256
+ dir_count = await self.count_path_dirs(url)
257
+ file_count = await self.count_path_files(url, flat=False)
258
+ if dir_count + file_count > MAX_ITEMS:
259
+ raise TooManyItemsError("Too many items, please paginate")
260
+ return PathContents(
261
+ dirs = await self.list_path_dirs(url, skim=True, limit=MAX_ITEMS),
262
+ files = await self.list_path_files(url, flat=False, limit=MAX_ITEMS)
201
263
  )
202
- res = await cursor.fetchall()
203
- dirs_str = [r[0] + '/' for r in res if r[0] != '/']
204
- async def get_dir(dir_url):
205
- return DirectoryRecord(dir_url, -1)
206
- dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
207
- return PathContents(dirs, files)
208
264
 
209
265
  async def get_path_record(self, url: str) -> DirectoryRecord:
210
266
  """
@@ -361,7 +417,8 @@ class FileConn(DBObjectBase):
361
417
  async def set_file_blob(self, file_id: str, blob: bytes):
362
418
  await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
363
419
 
364
- async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
420
+ @staticmethod
421
+ async def set_file_blob_external(file_id: str, stream: AsyncIterable[bytes])->int:
365
422
  size_sum = 0
366
423
  try:
367
424
  async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
@@ -389,7 +446,8 @@ class FileConn(DBObjectBase):
389
446
  if not chunk: break
390
447
  yield chunk
391
448
 
392
- async def delete_file_blob_external(self, file_id: str):
449
+ @staticmethod
450
+ async def delete_file_blob_external(file_id: str):
393
451
  if (LARGE_BLOB_DIR / file_id).exists():
394
452
  await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
395
453
 
@@ -399,6 +457,36 @@ class FileConn(DBObjectBase):
399
457
  async def delete_file_blobs(self, file_ids: list[str]):
400
458
  await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
401
459
 
460
+ _log_active_queue = []
461
+ _log_active_lock = asyncio.Lock()
462
+ @debounce_async()
463
+ async def _set_all_active():
464
+ async with transaction() as conn:
465
+ uconn = UserConn(conn)
466
+ async with _log_active_lock:
467
+ for u in _log_active_queue:
468
+ await uconn.set_active(u)
469
+ _log_active_queue.clear()
470
+ async def delayed_log_activity(username: str):
471
+ async with _log_active_lock:
472
+ _log_active_queue.append(username)
473
+ await _set_all_active()
474
+
475
+ _log_access_queue = []
476
+ _log_access_lock = asyncio.Lock()
477
+ @debounce_async()
478
+ async def _log_all_access():
479
+ async with transaction() as conn:
480
+ fconn = FileConn(conn)
481
+ async with _log_access_lock:
482
+ for r in _log_access_queue:
483
+ await fconn.log_access(r)
484
+ _log_access_queue.clear()
485
+ async def delayed_log_access(url: str):
486
+ async with _log_access_lock:
487
+ _log_access_queue.append(url)
488
+ await _log_all_access()
489
+
402
490
  def validate_url(url: str, is_file = True):
403
491
  prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
404
492
  ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
@@ -433,11 +521,6 @@ class Database:
433
521
  await execute_sql(conn, 'init.sql')
434
522
  return self
435
523
 
436
- async def record_user_activity(self, u: str):
437
- async with transaction() as conn:
438
- uconn = UserConn(conn)
439
- await uconn.set_active(u)
440
-
441
524
  async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
442
525
  validate_url(url)
443
526
  async with transaction() as conn:
@@ -459,9 +542,7 @@ class Database:
459
542
  if file_size is not provided, the blob must be bytes
460
543
  """
461
544
  validate_url(url)
462
- async with transaction() as cur:
463
- uconn = UserConn(cur)
464
- fconn = FileConn(cur)
545
+ async with unique_cursor() as cur:
465
546
  user = await get_user(cur, u)
466
547
  if user is None:
467
548
  return
@@ -477,27 +558,35 @@ class Database:
477
558
  if await get_user(cur, first_component) is None:
478
559
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
479
560
 
480
- user_size_used = await fconn.user_size(user.id)
561
+ fconn_r = FileConn(cur)
562
+ user_size_used = await fconn_r.user_size(user.id)
563
+
481
564
  if isinstance(blob, bytes):
482
565
  file_size = len(blob)
483
566
  if user_size_used + file_size > user.max_storage:
484
567
  raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
485
568
  f_id = uuid.uuid4().hex
486
- await fconn.set_file_blob(f_id, blob)
487
- await fconn.set_file_record(
488
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
489
- permission=permission, external=False, mime_type=mime_type)
569
+
570
+ async with transaction() as w_cur:
571
+ fconn_w = FileConn(w_cur)
572
+ await fconn_w.set_file_blob(f_id, blob)
573
+ await fconn_w.set_file_record(
574
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
575
+ permission=permission, external=False, mime_type=mime_type)
490
576
  else:
491
577
  assert isinstance(blob, AsyncIterable)
492
578
  f_id = uuid.uuid4().hex
493
- file_size = await fconn.set_file_blob_external(f_id, blob)
579
+ file_size = await FileConn.set_file_blob_external(f_id, blob)
494
580
  if user_size_used + file_size > user.max_storage:
495
- await fconn.delete_file_blob_external(f_id)
581
+ await FileConn.delete_file_blob_external(f_id)
496
582
  raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
497
- await fconn.set_file_record(
498
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
499
- permission=permission, external=True, mime_type=mime_type)
500
- await uconn.set_active(user.username)
583
+
584
+ async with transaction() as w_cur:
585
+ await FileConn(w_cur).set_file_record(
586
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
587
+ permission=permission, external=True, mime_type=mime_type)
588
+
589
+ await delayed_log_activity(user.username)
501
590
 
502
591
  async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
503
592
  validate_url(url)
@@ -510,9 +599,7 @@ class Database:
510
599
  raise ValueError(f"File {url} is not stored externally, should use read_file instead")
511
600
  ret = fconn.get_file_blob_external(r.file_id)
512
601
 
513
- async with transaction() as w_cur:
514
- await FileConn(w_cur).log_access(url)
515
-
602
+ await delayed_log_access(url)
516
603
  return ret
517
604
 
518
605
 
@@ -532,9 +619,7 @@ class Database:
532
619
  if blob is None:
533
620
  raise FileNotFoundError(f"File {url} data not found")
534
621
 
535
- async with transaction() as w_cur:
536
- await FileConn(w_cur).log_access(url)
537
-
622
+ await delayed_log_access(url)
538
623
  return blob
539
624
 
540
625
  async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
@@ -653,7 +738,8 @@ class Database:
653
738
  async with unique_cursor() as cur:
654
739
  fconn = FileConn(cur)
655
740
  if urls is None:
656
- urls = [r.url for r in (await fconn.list_path(top_url, flat=True)).files]
741
+ fcount = await fconn.count_path_files(top_url, flat=True)
742
+ urls = [r.url for r in (await fconn.list_path_files(top_url, flat=True, limit=fcount))]
657
743
 
658
744
  for url in urls:
659
745
  if not url.startswith(top_url):
lfss/src/datatype.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from enum import IntEnum
2
+ from typing import Literal
2
3
  import dataclasses
3
4
 
4
5
  class FileReadPermission(IntEnum):
@@ -51,6 +52,10 @@ class DirectoryRecord:
51
52
 
52
53
  @dataclasses.dataclass
53
54
  class PathContents:
54
- dirs: list[DirectoryRecord]
55
- files: list[FileRecord]
56
-
55
+ dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
56
+ files: list[FileRecord] = dataclasses.field(default_factory=list)
57
+
58
+ FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
59
+ isValidFileSortKey = lambda x: x in ['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
60
+ DirSortKey = Literal['', 'dirname']
61
+ isValidDirSortKey = lambda x: x in ['', 'dirname']
lfss/src/error.py CHANGED
@@ -7,4 +7,6 @@ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
7
7
 
8
8
  class InvalidPathError(LFSSExceptionBase, ValueError):...
9
9
 
10
- class StorageExceededError(LFSSExceptionBase):...
10
+ class StorageExceededError(LFSSExceptionBase):...
11
+
12
+ class TooManyItemsError(LFSSExceptionBase):...
lfss/src/server.py CHANGED
@@ -16,9 +16,13 @@ from .error import *
16
16
  from .log import get_logger
17
17
  from .stat import RequestDB
18
18
  from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
19
- from .utils import ensure_uri_compnents, format_last_modified, now_stamp
19
+ from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
20
20
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
21
- from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission, UserConn, FileConn, PathContents
21
+ from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity
22
+ from .datatype import (
23
+ FileReadPermission, FileRecord, UserRecord, PathContents,
24
+ FileSortKey, DirSortKey
25
+ )
22
26
  from .thumb import get_thumb
23
27
 
24
28
  logger = get_logger("server", term_level="DEBUG")
@@ -35,6 +39,7 @@ async def lifespan(app: FastAPI):
35
39
  yield
36
40
  await req_conn.commit()
37
41
  finally:
42
+ await wait_for_debounce_tasks()
38
43
  await asyncio.gather(req_conn.close(), global_connection_close())
39
44
 
40
45
  def handle_exception(fn):
@@ -49,6 +54,7 @@ def handle_exception(fn):
49
54
  if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
50
55
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
51
56
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
57
+ if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
52
58
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
53
59
  raise
54
60
  return wrapper
@@ -182,7 +188,7 @@ async def emit_file(
182
188
  @handle_exception
183
189
  async def get_file(
184
190
  path: str,
185
- download: bool = False, flat: bool = False, thumb: bool = False,
191
+ download: bool = False, thumb: bool = False,
186
192
  user: UserRecord = Depends(get_current_user)
187
193
  ):
188
194
  path = ensure_uri_compnents(path)
@@ -199,8 +205,6 @@ async def get_file(
199
205
  return await emit_thumbnail(path, download, create_time=None)
200
206
 
201
207
  if path == "/":
202
- if flat:
203
- raise HTTPException(status_code=400, detail="Flat query not supported for root path")
204
208
  return PathContents(
205
209
  dirs = await fconn.list_root_dirs(user.username, skim=True) \
206
210
  if not user.is_admin else await fconn.list_root_dirs(skim=True),
@@ -210,7 +214,7 @@ async def get_file(
210
214
  if not path.startswith(f"{user.username}/") and not user.is_admin:
211
215
  raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
212
216
 
213
- return await fconn.list_path(path, flat = flat)
217
+ return await fconn.list_path(path)
214
218
 
215
219
  # handle file query
216
220
  async with unique_cursor() as conn:
@@ -332,7 +336,7 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
332
336
  else:
333
337
  res = await db.delete_file(path, user if not user.is_admin else None)
334
338
 
335
- await db.record_user_activity(user.username)
339
+ await delayed_log_activity(user.username)
336
340
  if res:
337
341
  return Response(status_code=200, content="Deleted")
338
342
  else:
@@ -366,7 +370,10 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
366
370
 
367
371
  async with unique_cursor() as conn:
368
372
  fconn = FileConn(conn)
369
- files = (await fconn.list_path(path, flat = True)).files
373
+ files = await fconn.list_path_files(
374
+ url = path, flat = True,
375
+ limit=(await fconn.count_path_files(url = path, flat = True))
376
+ )
370
377
  files = [f for f in files if await is_access_granted(f)]
371
378
  if len(files) == 0:
372
379
  raise HTTPException(status_code=404, detail="No files found")
@@ -421,7 +428,7 @@ async def update_file_meta(
421
428
  path = ensure_uri_compnents(path)
422
429
  if path.startswith("/"):
423
430
  path = path[1:]
424
- await db.record_user_activity(user.username)
431
+ await delayed_log_activity(user.username)
425
432
 
426
433
  # file
427
434
  if not path.endswith("/"):
@@ -448,6 +455,57 @@ async def update_file_meta(
448
455
  await db.move_path(user, path, new_path)
449
456
 
450
457
  return Response(status_code=200, content="OK")
458
+
459
+ async def validate_path_permission(path: str, user: UserRecord):
460
+ if not path.endswith("/"):
461
+ raise HTTPException(status_code=400, detail="Path must end with /")
462
+ if not path.startswith(f"{user.username}/") and not user.is_admin:
463
+ raise HTTPException(status_code=403, detail="Permission denied")
464
+
465
+ @router_api.get("/count-files")
466
+ async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
467
+ await validate_path_permission(path, user)
468
+ path = ensure_uri_compnents(path)
469
+ async with unique_cursor() as conn:
470
+ fconn = FileConn(conn)
471
+ return { "count": await fconn.count_path_files(url = path, flat = flat) }
472
+ @router_api.get("/list-files")
473
+ async def list_files(
474
+ path: str, offset: int = 0, limit: int = 1000,
475
+ order_by: FileSortKey = "", order_desc: bool = False,
476
+ flat: bool = False, user: UserRecord = Depends(registered_user)
477
+ ):
478
+ await validate_path_permission(path, user)
479
+ path = ensure_uri_compnents(path)
480
+ async with unique_cursor() as conn:
481
+ fconn = FileConn(conn)
482
+ return await fconn.list_path_files(
483
+ url = path, offset = offset, limit = limit,
484
+ order_by=order_by, order_desc=order_desc,
485
+ flat=flat
486
+ )
487
+
488
+ @router_api.get("/count-dirs")
489
+ async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
490
+ await validate_path_permission(path, user)
491
+ path = ensure_uri_compnents(path)
492
+ async with unique_cursor() as conn:
493
+ fconn = FileConn(conn)
494
+ return { "count": await fconn.count_path_dirs(url = path) }
495
+ @router_api.get("/list-dirs")
496
+ async def list_dirs(
497
+ path: str, offset: int = 0, limit: int = 1000,
498
+ order_by: DirSortKey = "", order_desc: bool = False,
499
+ skim: bool = True, user: UserRecord = Depends(registered_user)
500
+ ):
501
+ await validate_path_permission(path, user)
502
+ path = ensure_uri_compnents(path)
503
+ async with unique_cursor() as conn:
504
+ fconn = FileConn(conn)
505
+ return await fconn.list_path_dirs(
506
+ url = path, offset = offset, limit = limit,
507
+ order_by=order_by, order_desc=order_desc, skim=skim
508
+ )
451
509
 
452
510
  @router_api.get("/whoami")
453
511
  @handle_exception
lfss/src/stat.py CHANGED
@@ -42,7 +42,7 @@ class RequestDB:
42
42
  async def commit(self):
43
43
  await self.conn.commit()
44
44
 
45
- @debounce_async(0.05)
45
+ @debounce_async()
46
46
  async def ensure_commit_once(self):
47
47
  await self.commit()
48
48
 
lfss/src/thumb.py CHANGED
@@ -5,6 +5,7 @@ from typing import Optional
5
5
  from PIL import Image
6
6
  from io import BytesIO
7
7
  import aiosqlite
8
+ from contextlib import asynccontextmanager
8
9
 
9
10
  async def _maybe_init_thumb(c: aiosqlite.Cursor):
10
11
  await c.execute('''
@@ -49,6 +50,13 @@ async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
49
50
  ''', (path, ))
50
51
  await c.execute('COMMIT')
51
52
 
53
+ @asynccontextmanager
54
+ async def cache_cursor():
55
+ async with aiosqlite.connect(THUMB_DB) as conn:
56
+ cur = await conn.cursor()
57
+ await _maybe_init_thumb(cur)
58
+ yield cur
59
+
52
60
  async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
53
61
  """
54
62
  returns [image bytes of thumbnail, mime type] if supported,
@@ -58,20 +66,17 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
58
66
  if path.endswith('/'):
59
67
  return None
60
68
 
61
- async with aiosqlite.connect(THUMB_DB) as conn:
62
- cur = await conn.cursor()
63
- await _maybe_init_thumb(cur)
64
-
65
- async with unique_cursor() as main_c:
66
- fconn = FileConn(main_c)
67
- r = await fconn.get_file_record(path)
68
- if r is None:
69
+ async with unique_cursor() as main_c:
70
+ fconn = FileConn(main_c)
71
+ r = await fconn.get_file_record(path)
72
+ if r is None:
73
+ async with cache_cursor() as cur:
69
74
  await _delete_cache_thumb(cur, path)
70
- raise FileNotFoundError(f'File not found: {path}')
71
-
72
- if not r.mime_type.startswith('image/'):
73
- return None
75
+ raise FileNotFoundError(f'File not found: {path}')
76
+ if not r.mime_type.startswith('image/'):
77
+ return None
74
78
 
79
+ async with cache_cursor() as cur:
75
80
  c_time = r.create_time
76
81
  thumb_blob = await _get_cache_thumb(cur, path, c_time)
77
82
  if thumb_blob is not None:
lfss/src/utils.py CHANGED
@@ -1,11 +1,14 @@
1
- import datetime
1
+ import datetime, time
2
2
  import urllib.parse
3
3
  import asyncio
4
4
  import functools
5
5
  import hashlib
6
+ from asyncio import Lock
7
+ from collections import OrderedDict
6
8
  from concurrent.futures import ThreadPoolExecutor
7
9
  from typing import TypeVar, Callable, Awaitable
8
10
  from functools import wraps, partial
11
+ from uuid import uuid4
9
12
  import os
10
13
 
11
14
  def hash_credential(username: str, password: str):
@@ -25,25 +28,56 @@ def ensure_uri_compnents(path: str):
25
28
  """ Ensure the path components are safe to use """
26
29
  return encode_uri_compnents(decode_uri_compnents(path))
27
30
 
28
- def debounce_async(delay: float = 0):
29
- """ Debounce the async procedure """
31
+ g_debounce_tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
32
+ lock_debounce_task_queue = Lock()
33
+ async def wait_for_debounce_tasks():
34
+ async def stop_task(task: asyncio.Task):
35
+ task.cancel()
36
+ try:
37
+ await task
38
+ except asyncio.CancelledError:
39
+ pass
40
+ await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
41
+ g_debounce_tasks.clear()
42
+
43
+ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
44
+ """
45
+ Debounce the async procedure,
46
+ ensuring execution at least once every `max_wait` seconds.
47
+ """
30
48
  def debounce_wrap(func):
31
- # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
49
+ task_record: tuple[str, asyncio.Task] | None = None
50
+ last_execution_time = 0
51
+
32
52
  async def delayed_func(*args, **kwargs):
53
+ nonlocal last_execution_time
33
54
  await asyncio.sleep(delay)
34
55
  await func(*args, **kwargs)
56
+ last_execution_time = time.monotonic()
35
57
 
36
- task_record: asyncio.Task | None = None
37
58
  @functools.wraps(func)
38
59
  async def wrapper(*args, **kwargs):
39
- nonlocal task_record
40
- if task_record is not None:
41
- task_record.cancel()
42
- task_record = asyncio.create_task(delayed_func(*args, **kwargs))
43
- try:
44
- await task_record
45
- except asyncio.CancelledError:
46
- pass
60
+ nonlocal task_record, last_execution_time
61
+
62
+ async with lock_debounce_task_queue:
63
+ if task_record is not None:
64
+ task_record[1].cancel()
65
+ g_debounce_tasks.pop(task_record[0], None)
66
+
67
+ if time.monotonic() - last_execution_time > max_wait:
68
+ await func(*args, **kwargs)
69
+ last_execution_time = time.monotonic()
70
+ return
71
+
72
+ task = asyncio.create_task(delayed_func(*args, **kwargs))
73
+ task_uid = uuid4().hex
74
+ task_record = (task_uid, task)
75
+ async with lock_debounce_task_queue:
76
+ g_debounce_tasks[task_uid] = task
77
+ if len(g_debounce_tasks) > 2048:
78
+ # finished tasks are not removed from the dict
79
+ # so we need to clear it periodically
80
+ await wait_for_debounce_tasks()
47
81
  return wrapper
48
82
  return debounce_wrap
49
83