Metadata-Version: 2.4
Name: globallock
Version: 0.2.0
Summary: Distributed lock manager, support many types of backend, e.g. redis, django-redis, etcd, zookeeper...
Author-email: rRR0VrFP <rrr0vrfp@qq.com>
Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
License-Expression: MIT
Project-URL: Homepage, https://gitee.com/rRR0VrFP/globallock
Keywords: global lock,distributed lock,redis lock,django redis lock,zookeeper lock,etcd lock
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: zenutils
Dynamic: license-file

# globallock

统一接口的分布式锁管理器，支持 **Redis**、**Django-Redis**、**etcd**、**ZooKeeper** 四种后端。

> 名称 `globallock` 中有两个 `l`。

---

## 安装

```bash
pip install globallock
```

---

## 快速开始

### Redis 后端（默认）

```python
from globallock import GlobalLockManager

config = {
    "global_lock_engine_options": {
        "host": "localhost",
        "port": 6379,
        "db": 0,
    },
}
lockman = GlobalLockManager(config)

with lockman.lock("my_lock", timeout=60, blocking=True, blocking_timeout=5) as lock:
    if lock.is_locked:
        # 获得锁，执行临界区代码
        ...
    else:
        # 未获得锁，多数情况下什么都不做
        ...
```

### etcd 后端

```python
from globallock import GlobalLockManager
from globallock import ETCD_GLOBAL_LOCK_CLASS

config = {
    "global_lock_engine_class": ETCD_GLOBAL_LOCK_CLASS,
    "global_lock_engine_options": {
        "host": "localhost",
        "port": 2379,
    },
}
lockman = GlobalLockManager(config)
with lockman.lock("my_lock", timeout=60, blocking=True, blocking_timeout=5) as lock:
    if lock.is_locked:
        ...
```

### ZooKeeper 后端

```python
from globallock import GlobalLockManager
from globallock import ZOOKEEPER_GLOBAL_LOCK_CLASS

config = {
    "global_lock_engine_class": ZOOKEEPER_GLOBAL_LOCK_CLASS,
    "global_lock_engine_options": {
        "hosts": "localhost:2181",
    },
}
lockman = GlobalLockManager(config)
with lockman.lock("my_lock", blocking=True, blocking_timeout=5) as lock:
    if lock.is_locked:
        ...
```

> ZooKeeper 后端不支持 `timeout` 参数（锁不会自动超时释放）。

### Django 集成

```python
# settings.py
GLOBAL_LOCK_CONFIG = {
    "global_lock_engine_options": {
        "redis-cache-name": "default",
    },
}
```

```python
from globallock.django import get_default_global_lock_manager

lockman = get_default_global_lock_manager()
with lockman.lock("my_lock", timeout=60) as lock:
    if lock.is_locked:
        ...
```

---

## 配置项说明

### `GlobalLockManager(config)`

`config` 是一个字典，支持以下键：

| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `global_lock_engine_class` | `str` | `globallock.redis_global_lock.RedisGlobalLock` | 锁定引擎类的完整路径，可使用 `REDIS_GLOBAL_LOCK_CLASS`、`DJANGO_REDIS_GLOBAL_LOCK_CLASS`、`ETCD_GLOBAL_LOCK_CLASS`、`ZOOKEEPER_GLOBAL_LOCK_CLASS` 常量 |
| `global_lock_engine_options` | `dict` | `{}` | 传递给后端驱动的连接参数 |
| `global_lock_key_prefix` | `str` | `_glocks:` | 锁 key 的前缀，不同后端会自动转换为合适的格式（见"key 前缀策略"） |
| `timeout` | `float` / `None` | `None`（Redis 引擎默认 `30`） | Redis 锁的 TTL / etcd lease 时长（秒），到点自动过期回收；非“最小锁定时长”。ZooKeeper 后端不使用该参数（锁无 TTL，`None` 即可，实际阻塞超时用 `blocking_timeout`） |
| `sleep` | `float` | `0.1` | 轮询等待锁的间隔（秒） |
| `blocking` | `bool` | `True` | 是否阻塞等待锁 |
| `blocking_timeout` | `float` / `None` | `None` | 阻塞等待超时（秒），超时未获得锁则返回 `False` |
| `auto_renew` | `bool` | `True`（Redis 引擎） | 是否开启看门狗续期（仅 Redis 后端） |
| `renew_interval` | `float` | `5`（Redis 引擎） | 看门狗续期间隔（秒） |

> `timeout`、`sleep`、`blocking`、`blocking_timeout` 也可以在 `lockman.lock()` 调用时传入，会覆盖 config 中的默认值。

### `lockman.lock(name, ...)`

| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `name` | `str` | 必填 | 锁的名称，同一时间同一名称的锁只能被一个进程持有 |
| `timeout` | `float` / `None` | config 中的值 | Redis 锁的 TTL / etcd lease 时长（秒）；ZooKeeper 后端不使用该参数，实际阻塞超时用 `blocking_timeout` |
| `sleep` | `float` | config 中的值 / `0.1` | 轮询间隔（秒） |
| `blocking` | `bool` | config 中的值 / `True` | 是否阻塞 |
| `blocking_timeout` | `float` / `None` | config 中的值 | 阻塞超时（秒） |

### 锁实例方法 / 属性

| 方法/属性 | 类型 | 说明 |
|---|---|---|
| `lock.acquire()` | `bool` | 主动请求锁，成功返回 `True` |
| `lock.release()` | `None` | 释放锁 |
| `lock.is_locked` | `bool` | 当前是否持有锁 |
| `lock.lock_key` | `str` | 锁在存储引擎中的实际 key |

### Manager 生命周期管理

`GlobalLockManager` 会跟踪它创建的所有锁实例。当 Manager 生命周期消亡时，
统一释放所有仍被持有的锁，并停止对应的看门狗续期线程。有三种触发方式：

1. **显式调用 `lockman.close()`**：
   ```python
   lockman = GlobalLockManager(config)
   lockman.lock("my_lock", ...)
   lockman.close()   # 释放所有仍持有的锁
   ```

2. **`with` 语法**（退出块时自动 `close()`）：
   ```python
   with GlobalLockManager(config) as lockman:
       with lockman.lock("my_lock", timeout=5) as lock:
           ...
       # 退出 with 块时已统一释放所有锁
   ```

3. **进程退出兜底**：`GlobalLockManager` 构造时通过 `atexit` 注册 `close()`，
   进程正常退出时自动释放所有仍持有的锁。

`close()` 是幂等的，只释放仍被持有的锁，已释放的锁会被跳过。

> 说明：Manager 管理的是自己创建的锁实例。锁本身也遵循 `with lock` 或
> `lock.release()` 的常规用法，两种方式可并存（前者用于 Manager 生命周期，
> 后者用于单个锁的即时释放）。

---

## 后端引擎说明

### 依赖安装

各后端的 Python 驱动需要额外安装：

| 后端 | 安装命令 |
|---|---|
| RedisGlobalLock | `pip install redis` |
| DjangoRedisGlobalLock | `pip install django-redis` |
| EtcdGlobalLock | `pip install etcd3` |
| ZookeeperGlobalLock | `pip install kazoo` |

> 这些驱动未声明在 `globallock` 的运行时依赖中，需使用者自行安装。

### tenacity 兼容性

`etcd3 0.12.0` 需要 `tenacity<8`。如使用 etcd 后端，需安装兼容版本：

```bash
pip install 'tenacity<8' etcd3
```

### Key 前缀策略

统一配置 `global_lock_key_prefix`（默认 `_glocks:`），各后端自动转换：

| 后端 | 实际 key 示例 | 说明 |
|---|---|---|
| Redis | `_glocks:my_lock` | 直接使用原始前缀，`:` 是 Redis 的 namespace 分隔符 |
| etcd | `_glocks/my_lock` | `:` 替换为 `/`，去掉前导 `/`（etcd3 Lock 内部添加 `/locks/` 前缀） |
| ZooKeeper | `/_glocks/my_lock` | `:` 替换为 `/`，确保以 `/` 开头（ZooKeeper 路径要求） |

如果锁名称中包含 `:`，该字符会保留不变（例如 `lock_name = "event:test"`，Redis 中为 `_glocks:event:test`）。

### ZooKeeper 后端注意事项

- `timeout` 参数无效（锁不会自动释放）。
- 进程被 `kill -9` 杀死后，其他进程约 10 秒后可重新获得锁。
- 进程正常退出时，ZooKeeper 客户端会自动关闭（通过 `atexit` 注册清理）。

---

## 看门狗续期（Watchdog / Auto-Renew）

Redis 锁的释放依赖 TTL 超时，而不像 etcd / ZooKeeper 那样由连接会话自动回收。
这里的 `timeout` 是 **Redis 锁的 TTL（lease 时长 / 键的过期时间）**，也就是“锁的键在 Redis 中多久后过期”，
**不是**“任务至少要锁定的时长”，也不是“任务持有锁的上限时长”。它本质上是一个**崩溃回收的安全网**：
进程崩溃后，锁在 `timeout` 后自动过期被回收，避免长期残留。

因此 `timeout` 的核心权衡是：

- `timeout` 设得太小：进程崩溃后锁回收较快（残留时间短），但续期间隔 `renew_interval` 必须小于 `timeout`，
  否则看门狗可能来不及续期就过期；
- `timeout` 设得太大：进程崩溃后锁长时间残留，阻塞其他进程获取，影响可用性。

开启 `auto_renew=True`（Redis 引擎默认开启）后，成功获得锁时，**所属 `GlobalLockManager` 会启动一条后台守护线程**（整个 Manager 只有一条，懒启动于首次 `acquire()`），
该线程按各锁自身 `renew_interval` 分时续期：每把锁记录上次续期时间，仅当到期时调用 `redis.Lock.reacquire()` 重置 TTL，并精确休眠到最早的下次唤醒时刻
（默认每 `renew_interval=5` 秒一次；不同间隔的锁互不牵累，长间隔锁不会被短间隔锁拖累成高频刷新）。
未显式设置 `timeout` 时，Redis 引擎默认 `timeout=30`（续期兜底，保证锁有 TTL 可续期）。
效果：

- 进程**存活**期间，锁被持续续期，临界区再长也不会过期；
- 进程**崩溃/被杀**时，守护线程随之消亡，锁在 `timeout` 后自然过期回收。
- 调用 `GlobalLockManager.close()` 会停止守护线程并统一释放所有锁。

### 使用示例

```python
config = {
    "auto_renew": True,          # 开启看门狗续期（Redis 引擎默认 True，可省）
    # "renew_interval": 5,       # 自定义续期间隔（秒），Redis 引擎默认 5
    "global_lock_engine_options": {"host": "localhost", "port": 6379},
}
lockman = GlobalLockManager(config)

# 即使 timeout=5，临界区执行 30 秒锁也不会过期
with lockman.lock("my_lock", timeout=5) as lock:
    if lock.is_locked:
        ...
```

### 注意事项

- `auto_renew` 仅对 **Redis 后端**（`RedisGlobalLock` / `DjangoRedisGlobalLock`）有效；
  etcd / ZooKeeper 由连接会话自动回收，无需续期。
- `auto_renew` 依赖锁有 TTL；未显式设置 `timeout` 时，Redis 引擎默认 `timeout=30`。
- 开启 `auto_renew` 时，底层 `redis.Lock` 会以 `thread_local=False` 创建，
  使 token 对看门狗线程可见（否则看门狗无法续期）。
- 看门狗线程按 `GlobalLockManager` 复用（非每锁一线程），由 Manager 在首次
  `acquire()` 时懒启动，`GlobalLockManager.close()` 时统一停止；仅调用 `acquire()`
  而不释放时，持有期间锁会持续续期。

---

## 设计原理

### 架构

```
GlobalLockManager (工厂)
    │
    ├─ RedisGlobalLock ────── redis.Redis + ConnectionPool (缓存)
    ├─ DjangoRedisGlobalLock ─ django_redis.get_redis_connection
    ├─ EtcdGlobalLock ─────── etcd3.client (每个实例独立)
    └─ ZookeeperGlobalLock ── KazooClient (缓存) + atexit 清理
```

### 连接池缓存

- Redis 的 `ConnectionPool` 通过 `@cacheutils.simple_cache` 缓存，同一进程中所有 `RedisGlobalLock` 实例共享一个连接池。
- ZooKeeper 的 `KazooClient` 同样缓存，所有 `ZookeeperGlobalLock` 实例共享一个客户端连接。

---

## 开发指南

### 启动测试服务

```bash
# 启动所有缺失的服务
bash scripts/start-test-services.sh

# 查看状态
bash scripts/start-test-services.sh status

# 停止所有服务
bash scripts/start-test-services.sh stop

# 使用 docker 而非 podman
CONTAINER_ENGINE=docker bash scripts/start-test-services.sh start
```

### 运行测试

```bash
pip3 install -r pytest.requirements.txt

# 单元测试（无需外部服务）
python3 -m pytest tests/test_base.py tests/test_config.py tests/test_implementations.py tests/test_security.py tests/test_django.py tests/test_redis_watchdog.py

# 集成测试（需要对应服务）
python3 -m pytest tests/test_integration_redis.py
python3 -m pytest tests/test_integration_etcd.py
python3 -m pytest tests/test_integration_zookeeper.py

# 全部测试
python3 -m pytest tests/
```

### 构建发布

```bash
pip3 install build twine
python3 -m build
python3 -m twine upload dist/*
```

---

## 测试通过版本

- Python 3.6 ～ 3.12

---

## 发布历史

### v0.2.0

- **Feature**: Redis 引擎新增看门狗续期（watchdog / auto-renew）。由
  `GlobalLockManager` 统一**单看门狗线程**按各锁自身的 `renew_interval`
  周期续期（默认 5s），避免临界区较长时锁因 TTL 过期，进程崩溃后仍按
  `lock_ttl` 自然回收。
- **Breaking**: 锁参数 `timeout` 重命名为 `lock_ttl`（体现 Redis 锁 TTL 语义）。
  旧参数 `timeout` 仍接受但被忽略，不再作为 TTL 使用。
- **Feature**: `lock_ttl`/`auto_renew`/`renew_interval` 支持在
  `config`（全局默认）或 `lock()` 参数（局部覆盖）中配置；Redis 引擎默认
  `lock_ttl=30`、`auto_renew=True`、`renew_interval=5`。
- **Feature**: 开启 `auto_renew` 时强制 `redis.Lock` 使用
  `thread_local=False`，使看门狗线程可见主线程写入的 token，保证续期成功。
- **Feature**: `GlobalLockManager` 新增统一生命周期管理：跟踪所有锁实例，
  `close()` / `with` 语法 / 进程退出（`atexit`）时统一释放仍持有的锁并停止看门狗。
- **Test**: 新增看门狗续期单元测试（`test_redis_watchdog.py`）与
  `globallock.django` 单元测试（`test_django.py`），Redis 引擎相关模块
  行覆盖率 100%。

### v0.1.5

- **Breaking**: 改用 `pyproject.toml` 打包，移除 `setup.py`。
- **Bug fix**: `acquire()` 现在会正确设置 `is_locked` 属性。
- **Bug fix**: ZooKeeper `_do_acquire` 捕获 `LockTimeout` 返回 `False`，而非抛异常。
- **Bug fix**: 修复 Django demo settings.py 中示例 app 被错误放在 `MIDDLEWARE` 的问题。
- **Bug fix**: 修复 ZK 测试类名、docstring 复制粘贴错误。
- **Feature**: 添加 KazooClient 进程退出自动关闭机制（`atexit`）。
- **Feature**: 添加 `__all__`、类型注解。
- **Feature**: 添加 key 前缀在各后端的一致性策略 (`normalize_key_prefix_for_path`)。
- **Feature**: 添加 `scripts/start-test-services.sh` 一键启动测试服务。
- **Test**: 新增 74 个单元测试（mock 方式，无需外部服务）。
- **Test**: 新增 29 个集成测试（Redis/etcd/ZK 真实后端）。
- **Test**: pytest markers 自动按服务可用性跳过 (`redis_required`, `etcd_required`, `zookeeper_required`)。
- **Security**: 移除本地配置文件中硬编码的凭据。
- **Chore**: 清理开发临时文件（`t1.py`, `t1zk.py`, `t2etcd.py`, `t3redis.py`）。

### v0.1.4

- 文档更新。

### v0.1.3

- 修正 `globallock.django` 默认设置。

### v0.1.2

- `GlobalLockManager.lock()` 方法参数可以在初始化时设置默认值。
- 添加 `globallock.django.get_default_global_lock_manager()` 方法，允许在 Django 中便捷使用。

### v0.1.1

- 文档更新。

### v0.1.0

- 首次发布。
- 支持 RedisGlobalLock、DjangoRedisGlobalLock、EtcdGlobalLock、ZookeeperGlobalLock。
