Compare commits
4 Commits
1964e976f4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaebf22b4 | ||
|
|
3b5f7db51d | ||
|
|
a494d60361 | ||
|
|
da60365214 |
39
.workbuddy/memory/2026-09-03.md
Normal file
39
.workbuddy/memory/2026-09-03.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 2026-09-03 同步 1062 复发 + Oracle 盒子过载
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
用户报 sync 状态卡在「同步中」+ `1062 Duplicate entry '...mp4' for key 'filename'`。
|
||||||
|
游标停在 `2026-09-02 13:55:21`。
|
||||||
|
|
||||||
|
## 根因(与 8/29 identity_map 同一类)
|
||||||
|
Oracle FAM-Edge 的 SQLite 视频/人物 id 整体重排(2303 段 → 3408/4600+ 段),
|
||||||
|
NAS 镜像表 `sync_videos`/`sync_people` 旧 upsert 以 Oracle id 当主键插入,
|
||||||
|
与已存在的同 `filename`/`label` 行在 UNIQUE 键上二次冲突 → 1062,整批中止、游标不推进。
|
||||||
|
量化:本次积压 348 videos 中 **114 行**会 1062;9 people 中 **6 行**会 1062。
|
||||||
|
events/model_calls 仅 PK id(ON DUPLICATE 按 id 更新,不会 1062)→ 无需改。
|
||||||
|
|
||||||
|
## 修复(已部署 NAS + 提交推送 0238030)
|
||||||
|
- `upsert_sync_videos`:改以 `filename` 业务键去重,命中就地 UPDATE 保留 NAS 原 id
|
||||||
|
(防 sync_events/model_calls/identity_map 的 video_id 外键失效),Oracle id 落
|
||||||
|
`oracle_id` 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增。
|
||||||
|
- `upsert_sync_people`:同模式,以 `label` 业务键去重(people.id 无外键引用)。
|
||||||
|
- `scripts/ddl.sql`:sync_videos/sync_people 的 id 改 NAS 本地自增 + 新增 oracle_id 列。
|
||||||
|
- NAS 现网迁移:两表加 oracle_id、回填 oracle_id=id、id 改 AUTO_INCREMENT,重启。
|
||||||
|
- 验证:单批补拉 videos+348 events+1017 people+9 model_calls+500 identity_map+238,
|
||||||
|
游标 → `2026-09-03 08:51:11`,无 1062。目标行保留 id=2303、oracle_id=3408。
|
||||||
|
|
||||||
|
## 第二个问题:Oracle 盒子过载/挂死(未解决,环境层)
|
||||||
|
NAS 日志 08:51–08:56 持续:`推送运动事件到 Oracle 失败 Connection reset` /
|
||||||
|
`500 {"error":"database is locked"}` / `RemoteDisconnected`。
|
||||||
|
FAM-Edge /health 先 200(status ok)后变 HTTP 000;最终整盒 129.146.26.249 全暗
|
||||||
|
(:80/:5000/:5300/:7000/:8123 均 000),但 TCP :22/:5000 端口 OPEN、SSH 握手被拒
|
||||||
|
(kex 阶段 Connection closed)→ 进程卡死/资源耗尽(4C23G 上 FAM-Edge 分析管线 +
|
||||||
|
ai-gateway + garmin + auth-hub + frps + caddy 挤一起,SQLite 锁竞争 + 可能 OOM)。
|
||||||
|
**SSH 连不进去,无法远程修复。** 需用户在 Oracle Cloud 控制台重启 VM,或等其自恢复。
|
||||||
|
恢复后 NAS 的 oracle_sync 后台线程(30min 周期)会自动从游标 08:51:11 续拉,
|
||||||
|
motion_notifier 也会恢复推送。
|
||||||
|
|
||||||
|
## 经验
|
||||||
|
- 所有以 Oracle id 为镜像主键的表迟早会因 Oracle 库重建/重排踩坑。已修:
|
||||||
|
identity_map(8/29)、videos+people(9/3)。events/model_calls 目前 PK-only 安全,
|
||||||
|
但若将来 Oracle 重建导致子表引用错位,需同样改造(加 oracle_id + 业务键)。
|
||||||
|
- Oracle 盒子服务过多挤在 4C23G,建议拆分或给 FAM-Edge SQLite 开 WAL + 限速。
|
||||||
36
PROGRESS.md
36
PROGRESS.md
@@ -612,3 +612,39 @@ people 59;外网 `/` `/timeline` 200、`/login` 302、`/api/*` 未登录 401
|
|||||||
|
|
||||||
**待办**:NAS 侧部署 fam-notifier(只能用户手动,密码登录);chat_history 一次性迁移
|
**待办**:NAS 侧部署 fam-notifier(只能用户手动,密码登录);chat_history 一次性迁移
|
||||||
(`fam-core/scripts/import_chat_history.py`,幂等);frpc.toml 里的 8000 映射可删。
|
(`fam-core/scripts/import_chat_history.py`,幂等);frpc.toml 里的 8000 映射可删。
|
||||||
|
|
||||||
|
## 修复 fam-edge 多线程共用 SQLite 连接(2026-09-13)
|
||||||
|
|
||||||
|
**触发**:用户问「甲骨文剩余硬盘小于 10G 会删东西的逻辑怎么没了?」。查下来逻辑一直在、
|
||||||
|
也没被迁云动过(`disk_guard.py` 最后一次改动还是 8/28 新增它那次),但它 95% 的检查在空转:
|
||||||
|
|
||||||
|
```
|
||||||
|
DiskGuard「本轮清理完成」 128 次
|
||||||
|
DiskGuard「检查异常」 2837 次 ← 22 倍
|
||||||
|
```
|
||||||
|
|
||||||
|
失败原因全是 `cannot start a transaction within a transaction`。表现就是磁盘剩余空间在
|
||||||
|
2.7GB 和 16GB 之间来回荡——清理能不能成功全靠运气,赶上 rclone 集中下载
|
||||||
|
(实测 5 分钟写入 12.6GB)就掉进危险区。
|
||||||
|
|
||||||
|
**根因**:`OracleDB.__init__` 建一条 `sqlite3.connect(check_same_thread=False)` 的连接
|
||||||
|
给全进程共用,而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的 4 个
|
||||||
|
请求线程都在并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏。
|
||||||
|
同一个根因在线上刷出三类错误,累计:`database is locked` 71604 次、
|
||||||
|
`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次
|
||||||
|
(后者堆栈落在 `self._conn.commit()`,是游标被别的线程重置的典型症状)。
|
||||||
|
运动事件推送被 500 打回也是它——NAS 侧失败批次不推进游标,下一轮补推,所以没丢事件。
|
||||||
|
|
||||||
|
**修复**:`_conn` 改成 `@property`,从 `threading.local()` 取当前线程的连接,没有就新建
|
||||||
|
(WAL + busy_timeout=10000)。`close()` 相应改成收掉所有线程开过的连接。因为外部调用方
|
||||||
|
(如 `api_gateway` 的 activity 端点)也在直接用 `db._conn.execute(...)`,做成 property
|
||||||
|
可以让全部现有调用点原样工作,不用逐个改。`_write_lock` 保留,复合写语义不变。
|
||||||
|
|
||||||
|
**测试**:新增 2 个用例(8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上
|
||||||
|
稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过;
|
||||||
|
fam-edge 全套 159 个测试绿。
|
||||||
|
|
||||||
|
**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个
|
||||||
|
文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError,代价是
|
||||||
|
**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件,
|
||||||
|
新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
|
|||||||
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
|
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
|
||||||
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护(Restart=always)**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`)
|
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护(Restart=always)**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`)
|
||||||
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧(pkill/pgrep 会匹配 SSH 自身命令行导致断连)
|
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧(pkill/pgrep 会匹配 SSH 自身命令行导致断连)
|
||||||
|
- **sqlite3 连接不能跨线程共享**(2026-09-13 修):`OracleDB` 原来在 `__init__` 里建一条 `check_same_thread=False` 的连接给全进程用,VideoQueue / PersonService / DiskGuard 三个后台线程加 gunicorn 请求线程并发读写它,事务状态互相踩踏,线上累计刷出 `database is locked` 71604 次、`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次;DiskGuard 的清理被打断 2837 次,磁盘守护形同虚设(剩余空间在 2.7G 和 16G 之间来回荡)。改成 `threading.local()` 每线程一条连接后,WAL 下读不互斥、写由 SQLite 自己排队。**新增后台线程时不要再去共用某一条连接对象**
|
||||||
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`);`start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
|
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`);`start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
|
||||||
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
|
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
|
||||||
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析
|
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析
|
||||||
|
|||||||
@@ -33,13 +33,42 @@ class OracleDB:
|
|||||||
def __init__(self, db_path: str):
|
def __init__(self, db_path: str):
|
||||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
self._local = threading.local() # 每线程一条连接,见下面的 _conn
|
||||||
self._conn.row_factory = sqlite3.Row
|
self._all_conns = [] # 仅供 close() 收尾
|
||||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
self._conns_lock = threading.Lock()
|
||||||
self._conn.execute("PRAGMA busy_timeout=10000")
|
|
||||||
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit)串行化
|
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit)串行化
|
||||||
self._init_schema()
|
self._init_schema()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _conn(self) -> sqlite3.Connection:
|
||||||
|
"""当前线程的连接(2026-09-13 从"全进程共用一条"改成每线程一条)。
|
||||||
|
|
||||||
|
原来是 __init__ 里建一条 `check_same_thread=False` 的连接给所有线程共用:
|
||||||
|
VideoQueue / PersonService / DiskGuard 三个后台线程,加上 gunicorn 的请求
|
||||||
|
线程,并发读写同一个连接对象,事务状态互相踩踏。线上长期刷三类报错,
|
||||||
|
全是这一个根因:
|
||||||
|
|
||||||
|
- `cannot start a transaction within a transaction`:一个线程的事务还
|
||||||
|
开着,另一个线程又要开——DiskGuard 的清理被打断 2837 次,磁盘守护基本
|
||||||
|
靠运气生效(表现为剩余空间在 2.7G 和 16G 之间来回荡)
|
||||||
|
- `no more rows available`:commit 时游标已被别的线程重置,87 次
|
||||||
|
- `database is locked`:每小时上百次,NAS 推来的运动事件被 500 打回
|
||||||
|
|
||||||
|
sqlite3 的连接本来就不是可并发共享的对象。改成各线程各拿一条之后:WAL 下
|
||||||
|
多连接读不互斥,写由 SQLite 自己排队(busy_timeout 兜底等 10 秒),而
|
||||||
|
`_write_lock` 继续保证"复合写"在本进程内串行,语义不变。
|
||||||
|
"""
|
||||||
|
conn = getattr(self._local, 'conn', None)
|
||||||
|
if conn is None:
|
||||||
|
conn = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=10000")
|
||||||
|
self._local.conn = conn
|
||||||
|
with self._conns_lock:
|
||||||
|
self._all_conns.append(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _init_schema(self):
|
def _init_schema(self):
|
||||||
c = self._conn
|
c = self._conn
|
||||||
@@ -1078,4 +1107,12 @@ class OracleDB:
|
|||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self._conn.close()
|
"""关掉所有线程开过的连接(不只当前线程这一条)。"""
|
||||||
|
with self._conns_lock:
|
||||||
|
conns, self._all_conns = self._all_conns, []
|
||||||
|
for c in conns:
|
||||||
|
try:
|
||||||
|
c.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
self._local = threading.local()
|
||||||
|
|||||||
@@ -84,7 +84,15 @@ class VideoQueue:
|
|||||||
if row is None:
|
if row is None:
|
||||||
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
|
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
|
||||||
if self.file_validate:
|
if self.file_validate:
|
||||||
if time.time() - os.path.getmtime(path) < self.stable_window_sec:
|
try:
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
except OSError:
|
||||||
|
# 列目录之后、读 mtime 之前,DiskGuard 可能刚好把这个文件清掉了
|
||||||
|
# (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描
|
||||||
|
# 会被这一个文件中断,后面的新素材本轮都登记不上。
|
||||||
|
logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}")
|
||||||
|
continue
|
||||||
|
if time.time() - mtime < self.stable_window_sec:
|
||||||
logger.info(f"文件仍在写入(mtime 未稳定),跳过本轮: {fn}")
|
logger.info(f"文件仍在写入(mtime 未稳定),跳过本轮: {fn}")
|
||||||
continue
|
continue
|
||||||
ok, verr, vmeta = validate_video(path)
|
ok, verr, vmeta = validate_video(path)
|
||||||
|
|||||||
@@ -463,3 +463,63 @@ def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
|
|||||||
assert db.get_video_by_motion_event_id(555) is None
|
assert db.get_video_by_motion_event_id(555) is None
|
||||||
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
|
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 并发(2026-09-13:修"全进程共用一条 sqlite 连接"的回归测试)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_concurrent_writes_from_many_threads(tmp_path):
|
||||||
|
"""多线程同时读写不能互相踩踏。
|
||||||
|
|
||||||
|
改成每线程一条连接之前,这里会稳定炸出三类错误之一:
|
||||||
|
`cannot start a transaction within a transaction` / `no more rows available`
|
||||||
|
/ `database is locked`——线上 DiskGuard 的清理就是被第一种打断了 2837 次。
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
db = _db(tmp_path)
|
||||||
|
errors = []
|
||||||
|
rounds = 25
|
||||||
|
|
||||||
|
def writer(tid):
|
||||||
|
try:
|
||||||
|
for i in range(rounds):
|
||||||
|
db.record_activity('t%d' % tid, 'act%d' % i, 'detail')
|
||||||
|
db.set_cursor('cursor_t%d' % tid, str(i))
|
||||||
|
db.record_motion_events([{
|
||||||
|
'event_id': tid * 1000 + i, 'camera_id': 2, 'event_type': 10,
|
||||||
|
'start_time': 1789000000 + i, 'duration': 5,
|
||||||
|
'thumbnail_url': '',
|
||||||
|
}])
|
||||||
|
db.get_recent_activities(5)
|
||||||
|
db.get_motion_heartbeat_age_sec()
|
||||||
|
except Exception as e: # noqa: BLE001 —— 要把原始异常带出来看
|
||||||
|
errors.append(f"线程{tid}: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=writer, args=(t,)) for t in range(8)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=60)
|
||||||
|
|
||||||
|
assert not errors, "并发写出错:\n" + "\n".join(errors[:5])
|
||||||
|
assert db._conn.execute("SELECT COUNT(*) FROM ss_motion_events").fetchone()[0] == 8 * rounds
|
||||||
|
for tid in range(8):
|
||||||
|
assert db.get_cursor('cursor_t%d' % tid) == str(rounds - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_releases_every_thread_connection(tmp_path):
|
||||||
|
"""close() 要收掉所有线程开过的连接,不只当前线程那一条。"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
db = _db(tmp_path)
|
||||||
|
db.record_activity('main', 'x', '')
|
||||||
|
|
||||||
|
def other():
|
||||||
|
db.record_activity('other', 'y', '')
|
||||||
|
t = threading.Thread(target=other)
|
||||||
|
t.start(); t.join()
|
||||||
|
|
||||||
|
assert len(db._all_conns) == 2 # 主线程 + 子线程各一条
|
||||||
|
db.close()
|
||||||
|
assert db._all_conns == []
|
||||||
|
|||||||
73
fam-edge/tests/test_video_queue.py
Normal file
73
fam-edge/tests/test_video_queue.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""VideoQueue 生产者扫描的单测。
|
||||||
|
|
||||||
|
目前只覆盖一个点:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把文件清掉
|
||||||
|
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
|
||||||
|
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fam_edge import video_queue as vq_mod
|
||||||
|
from fam_edge.video_queue import VideoQueue
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""只实现生产者路径上用到的方法。"""
|
||||||
|
def __init__(self):
|
||||||
|
self.registered = []
|
||||||
|
self.activities = []
|
||||||
|
|
||||||
|
def get_video_by_filename(self, fn):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def ensure_video(self, fn, path, camera_name=None):
|
||||||
|
self.registered.append(fn)
|
||||||
|
return len(self.registered)
|
||||||
|
|
||||||
|
def set_video_file_status(self, vid, valid, err, meta=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def record_activity(self, service, action, detail=''):
|
||||||
|
self.activities.append((service, action))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queue_with_two_files(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
|
||||||
|
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
|
||||||
|
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
|
||||||
|
})
|
||||||
|
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
|
||||||
|
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
|
||||||
|
db = _FakeDB()
|
||||||
|
q = VideoQueue(db)
|
||||||
|
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
|
||||||
|
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
|
||||||
|
return q, db
|
||||||
|
|
||||||
|
|
||||||
|
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
|
||||||
|
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
|
||||||
|
q, db = queue_with_two_files
|
||||||
|
real_getmtime = os.path.getmtime
|
||||||
|
|
||||||
|
def fake_getmtime(path):
|
||||||
|
if 'a_被删掉的' in path:
|
||||||
|
raise FileNotFoundError(2, 'No such file or directory', path)
|
||||||
|
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
|
||||||
|
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
|
||||||
|
|
||||||
|
q._produce_once() # 不抛异常
|
||||||
|
|
||||||
|
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
|
||||||
|
|
||||||
|
|
||||||
|
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
|
||||||
|
"""mtime 太新(rclone 还在写)仍然要跳过,别把半成品入队。"""
|
||||||
|
q, db = queue_with_two_files
|
||||||
|
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
|
||||||
|
|
||||||
|
q._produce_once()
|
||||||
|
|
||||||
|
assert db.registered == []
|
||||||
72
fam-notifier/scripts/S99fam-notifier.sh
Executable file
72
fam-notifier/scripts/S99fam-notifier.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# fam-notifier 开机自启(DSM 没有 systemd,/usr/local/etc/rc.d/ 是唯一的守护手段)
|
||||||
|
#
|
||||||
|
# 部署:sudo cp 到 /usr/local/etc/rc.d/S99fam-notifier.sh && sudo chmod 755 同路径
|
||||||
|
#
|
||||||
|
# 为什么非有不可:这个进程是摄像头系统在 NAS 上唯一保留的东西(轮询
|
||||||
|
# Surveillance Station,把运动事件单向推给甲骨文)。它没起来的话事件就悄无声息
|
||||||
|
# 地断流——2026-09-04 到 09-12 断档整整十天,就是因为当时它还在 fam-core 里、
|
||||||
|
# fam-core 挂了没人重启,而且不打开网站根本发现不了。
|
||||||
|
#
|
||||||
|
# 以 ericwyuan 而不是 root 运行:保持 logs/ 和 data/cursor.json 的属主跟手动启动
|
||||||
|
# 时一致,免得 root 建出来的文件之后普通用户改不动。
|
||||||
|
RUN_USER=ericwyuan
|
||||||
|
APP_DIR=/volume1/web/sentinel-home-ai/fam-notifier
|
||||||
|
START="$APP_DIR/scripts/start_notifier.sh"
|
||||||
|
LOG="$APP_DIR/logs/start.log"
|
||||||
|
PIDFILE="$APP_DIR/fam-notifier.pid"
|
||||||
|
|
||||||
|
# DSM 上没有 pgrep,用 grep 字符类找 pid([f] 写法避免匹配到 grep 自己)
|
||||||
|
find_pid() {
|
||||||
|
ps aux | grep "[f]am_notifier" | awk '{print $2}' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
RUNNING=$(find_pid)
|
||||||
|
if [ -n "$RUNNING" ]; then
|
||||||
|
echo "fam-notifier 已在运行 (PID $RUNNING)"; echo "$RUNNING" > "$PIDFILE"; return 0
|
||||||
|
fi
|
||||||
|
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
|
||||||
|
n=0
|
||||||
|
while [ ! -f "$START" ] && [ $n -lt 90 ]; do
|
||||||
|
sleep 1; n=$((n+1))
|
||||||
|
done
|
||||||
|
if [ ! -f "$START" ]; then
|
||||||
|
echo "等待 $START 超时(数据卷可能未挂载),启动放弃"; return 1
|
||||||
|
fi
|
||||||
|
# su 自身的 stdio 也要断开:否则通过 SSH 执行本脚本时,子进程继承了连接的管道,
|
||||||
|
# 命令跑完了 SSH 会话却迟迟不退出(开机时无所谓,手动敲的时候很烦)
|
||||||
|
su "$RUN_USER" -c "cd '$APP_DIR' && setsid nohup sh scripts/start_notifier.sh >> '$LOG' 2>&1 < /dev/null &" > /dev/null 2>&1 < /dev/null
|
||||||
|
sleep 4
|
||||||
|
PID=$(find_pid)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "$PID" > "$PIDFILE"
|
||||||
|
echo "fam-notifier 已启动 (PID $PID)"
|
||||||
|
else
|
||||||
|
echo "启动失败,请查看 $LOG 与 $APP_DIR/logs/fam-notifier.log"; return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
|
||||||
|
for p in $(ps aux | grep "[f]am_notifier" | awk '{print $2}'); do kill "$p" 2>/dev/null; done
|
||||||
|
echo "fam-notifier 已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
PID=$(find_pid)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "运行中 (PID $PID)"
|
||||||
|
tail -3 "$APP_DIR/logs/fam-notifier.log" 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "未运行"; return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start) start ;;
|
||||||
|
stop) stop ;;
|
||||||
|
restart) stop; sleep 2; start ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user