lfss 0.5.0__tar.gz → 0.5.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -57,13 +57,13 @@ export default class Connector {
57
57
  * @returns {Promise<string>} - the promise of the request, the url of the file
58
58
  */
59
59
  async put(path, file, {
60
- overwrite = false,
60
+ conflict = 'abort',
61
61
  permission = 0
62
62
  } = {}){
63
63
  if (path.startsWith('/')){ path = path.slice(1); }
64
64
  const fileBytes = await file.arrayBuffer();
65
65
  const dst = new URL(this.config.endpoint + '/' + path);
66
- dst.searchParams.append('overwrite', overwrite);
66
+ dst.searchParams.append('conflict', conflict);
67
67
  dst.searchParams.append('permission', permission);
68
68
  const res = await fetch(dst.toString(), {
69
69
  method: 'PUT',
@@ -176,7 +176,7 @@ Are you sure you want to proceed?
176
176
  async function uploadFile(...args){
177
177
  const [file, path] = args;
178
178
  try{
179
- await conn.put(path, file, {overwrite: true});
179
+ await conn.put(path, file, {conflict: 'overwrite'});
180
180
  }
181
181
  catch (err){
182
182
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
@@ -0,0 +1,111 @@
1
+ """
2
+ Balance the storage by ensuring that large file thresholds are met.
3
+ """
4
+
5
+ from lfss.src.config import DATA_HOME, LARGE_BLOB_DIR, LARGE_FILE_BYTES
6
+ import argparse, time
7
+ from functools import wraps
8
+ from asyncio import Semaphore
9
+ import aiosqlite, aiofiles, asyncio
10
+
11
+ sem = Semaphore(1)
12
+ db_file = DATA_HOME / 'lfss.db'
13
+
14
+ def _get_sem():
15
+ return sem
16
+
17
+ def barriered(func):
18
+ @wraps(func)
19
+ async def wrapper(*args, **kwargs):
20
+ async with _get_sem():
21
+ return await func(*args, **kwargs)
22
+ return wrapper
23
+
24
+ @barriered
25
+ async def move_to_external(f_id: str, flag: str = ''):
26
+ async with aiosqlite.connect(db_file, timeout = 60) as c:
27
+ async with c.execute( "SELECT data FROM fdata WHERE file_id = ?", (f_id,)) as cursor:
28
+ blob_row = await cursor.fetchone()
29
+ if blob_row is None:
30
+ print(f"{flag}File {f_id} not found in fdata")
31
+ return
32
+ await c.execute("BEGIN")
33
+ blob: bytes = blob_row[0]
34
+ try:
35
+ async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'wb') as f:
36
+ await f.write(blob)
37
+ await c.execute( "UPDATE fmeta SET external = 1 WHERE file_id = ?", (f_id,))
38
+ await c.execute( "DELETE FROM fdata WHERE file_id = ?", (f_id,))
39
+ await c.commit()
40
+ print(f"{flag}Moved {f_id} to external storage")
41
+ except Exception as e:
42
+ await c.rollback()
43
+ print(f"{flag}Error moving {f_id}: {e}")
44
+
45
+ if isinstance(e, KeyboardInterrupt):
46
+ raise e
47
+
48
+ @barriered
49
+ async def move_to_internal(f_id: str, flag: str = ''):
50
+ async with aiosqlite.connect(db_file, timeout = 60) as c:
51
+ if not (LARGE_BLOB_DIR / f_id).exists():
52
+ print(f"{flag}File {f_id} not found in external storage")
53
+ return
54
+ async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'rb') as f:
55
+ blob = await f.read()
56
+
57
+ await c.execute("BEGIN")
58
+ try:
59
+ await c.execute("INSERT INTO fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
60
+ await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
61
+ await c.commit()
62
+ (LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
63
+ print(f"{flag}Moved {f_id} to internal storage")
64
+ except Exception as e:
65
+ await c.rollback()
66
+ print(f"{flag}Error moving {f_id}: {e}")
67
+ if isinstance(e, KeyboardInterrupt):
68
+ raise e
69
+
70
+
71
+ async def _main():
72
+
73
+ tasks = []
74
+ start_time = time.time()
75
+ async with aiosqlite.connect(db_file) as conn:
76
+ exceeded_rows = await (await conn.execute(
77
+ "SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0",
78
+ (LARGE_FILE_BYTES,)
79
+ )).fetchall()
80
+
81
+ for i in range(0, len(exceeded_rows)):
82
+ row = exceeded_rows[i]
83
+ f_id = row[0]
84
+ tasks.append(move_to_external(f_id, flag=f"[e-{i+1}/{len(exceeded_rows)}] "))
85
+
86
+ async with aiosqlite.connect(db_file) as conn:
87
+ under_rows = await (await conn.execute(
88
+ "SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1",
89
+ (LARGE_FILE_BYTES,)
90
+ )).fetchall()
91
+
92
+ for i in range(0, len(under_rows)):
93
+ row = under_rows[i]
94
+ f_id = row[0]
95
+ tasks.append(move_to_internal(f_id, flag=f"[i-{i+1}/{len(under_rows)}] "))
96
+
97
+ await asyncio.gather(*tasks)
98
+ end_time = time.time()
99
+ print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
100
+ f"{len(exceeded_rows)} files moved to external storage, {len(under_rows)} files moved to internal storage.")
101
+
102
+ def main():
103
+ global sem
104
+ parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
105
+ parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
106
+ args = parser.parse_args()
107
+ sem = Semaphore(args.jobs)
108
+ asyncio.run(_main())
109
+
110
+ if __name__ == '__main__':
111
+ main()
@@ -13,8 +13,8 @@ def parse_arguments():
13
13
  sp_upload.add_argument("src", help="Source file or directory", type=str)
14
14
  sp_upload.add_argument("dst", help="Destination path", type=str)
15
15
  sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
16
- sp_upload.add_argument("--interval", type=float, default=0, help="Interval between retries, only works with directory upload")
17
- sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
16
+ sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
17
+ sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip"], default="abort", help="Conflict resolution")
18
18
  sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
19
19
  sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
20
20
 
@@ -32,7 +32,7 @@ def main():
32
32
  n_concurrent=args.jobs,
33
33
  n_reties=args.retries,
34
34
  interval=args.interval,
35
- overwrite=args.overwrite,
35
+ conflict=args.conflict,
36
36
  permission=args.permission
37
37
  )
38
38
  if failed_upload:
@@ -44,7 +44,7 @@ def main():
44
44
  connector.put(
45
45
  args.dst,
46
46
  f.read(),
47
- overwrite=args.overwrite,
47
+ conflict=args.conflict,
48
48
  permission=args.permission
49
49
  )
50
50
  else:
@@ -34,13 +34,13 @@ class Connector:
34
34
  return response
35
35
  return f
36
36
 
37
- def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
37
+ def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
38
38
  """Uploads a file to the specified path."""
39
39
  if path.startswith('/'):
40
40
  path = path[1:]
41
41
  response = self._fetch('PUT', path, search_params={
42
42
  'permission': int(permission),
43
- 'overwrite': overwrite
43
+ 'conflict': conflict
44
44
  })(
45
45
  data=file_data,
46
46
  headers={'Content-Type': 'application/octet-stream'}
@@ -0,0 +1,38 @@
1
+ CREATE TABLE IF NOT EXISTS user (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ username VARCHAR(255) UNIQUE NOT NULL,
4
+ credential VARCHAR(255) NOT NULL,
5
+ is_admin BOOLEAN DEFAULT FALSE,
6
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
7
+ last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
8
+ max_storage INTEGER DEFAULT 1073741824,
9
+ permission INTEGER DEFAULT 0
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS fmeta (
13
+ url VARCHAR(512) PRIMARY KEY,
14
+ owner_id INTEGER NOT NULL,
15
+ file_id VARCHAR(256) NOT NULL,
16
+ file_size INTEGER,
17
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
18
+ access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
19
+ permission INTEGER DEFAULT 0,
20
+ external BOOLEAN DEFAULT FALSE,
21
+ FOREIGN KEY(owner_id) REFERENCES user(id)
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS fdata (
25
+ file_id VARCHAR(256) PRIMARY KEY,
26
+ data BLOB
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS usize (
30
+ user_id INTEGER PRIMARY KEY,
31
+ size INTEGER DEFAULT 0
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential);
@@ -0,0 +1,5 @@
1
+ PRAGMA journal_mode=MEMROY;
2
+ PRAGMA temp_store=MEMORY;
3
+ PRAGMA page_size=8192;
4
+ PRAGMA synchronous=NORMAL;
5
+ PRAGMA case_sensitive_like=ON;
@@ -10,6 +10,7 @@ if not DATA_HOME.exists():
10
10
  LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
11
11
  LARGE_BLOB_DIR.mkdir(exist_ok=True)
12
12
 
13
- LARGE_FILE_BYTES = 64 * 1024 * 1024 # 64MB
13
+ # https://sqlite.org/fasterthanfs.html
14
+ LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
14
15
  MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
15
16
  MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
@@ -3,6 +3,7 @@ from typing import Optional, overload, Literal, AsyncIterable
3
3
  from abc import ABC, abstractmethod
4
4
 
5
5
  import urllib.parse
6
+ from pathlib import Path
6
7
  import dataclasses, hashlib, uuid
7
8
  from contextlib import asynccontextmanager
8
9
  from functools import wraps
@@ -23,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
23
24
  def hash_credential(username, password):
24
25
  return hashlib.sha256((username + password).encode()).hexdigest()
25
26
 
27
+ async def execute_sql(conn: aiosqlite.Connection, name: str):
28
+ this_dir = Path(__file__).parent
29
+ sql_dir = this_dir.parent / 'sql'
30
+ async with aiofiles.open(sql_dir / name, 'r') as f:
31
+ sql = await f.read()
32
+ sql = sql.split(';')
33
+ for s in sql:
34
+ await conn.execute(s)
35
+
26
36
  _atomic_lock = Lock()
27
37
  def atomic(func):
28
38
  """ Ensure non-reentrancy """
@@ -48,7 +58,8 @@ class DBConnBase(ABC):
48
58
  global _g_conn
49
59
  if _g_conn is None:
50
60
  _g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
51
- await _g_conn.execute('PRAGMA journal_mode=memory')
61
+ await execute_sql(_g_conn, 'pragma.sql')
62
+ await execute_sql(_g_conn, 'init.sql')
52
63
 
53
64
  async def commit(self):
54
65
  await self.conn.commit()
@@ -82,26 +93,6 @@ class UserConn(DBConnBase):
82
93
 
83
94
  async def init(self):
84
95
  await super().init()
85
- # default to 1GB (1024x1024x1024 bytes)
86
- await self.conn.execute('''
87
- CREATE TABLE IF NOT EXISTS user (
88
- id INTEGER PRIMARY KEY AUTOINCREMENT,
89
- username VARCHAR(255) UNIQUE NOT NULL,
90
- credential VARCHAR(255) NOT NULL,
91
- is_admin BOOLEAN DEFAULT FALSE,
92
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
93
- last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
94
- max_storage INTEGER DEFAULT 1073741824,
95
- permission INTEGER DEFAULT 0
96
- )
97
- ''')
98
- await self.conn.execute('''
99
- CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
100
- ''')
101
- await self.conn.execute('''
102
- CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
103
- ''')
104
-
105
96
  return self
106
97
 
107
98
  async def get_user(self, username: str) -> Optional[UserRecord]:
@@ -222,37 +213,6 @@ class FileConn(DBConnBase):
222
213
 
223
214
  async def init(self):
224
215
  await super().init()
225
- await self.conn.execute('''
226
- CREATE TABLE IF NOT EXISTS fmeta (
227
- url VARCHAR(512) PRIMARY KEY,
228
- owner_id INTEGER NOT NULL,
229
- file_id VARCHAR(256) NOT NULL,
230
- file_size INTEGER,
231
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
232
- access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
233
- permission INTEGER DEFAULT 0,
234
- external BOOLEAN DEFAULT FALSE,
235
- FOREIGN KEY(owner_id) REFERENCES user(id)
236
- )
237
- ''')
238
- await self.conn.execute('''
239
- CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
240
- ''')
241
-
242
- await self.conn.execute('''
243
- CREATE TABLE IF NOT EXISTS fdata (
244
- file_id VARCHAR(256) PRIMARY KEY,
245
- data BLOB
246
- )
247
- ''')
248
-
249
- # user file size table
250
- await self.conn.execute('''
251
- CREATE TABLE IF NOT EXISTS usize (
252
- user_id INTEGER PRIMARY KEY,
253
- size INTEGER DEFAULT 0
254
- )
255
- ''')
256
216
  # backward compatibility, since 0.2.1
257
217
  async with self.conn.execute("SELECT * FROM user") as cursor:
258
218
  res = await cursor.fetchall()
@@ -1,4 +1,4 @@
1
- from typing import Optional
1
+ from typing import Optional, Literal
2
2
  from functools import wraps
3
3
 
4
4
  from fastapi import FastAPI, APIRouter, Depends, Request, Response
@@ -179,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
179
179
  async def put_file(
180
180
  request: Request,
181
181
  path: str,
182
- overwrite: Optional[bool] = False,
182
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
183
183
  permission: int = 0,
184
184
  user: UserRecord = Depends(get_current_user)):
185
185
  path = ensure_uri_compnents(path)
@@ -201,8 +201,12 @@ async def put_file(
201
201
  exists_flag = False
202
202
  file_record = await conn.file.get_file_record(path)
203
203
  if file_record:
204
- if not overwrite:
204
+ if conflict == "abort":
205
205
  raise HTTPException(status_code=409, detail="File exists")
206
+ if conflict == "skip":
207
+ return Response(status_code=200, headers={
208
+ "Content-Type": "application/json",
209
+ }, content=json.dumps({"url": path}))
206
210
  # remove the old file
207
211
  exists_flag = True
208
212
  await conn.delete_file(path)
@@ -226,8 +230,9 @@ async def put_file(
226
230
  blobs = await request.body()
227
231
  if len(blobs) > LARGE_FILE_BYTES:
228
232
  async def blob_reader():
229
- for b in range(0, len(blobs), 4096):
230
- yield blobs[b:b+4096]
233
+ chunk_size = 16 * 1024 * 1024 # 16MB
234
+ for b in range(0, len(blobs), chunk_size):
235
+ yield blobs[b:b+chunk_size]
231
236
  await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
232
237
  else:
233
238
  await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
@@ -1,12 +1,12 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
7
7
  homepage = "https://github.com/MenxLi/lfss"
8
8
  repository = "https://github.com/MenxLi/lfss"
9
- include = ["Readme.md", "docs/*", "frontend/*"]
9
+ include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = ">=3.9"
@@ -20,6 +20,7 @@ lfss-serve = "lfss.cli.serve:main"
20
20
  lfss-user = "lfss.cli.user:main"
21
21
  lfss-panel = "lfss.cli.panel:main"
22
22
  lfss-cli = "lfss.cli.cli:main"
23
+ lfss-balance = "lfss.cli.balance:main"
23
24
 
24
25
  [build-system]
25
26
  requires = ["poetry-core>=1.0.0"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes