redislib.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. # SPDX-License-Identifier: AGPL-3.0-or-later
  2. # lint: pylint
  3. """A collection of convenient functions and redis/lua scripts.
  4. This code was partial inspired by the `Bullet-Proofing Lua Scripts in RedisPy`_
  5. article.
  6. .. _Bullet-Proofing Lua Scripts in RedisPy:
  7. https://redis.com/blog/bullet-proofing-lua-scripts-in-redispy/
  8. """
  9. import hmac
  10. from searx import get_setting
  11. LUA_SCRIPT_STORAGE = {}
  12. """A global dictionary to cache client's ``Script`` objects, used by
  13. :py:obj:`lua_script_storage`"""
  14. def lua_script_storage(client, script):
  15. """Returns a redis :py:obj:`Script
  16. <redis.commands.core.CoreCommands.register_script>` instance.
  17. Due to performance reason the ``Script`` object is instantiated only once
  18. for a client (``client.register_script(..)``) and is cached in
  19. :py:obj:`LUA_SCRIPT_STORAGE`.
  20. """
  21. # redis connection can be closed, lets use the id() of the redis connector
  22. # as key in the script-storage:
  23. client_id = id(client)
  24. if LUA_SCRIPT_STORAGE.get(client_id) is None:
  25. LUA_SCRIPT_STORAGE[client_id] = {}
  26. if LUA_SCRIPT_STORAGE[client_id].get(script) is None:
  27. LUA_SCRIPT_STORAGE[client_id][script] = client.register_script(script)
  28. return LUA_SCRIPT_STORAGE[client_id][script]
  29. PURGE_BY_PREFIX = """
  30. local prefix = tostring(ARGV[1])
  31. for i, name in ipairs(redis.call('KEYS', prefix .. '*')) do
  32. redis.call('EXPIRE', name, 0)
  33. end
  34. """
  35. def purge_by_prefix(client, prefix: str = "SearXNG_"):
  36. """Purge all keys with ``prefix`` from database.
  37. Queries all keys in the database by the given prefix and set expire time to
  38. zero. The default prefix will drop all keys which has been set by SearXNG
  39. (drops SearXNG schema entirely from database).
  40. The implementation is the lua script from string :py:obj:`PURGE_BY_PREFIX`.
  41. The lua script uses EXPIRE_ instead of DEL_: if there are a lot keys to
  42. delete and/or their values are big, `DEL` could take more time and blocks
  43. the command loop while `EXPIRE` turns back immediate.
  44. :param prefix: prefix of the key to delete (default: ``SearXNG_``)
  45. :type name: str
  46. .. _EXPIRE: https://redis.io/commands/expire/
  47. .. _DEL: https://redis.io/commands/del/
  48. """
  49. script = lua_script_storage(client, PURGE_BY_PREFIX)
  50. script(args=[prefix])
  51. def secret_hash(name: str):
  52. """Creates a hash of the ``name``.
  53. Combines argument ``name`` with the ``secret_key`` from :ref:`settings
  54. server`. This function can be used to get a more anonymised name of a Redis
  55. KEY.
  56. :param name: the name to create a secret hash for
  57. :type name: str
  58. """
  59. m = hmac.new(bytes(name, encoding='utf-8'), digestmod='sha256')
  60. m.update(bytes(get_setting('server.secret_key'), encoding='utf-8'))
  61. return m.hexdigest()
  62. INCR_COUNTER = """
  63. local limit = tonumber(ARGV[1])
  64. local expire = tonumber(ARGV[2])
  65. local c_name = KEYS[1]
  66. local c = redis.call('GET', c_name)
  67. if not c then
  68. c = redis.call('INCR', c_name)
  69. if expire > 0 then
  70. redis.call('EXPIRE', c_name, expire)
  71. end
  72. else
  73. c = tonumber(c)
  74. if limit == 0 or c < limit then
  75. c = redis.call('INCR', c_name)
  76. end
  77. end
  78. return c
  79. """
  80. def incr_counter(client, name: str, limit: int = 0, expire: int = 0):
  81. """Increment a counter and return the new value.
  82. If counter with redis key ``SearXNG_counter_<name>`` does not exists it is
  83. created with initial value 1 returned. The replacement ``<name>`` is a
  84. *secret hash* of the value from argument ``name`` (see
  85. :py:func:`secret_hash`).
  86. The implementation of the redis counter is the lua script from string
  87. :py:obj:`INCR_COUNTER`.
  88. :param name: name of the counter
  89. :type name: str
  90. :param expire: live-time of the counter in seconds (default ``None`` means
  91. infinite).
  92. :type expire: int / see EXPIRE_
  93. :param limit: limit where the counter stops to increment (default ``None``)
  94. :type limit: int / limit is 2^64 see INCR_
  95. :return: value of the incremented counter
  96. :type return: int
  97. .. _EXPIRE: https://redis.io/commands/expire/
  98. .. _INCR: https://redis.io/commands/incr/
  99. A simple demo of a counter with expire time and limit::
  100. >>> for i in range(6):
  101. ... i, incr_counter(client, "foo", 3, 5) # max 3, duration 5 sec
  102. ... time.sleep(1) # from the third call on max has been reached
  103. ...
  104. (0, 1)
  105. (1, 2)
  106. (2, 3)
  107. (3, 3)
  108. (4, 3)
  109. (5, 1)
  110. """
  111. script = lua_script_storage(client, INCR_COUNTER)
  112. name = "SearXNG_counter_" + secret_hash(name)
  113. c = script(args=[limit, expire], keys=[name])
  114. return c
  115. def drop_counter(client, name):
  116. """Drop counter with redis key ``SearXNG_counter_<name>``
  117. The replacement ``<name>`` is a *secret hash* of the value from argument
  118. ``name`` (see :py:func:`incr_counter` and :py:func:`incr_sliding_window`).
  119. """
  120. name = "SearXNG_counter_" + secret_hash(name)
  121. client.delete(name)
  122. INCR_SLIDING_WINDOW = """
  123. local expire = tonumber(ARGV[1])
  124. local name = KEYS[1]
  125. local current_time = redis.call('TIME')
  126. redis.call('ZREMRANGEBYSCORE', name, 0, current_time[1] - expire)
  127. redis.call('ZADD', name, current_time[1], current_time[1] .. current_time[2])
  128. local result = redis.call('ZCOUNT', name, 0, current_time[1] + 1)
  129. redis.call('EXPIRE', name, expire)
  130. return result
  131. """
  132. def incr_sliding_window(client, name: str, duration: int):
  133. """Increment a sliding-window counter and return the new value.
  134. If counter with redis key ``SearXNG_counter_<name>`` does not exists it is
  135. created with initial value 1 returned. The replacement ``<name>`` is a
  136. *secret hash* of the value from argument ``name`` (see
  137. :py:func:`secret_hash`).
  138. :param name: name of the counter
  139. :type name: str
  140. :param duration: live-time of the sliding window in seconds
  141. :typeduration: int
  142. :return: value of the incremented counter
  143. :type return: int
  144. The implementation of the redis counter is the lua script from string
  145. :py:obj:`INCR_SLIDING_WINDOW`. The lua script uses `sorted sets in Redis`_
  146. to implement a sliding window for the redis key ``SearXNG_counter_<name>``
  147. (ZADD_). The current TIME_ is used to score the items in the sorted set and
  148. the time window is moved by removing items with a score lower current time
  149. minus *duration* time (ZREMRANGEBYSCORE_).
  150. The EXPIRE_ time (the duration of the sliding window) is refreshed on each
  151. call (incrementation) and if there is no call in this duration, the sorted
  152. set expires from the redis DB.
  153. The return value is the amount of items in the sorted set (ZCOUNT_), what
  154. means the number of calls in the sliding window.
  155. .. _Sorted sets in Redis:
  156. https://redis.com/ebook/part-1-getting-started/chapter-1-getting-to-know-redis/1-2-what-redis-data-structures-look-like/1-2-5-sorted-sets-in-redis/
  157. .. _TIME: https://redis.io/commands/time/
  158. .. _ZADD: https://redis.io/commands/zadd/
  159. .. _EXPIRE: https://redis.io/commands/expire/
  160. .. _ZREMRANGEBYSCORE: https://redis.io/commands/zremrangebyscore/
  161. .. _ZCOUNT: https://redis.io/commands/zcount/
  162. A simple demo of the sliding window::
  163. >>> for i in range(5):
  164. ... incr_sliding_window(client, "foo", 3) # duration 3 sec
  165. ... time.sleep(1) # from the third call (second) on the window is moved
  166. ...
  167. 1
  168. 2
  169. 3
  170. 3
  171. 3
  172. >>> time.sleep(3) # wait until expire
  173. >>> incr_sliding_window(client, "foo", 3)
  174. 1
  175. """
  176. script = lua_script_storage(client, INCR_SLIDING_WINDOW)
  177. name = "SearXNG_counter_" + secret_hash(name)
  178. c = script(args=[duration], keys=[name])
  179. return c