svc-infra 0.1.595__py3-none-any.whl → 1.1.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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from dataclasses import asdict
6
+ from datetime import UTC, datetime
7
+ from typing import Any, cast
8
+
9
+ from redis import Redis
10
+
11
+ from .queue import Job, JobQueue
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Lua script for atomic reserve: pop from ready, push to processing, set visibility timeout
16
+ # Returns job_id if successful, nil if queue is empty
17
+ _RESERVE_LUA = """
18
+ local ready_key = KEYS[1]
19
+ local processing_key = KEYS[2]
20
+ local processing_vt_key = KEYS[3]
21
+ local visible_at = ARGV[1]
22
+
23
+ local job_id = redis.call('RPOPLPUSH', ready_key, processing_key)
24
+ if job_id then
25
+ redis.call('ZADD', processing_vt_key, visible_at, job_id)
26
+ end
27
+ return job_id
28
+ """
29
+
30
+
31
+ class RedisJobQueue(JobQueue):
32
+ """Redis-backed job queue with visibility timeout and delayed retries.
33
+
34
+ Keys (with optional prefix):
35
+ - {p}:ready (LIST) ready job ids
36
+ - {p}:processing (LIST) in-flight job ids
37
+ - {p}:processing_vt (ZSET) id -> visible_at (epoch seconds)
38
+ - {p}:delayed (ZSET) id -> available_at (epoch seconds)
39
+ - {p}:seq (STRING) INCR for job ids
40
+ - {p}:job:{id} (HASH) job fields (json payload)
41
+ - {p}:dlq (LIST) dead-letter job ids
42
+ """
43
+
44
+ def __init__(self, client: Redis, *, prefix: str = "jobs", visibility_timeout: int = 60):
45
+ self._r = client
46
+ self._p = prefix
47
+ self._vt = visibility_timeout
48
+ # Try to register Lua script for atomic reserve
49
+ # Falls back to non-atomic if Lua scripting isn't available (e.g., fakeredis in tests)
50
+ self._reserve_script = None
51
+ try:
52
+ self._reserve_script = client.register_script(_RESERVE_LUA)
53
+ except Exception as e:
54
+ logger.debug("Lua scripting not available, using non-atomic reserve: %s", e)
55
+
56
+ # Key helpers
57
+ def _k(self, name: str) -> str:
58
+ return f"{self._p}:{name}"
59
+
60
+ def _job_key(self, job_id: str) -> str:
61
+ return f"{self._p}:job:{job_id}"
62
+
63
+ # Core ops
64
+ def enqueue(self, name: str, payload: dict, *, delay_seconds: int = 0) -> Job:
65
+ now = datetime.now(UTC)
66
+ job_id = str(self._r.incr(self._k("seq")))
67
+ job = Job(id=job_id, name=name, payload=dict(payload))
68
+ # Persist job
69
+ data = asdict(job)
70
+ data["payload"] = json.dumps(data["payload"]) # store payload as JSON string
71
+ # available_at stored as ISO format
72
+ data["available_at"] = job.available_at.isoformat()
73
+ self._r.hset(
74
+ self._job_key(job_id),
75
+ mapping={k: str(v) for k, v in data.items() if v is not None},
76
+ )
77
+ if delay_seconds and delay_seconds > 0:
78
+ at = int(now.timestamp()) + int(delay_seconds)
79
+ self._r.zadd(self._k("delayed"), {job_id: at})
80
+ else:
81
+ # push to ready
82
+ self._r.lpush(self._k("ready"), job_id)
83
+ return job
84
+
85
+ def _move_due_delayed_to_ready(self) -> None:
86
+ now_ts = int(datetime.now(UTC).timestamp())
87
+ ids = cast("list[Any]", self._r.zrangebyscore(self._k("delayed"), "-inf", now_ts))
88
+ if not ids:
89
+ return
90
+ pipe = self._r.pipeline()
91
+ for jid in ids:
92
+ jid_s = jid.decode() if isinstance(jid, (bytes, bytearray)) else str(jid)
93
+ pipe.lpush(self._k("ready"), jid_s)
94
+ pipe.zrem(self._k("delayed"), jid_s)
95
+ pipe.execute()
96
+
97
+ def _requeue_timed_out_processing(self) -> None:
98
+ now_ts = int(datetime.now(UTC).timestamp())
99
+ ids = cast("list[Any]", self._r.zrangebyscore(self._k("processing_vt"), "-inf", now_ts))
100
+ if not ids:
101
+ return
102
+ pipe = self._r.pipeline()
103
+ for jid in ids:
104
+ jid_s = jid.decode() if isinstance(jid, (bytes, bytearray)) else str(jid)
105
+ pipe.lrem(self._k("processing"), 1, jid_s)
106
+ pipe.lpush(self._k("ready"), jid_s)
107
+ pipe.zrem(self._k("processing_vt"), jid_s)
108
+ # clear stale visibility timestamp so next reservation can set a fresh one
109
+ pipe.hdel(self._job_key(jid_s), "visible_at")
110
+ pipe.execute()
111
+
112
+ def reserve_next(self) -> Job | None:
113
+ # opportunistically move due delayed jobs
114
+ self._move_due_delayed_to_ready()
115
+ # move timed-out processing jobs back to ready before reserving
116
+ self._requeue_timed_out_processing()
117
+
118
+ # Calculate visibility timeout BEFORE reserve to prevent race condition
119
+ visible_at = int(datetime.now(UTC).timestamp()) + int(self._vt)
120
+
121
+ # Try atomic reserve using Lua script if available
122
+ # This prevents race condition where two workers could reserve the same job
123
+ if self._reserve_script is not None:
124
+ try:
125
+ jid = self._reserve_script(
126
+ keys=[
127
+ self._k("ready"),
128
+ self._k("processing"),
129
+ self._k("processing_vt"),
130
+ ],
131
+ args=[visible_at],
132
+ )
133
+ except Exception as e:
134
+ # Fall back to non-atomic if Lua fails at runtime
135
+ logger.warning("Lua script failed, using non-atomic reserve: %s", e)
136
+ jid = self._r.rpoplpush(self._k("ready"), self._k("processing"))
137
+ if jid:
138
+ job_id_tmp = jid.decode() if isinstance(jid, (bytes, bytearray)) else str(jid)
139
+ self._r.zadd(self._k("processing_vt"), {job_id_tmp: visible_at})
140
+ else:
141
+ # Non-atomic fallback (for fakeredis in tests, or older Redis versions)
142
+ jid = self._r.rpoplpush(self._k("ready"), self._k("processing"))
143
+ if jid:
144
+ job_id_tmp = jid.decode() if isinstance(jid, (bytes, bytearray)) else str(jid)
145
+ self._r.zadd(self._k("processing_vt"), {job_id_tmp: visible_at})
146
+
147
+ if not jid:
148
+ return None
149
+ job_id = jid.decode() if isinstance(jid, (bytes, bytearray)) else str(jid)
150
+ key = self._job_key(job_id)
151
+ data = cast("dict[Any, Any]", self._r.hgetall(key))
152
+ if not data:
153
+ # corrupted entry; ack and skip
154
+ self._r.lrem(self._k("processing"), 1, job_id)
155
+ self._r.zrem(self._k("processing_vt"), job_id)
156
+ return None
157
+
158
+ # Decode fields
159
+ def _get(field: str, default: str | None = None) -> str | None:
160
+ val = (
161
+ data.get(field.encode())
162
+ if isinstance(next(iter(data.keys())), bytes)
163
+ else data.get(field)
164
+ )
165
+ if val is None:
166
+ return default
167
+ return val.decode() if isinstance(val, (bytes, bytearray)) else str(val)
168
+
169
+ attempts = int(_get("attempts", "0") or "0") + 1
170
+ max_attempts = int(_get("max_attempts", "5") or "5")
171
+ backoff_seconds = int(_get("backoff_seconds", "60") or "60")
172
+ name = _get("name", "") or ""
173
+ payload_json = _get("payload", "{}") or "{}"
174
+ try:
175
+ payload = json.loads(payload_json)
176
+ except Exception: # pragma: no cover
177
+ payload = {}
178
+ available_at_str = _get("available_at")
179
+ available_at = (
180
+ datetime.fromisoformat(available_at_str) if available_at_str else datetime.now(UTC)
181
+ )
182
+ # If exceeded max_attempts → DLQ and skip
183
+ if attempts > max_attempts:
184
+ self._r.lrem(self._k("processing"), 1, job_id)
185
+ self._r.zrem(self._k("processing_vt"), job_id)
186
+ self._r.lpush(self._k("dlq"), job_id)
187
+ return None
188
+ # Update attempts count in job hash (visibility timeout already set atomically in Lua script)
189
+ self._r.hset(key, mapping={"attempts": attempts, "visible_at": visible_at})
190
+ return Job(
191
+ id=job_id,
192
+ name=name,
193
+ payload=payload,
194
+ available_at=available_at,
195
+ attempts=attempts,
196
+ max_attempts=max_attempts,
197
+ backoff_seconds=backoff_seconds,
198
+ )
199
+
200
+ def ack(self, job_id: str) -> None:
201
+ self._r.lrem(self._k("processing"), 1, job_id)
202
+ self._r.zrem(self._k("processing_vt"), job_id)
203
+ self._r.delete(self._job_key(job_id))
204
+
205
+ def fail(self, job_id: str, *, error: str | None = None) -> None:
206
+ key = self._job_key(job_id)
207
+ data = cast("dict[Any, Any]", self._r.hgetall(key))
208
+ if not data:
209
+ # nothing to do
210
+ self._r.lrem(self._k("processing"), 1, job_id)
211
+ return
212
+
213
+ def _get(field: str, default: str | None = None) -> str | None:
214
+ val = (
215
+ data.get(field.encode())
216
+ if isinstance(next(iter(data.keys())), bytes)
217
+ else data.get(field)
218
+ )
219
+ if val is None:
220
+ return default
221
+ return val.decode() if isinstance(val, (bytes, bytearray)) else str(val)
222
+
223
+ attempts = int(_get("attempts", "0") or "0")
224
+ max_attempts = int(_get("max_attempts", "5") or "5")
225
+ backoff_seconds = int(_get("backoff_seconds", "60") or "60")
226
+ now_ts = int(datetime.now(UTC).timestamp())
227
+ # DLQ if at or beyond max_attempts
228
+ if attempts >= max_attempts:
229
+ self._r.lrem(self._k("processing"), 1, job_id)
230
+ self._r.zrem(self._k("processing_vt"), job_id)
231
+ self._r.lpush(self._k("dlq"), job_id)
232
+ return
233
+ delay = backoff_seconds * max(1, attempts)
234
+ available_at_ts = now_ts + delay
235
+ mapping: dict[str, str] = {
236
+ "last_error": error or "",
237
+ "available_at": datetime.fromtimestamp(available_at_ts, tz=UTC).isoformat(),
238
+ }
239
+ self._r.hset(key, mapping=mapping)
240
+ self._r.lrem(self._k("processing"), 1, job_id)
241
+ self._r.zrem(self._k("processing_vt"), job_id)
242
+ self._r.zadd(self._k("delayed"), {job_id: available_at_ts})
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from .queue import JobQueue
8
+
9
+ ProcessFunc = Callable[[object], Awaitable[None]]
10
+
11
+
12
+ class WorkerRunner:
13
+ """Cooperative worker loop with graceful stop.
14
+
15
+ - start(): begin polling the queue and processing jobs
16
+ - stop(grace_seconds): signal stop, wait up to grace for current job to finish
17
+ """
18
+
19
+ def __init__(self, queue: JobQueue, handler: ProcessFunc, *, poll_interval: float = 0.25):
20
+ self._queue = queue
21
+ self._handler = handler
22
+ self._poll_interval = poll_interval
23
+ self._task: asyncio.Task | None = None
24
+ self._stopping = asyncio.Event()
25
+ self._inflight: asyncio.Task | None = None
26
+
27
+ async def _loop(self) -> None:
28
+ try:
29
+ while not self._stopping.is_set():
30
+ job = self._queue.reserve_next()
31
+ if not job:
32
+ await asyncio.sleep(self._poll_interval)
33
+ continue
34
+
35
+ # Process one job; track in-flight task for stop()
36
+ async def _run():
37
+ try:
38
+ await self._handler(job)
39
+ except Exception as exc: # pragma: no cover
40
+ self._queue.fail(job.id, error=str(exc))
41
+ return
42
+ self._queue.ack(job.id)
43
+
44
+ self._inflight = asyncio.create_task(_run())
45
+ try:
46
+ await self._inflight
47
+ finally:
48
+ self._inflight = None
49
+ finally:
50
+ # exiting loop
51
+ pass
52
+
53
+ def start(self) -> asyncio.Task:
54
+ if self._task is None or self._task.done():
55
+ self._task = asyncio.create_task(self._loop())
56
+ return self._task
57
+
58
+ async def stop(self, *, grace_seconds: float = 10.0) -> None:
59
+ self._stopping.set()
60
+ # Wait for in-flight job to complete, up to grace
61
+ if self._inflight is not None and not self._inflight.done():
62
+ try:
63
+ await asyncio.wait_for(self._inflight, timeout=grace_seconds)
64
+ except TimeoutError:
65
+ # Give up; job will be retried if your queue supports visibility timeouts
66
+ pass
67
+ # Finally, wait for loop to exit (should be quick since stopping is set)
68
+ if self._task is not None:
69
+ try:
70
+ await asyncio.wait_for(self._task, timeout=max(0.1, self._poll_interval + 0.1))
71
+ except TimeoutError:
72
+ # Cancel as a last resort
73
+ self._task.cancel()
74
+ with contextlib.suppress(Exception):
75
+ await self._task
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ CronFunc = Callable[[], Awaitable[None]]
9
+
10
+
11
+ @dataclass
12
+ class ScheduledTask:
13
+ name: str
14
+ interval_seconds: int
15
+ func: CronFunc
16
+ next_run_at: datetime
17
+
18
+
19
+ class InMemoryScheduler:
20
+ """Interval-based scheduler for simple periodic tasks (tests/local).
21
+
22
+ Not a full cron parser. Tracks next_run_at per task.
23
+ """
24
+
25
+ def __init__(self, tick_interval: float = 60.0):
26
+ self._tasks: dict[str, ScheduledTask] = {}
27
+ self._tick_interval = tick_interval
28
+
29
+ def add_task(self, name: str, interval_seconds: int, func: CronFunc) -> None:
30
+ now = datetime.now(UTC)
31
+ self._tasks[name] = ScheduledTask(
32
+ name=name,
33
+ interval_seconds=interval_seconds,
34
+ func=func,
35
+ next_run_at=now + timedelta(seconds=interval_seconds),
36
+ )
37
+
38
+ async def tick(self) -> None:
39
+ now = datetime.now(UTC)
40
+ for task in self._tasks.values():
41
+ if task.next_run_at <= now:
42
+ await task.func()
43
+ task.next_run_at = now + timedelta(seconds=task.interval_seconds)
44
+
45
+ async def run(self) -> None:
46
+ """Run the scheduler loop indefinitely.
47
+
48
+ Calls tick() at regular intervals to check and execute due tasks.
49
+ This method runs forever until cancelled.
50
+ """
51
+ while True:
52
+ await self.tick()
53
+ await asyncio.sleep(self._tick_interval)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from .queue import Job, JobQueue
8
+
9
+ ProcessFunc = Callable[[Job], Awaitable[None]]
10
+
11
+
12
+ def _get_job_timeout_seconds() -> float | None:
13
+ raw = os.getenv("JOB_DEFAULT_TIMEOUT_SECONDS")
14
+ if not raw:
15
+ return None
16
+ try:
17
+ return float(raw)
18
+ except ValueError:
19
+ return None
20
+
21
+
22
+ async def process_one(queue: JobQueue, handler: ProcessFunc) -> bool:
23
+ """Reserve a job, process with handler, ack on success or fail with backoff.
24
+
25
+ Returns True if a job was processed (success or fail), False if no job was available.
26
+ """
27
+ job = queue.reserve_next()
28
+ if not job:
29
+ return False
30
+ try:
31
+ timeout = _get_job_timeout_seconds()
32
+ if timeout and timeout > 0:
33
+ await asyncio.wait_for(handler(job), timeout=timeout)
34
+ else:
35
+ await handler(job)
36
+ except Exception as exc: # pragma: no cover - exercise in tests by raising
37
+ queue.fail(job.id, error=str(exc))
38
+ return True
39
+ queue.ack(job.id)
40
+ return True
@@ -0,0 +1,186 @@
1
+ """
2
+ Content loaders for fetching from remote and local sources.
3
+
4
+ This module provides async-first loaders for GitHub, URLs, and other sources.
5
+ All loaders return a consistent `LoadedContent` format that is compatible
6
+ with ai-infra's Retriever.add_text() method.
7
+
8
+ Quick Start:
9
+ >>> from svc_infra.loaders import GitHubLoader, URLLoader
10
+ >>>
11
+ >>> # Load from GitHub
12
+ >>> loader = GitHubLoader("nfraxlab/svc-infra", path="docs")
13
+ >>> contents = await loader.load()
14
+ >>>
15
+ >>> # Load from URL
16
+ >>> loader = URLLoader("https://example.com/guide.md")
17
+ >>> contents = await loader.load()
18
+ >>>
19
+ >>> # Sync usage (for scripts/notebooks)
20
+ >>> contents = loader.load_sync()
21
+
22
+ With ai-infra Retriever:
23
+ >>> from ai_infra import Retriever
24
+ >>> from svc_infra.loaders import GitHubLoader
25
+ >>>
26
+ >>> retriever = Retriever()
27
+ >>> loader = GitHubLoader("nfraxlab/svc-infra", path="docs")
28
+ >>>
29
+ >>> for content in await loader.load():
30
+ ... retriever.add_text(content.content, metadata=content.metadata)
31
+
32
+ Convenience Functions:
33
+ >>> from svc_infra.loaders import load_github, load_url
34
+ >>>
35
+ >>> # One-liner loading
36
+ >>> contents = await load_github("nfraxlab/svc-infra", path="docs")
37
+ >>> contents = await load_url("https://example.com/guide.md")
38
+
39
+ Available Loaders:
40
+ - GitHubLoader: Load files from GitHub repositories
41
+ - URLLoader: Load content from URLs (with HTML text extraction)
42
+
43
+ Future Loaders (planned):
44
+ - S3Loader: Load files from S3-compatible storage
45
+ - NotionLoader: Load pages from Notion
46
+ - ConfluenceLoader: Load pages from Confluence
47
+ """
48
+
49
+ from .base import BaseLoader
50
+ from .github import GitHubLoader
51
+ from .models import LoadedContent, LoadedDocument, to_loaded_documents
52
+ from .url import URLLoader
53
+
54
+
55
+ async def load_github(
56
+ repo: str,
57
+ path: str = "",
58
+ branch: str = "main",
59
+ pattern: str = "*.md",
60
+ **kwargs,
61
+ ) -> list[LoadedContent]:
62
+ """Convenience function to load content from GitHub.
63
+
64
+ This is a shortcut for creating a GitHubLoader and calling load().
65
+
66
+ Args:
67
+ repo: Repository in "owner/repo" format
68
+ path: Path within repo (empty for root)
69
+ branch: Branch name (default: "main")
70
+ pattern: Glob pattern for files (default: "*.md")
71
+ **kwargs: Additional arguments passed to GitHubLoader
72
+
73
+ Returns:
74
+ List of LoadedContent objects.
75
+
76
+ Example:
77
+ >>> contents = await load_github("nfraxlab/svc-infra", path="docs")
78
+ >>> for c in contents:
79
+ ... print(f"{c.source}: {len(c.content)} chars")
80
+ """
81
+ loader = GitHubLoader(repo, path=path, branch=branch, pattern=pattern, **kwargs)
82
+ return await loader.load()
83
+
84
+
85
+ async def load_url(
86
+ urls: str | list[str],
87
+ **kwargs,
88
+ ) -> list[LoadedContent]:
89
+ """Convenience function to load content from URL(s).
90
+
91
+ This is a shortcut for creating a URLLoader and calling load().
92
+
93
+ Args:
94
+ urls: Single URL or list of URLs to load
95
+ **kwargs: Additional arguments passed to URLLoader
96
+
97
+ Returns:
98
+ List of LoadedContent objects.
99
+
100
+ Example:
101
+ >>> # Single URL
102
+ >>> contents = await load_url("https://example.com/guide.md")
103
+ >>>
104
+ >>> # Multiple URLs
105
+ >>> contents = await load_url([
106
+ ... "https://example.com/page1",
107
+ ... "https://example.com/page2",
108
+ ... ])
109
+ """
110
+ loader = URLLoader(urls, **kwargs)
111
+ return await loader.load()
112
+
113
+
114
+ def load_github_sync(
115
+ repo: str,
116
+ path: str = "",
117
+ branch: str = "main",
118
+ pattern: str = "*.md",
119
+ **kwargs,
120
+ ) -> list[LoadedContent]:
121
+ """Synchronous convenience function to load content from GitHub.
122
+
123
+ This is a shortcut for creating a GitHubLoader and calling load_sync().
124
+ Use this in scripts, notebooks, or non-async contexts.
125
+
126
+ Args:
127
+ repo: Repository in "owner/repo" format
128
+ path: Path within repo (empty for root)
129
+ branch: Branch name (default: "main")
130
+ pattern: Glob pattern for files (default: "*.md")
131
+ **kwargs: Additional arguments passed to GitHubLoader
132
+
133
+ Returns:
134
+ List of LoadedContent objects.
135
+
136
+ Example:
137
+ >>> # In a script or notebook (no await needed)
138
+ >>> contents = load_github_sync("nfraxlab/svc-infra", path="docs")
139
+ >>> for c in contents:
140
+ ... print(f"{c.source}: {len(c.content)} chars")
141
+ """
142
+ loader = GitHubLoader(repo, path=path, branch=branch, pattern=pattern, **kwargs)
143
+ return loader.load_sync()
144
+
145
+
146
+ def load_url_sync(
147
+ urls: str | list[str],
148
+ **kwargs,
149
+ ) -> list[LoadedContent]:
150
+ """Synchronous convenience function to load content from URL(s).
151
+
152
+ This is a shortcut for creating a URLLoader and calling load_sync().
153
+ Use this in scripts, notebooks, or non-async contexts.
154
+
155
+ Args:
156
+ urls: Single URL or list of URLs to load
157
+ **kwargs: Additional arguments passed to URLLoader
158
+
159
+ Returns:
160
+ List of LoadedContent objects.
161
+
162
+ Example:
163
+ >>> # In a script or notebook (no await needed)
164
+ >>> contents = load_url_sync("https://example.com/guide.md")
165
+ """
166
+ loader = URLLoader(urls, **kwargs)
167
+ return loader.load_sync()
168
+
169
+
170
+ __all__ = [
171
+ # Base classes
172
+ "BaseLoader",
173
+ "LoadedContent",
174
+ # Compatibility
175
+ "LoadedDocument",
176
+ "to_loaded_documents",
177
+ # Loaders
178
+ "GitHubLoader",
179
+ "URLLoader",
180
+ # Async convenience functions
181
+ "load_github",
182
+ "load_url",
183
+ # Sync convenience functions
184
+ "load_github_sync",
185
+ "load_url_sync",
186
+ ]