redis_queued_locks 1.23.0 → 1.23.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c06bae2bc7f0b6164847caa6b7adb5c77a7aab5a85dc5f3ef8c45ac19b8ed80
4
- data.tar.gz: 6a73267ecd535b74c98f489066ee5cc6f2a8c2ea261eab6d5b5f6b84e4748585
3
+ metadata.gz: ad3786d20407687dc7cfdb06956fecec429c199c4fb50d984af30afa15899352
4
+ data.tar.gz: cd71e3b222e1267ddbb29b34c2f2de66be834d9100a97d86b3cb3e3fc952f87b
5
5
  SHA512:
6
- metadata.gz: b3f34d6976337bcdfe4732b3c58034ed455922450f98758a0f4c90e36a885721d847e648c5dfa64357aac9b1b401138787649bda0491693777581669f932a187
7
- data.tar.gz: 7d4e8769653ddfaf0f483a413c08ba10ea15d4f4d324e57dee1009e27222f7c2473789b0221380fc68a0047a5383083761963ed541179f7ab633876f68b3e891
6
+ metadata.gz: 44dff1c6e0b2321b75cea2efea7094ffa47c88e8a0e8095e49d030f823048ff0834916030feba524ca354f3559ef97afc5d803c3fe41b88017ff6751bf9c479d
7
+ data.tar.gz: 73ad1a2679f8c84b5672e95036c06831a415c02878683ac886ac752552352c2d7d1d444fc34e6daf6f0eeef4e21bce02d925b5c4821cdb7df0cae834e53de022
@@ -174,7 +174,7 @@ AcquireLock Step 0.1 / LockSeries Step 0.1 name -> Integer, range -
174
174
  | `WithAcqTimeout` | Global acquisition timeout (`timeout`). |
175
175
  | `DelayExecution` | Retry delay + jitter between attempts. |
176
176
  | `DequeueFromLockQueue` | Removes the acquirer from the queue of the requested mode on timeout/failure. |
177
- | `YieldExpire` | Runs the user block (optionally `timed`), then releases/expires the lock of the held mode (write: `EXPIRE 0`; read: `ZREM` own reader + `DEL` own read lock data; extendable reentrant: decrease). Known limitation (kept by the user's decision, documented in README): the write release and the write ttl decrease have no owner check, so a block longer than the ttl can delete/shorten the lock of another acquirer. |
177
+ | `YieldExpire` | Runs the user block (optionally `timed`: budget = `ttl` - time from the start of the successful attempt - `REDIS_TIMESHIFT_ERROR`, always within the lock lifetime; no budget left => the block is not invoked, `LockTtlTimeBudgetIsSpentError`; the budget exceeded => `TimedBlockExceedsLockTtlError`; `Timeout.timeout(0)` means no timeout; a long `ensure` of the block, `rescue Exception` and non-interruptible code can still outlive the lock), then releases/expires the lock of the held mode (write: `EXPIRE 0`; read: `ZREM` own reader + `DEL` own read lock data; extendable reentrant: decrease). Known limitation (kept by the user's decision, documented in README): the write release and the write ttl decrease have no owner check, so a block longer than the ttl can delete/shorten the lock of another acquirer. |
178
178
  | `LogVisitor` / `InstrVisitor` | One method per lifecycle event for logs and instrumentation. |
179
179
 
180
180
  ### Lock series (`acquirer/lock_series.rb`)
@@ -483,7 +483,7 @@ log event: `dead_request_removed` (`dead_acq_id`, `dead_priority`: a dead blocki
483
483
 
484
484
  Global base errors per branch (`logic.md#15`): `RedisQueuedLocks::Error < StandardError` (the main
485
485
  hierarchy: `LockAlreadyObtainedError`, `LockAcquirementTimeoutError`, `LockAcquirementRetryLimitError`,
486
- `TimedLockTimeoutError`, `ConflictLockObtainError`, `SwarmError`, `ConfigError` (`ConfigNotFoundError`,
486
+ `TimedLockTimeoutError` (`LockTtlTimeBudgetIsSpentError`, `TimedBlockExceedsLockTtlError`), `ConflictLockObtainError`, `SwarmError`, `ConfigError` (`ConfigNotFoundError`,
487
487
  `ConfigValidationError`)), `RedisQueuedLocks::ArgumentError < ::ArgumentError` (`SwarmArgumentError`),
488
488
  `RedisQueuedLocks::Exception < ::Exception` (`@api private`, super exceptional cases only: the internal
489
489
  `*IntermediateTimeoutError`s of `Timeout.timeout` for `timeout` and `timed`; like `Timeout::ExitException`,
@@ -65,6 +65,7 @@ paths:
65
65
  - `TryToLock` attempt (both modes): `multi(watch:)` (write: `[lock_key, lock_readers_key]`, read: `[lock_key]`) → one pipelined state read (`HGET acq_id`, `TIME`, own reader score, longest reader: write requests only, the write-only tail of the pipeline) → same-process conflict detection (`sp_conflict_rw_mode` = held `:write`/`:read`) → the lock request queueing in one pipelined round trip (`queue_lock_request`: prune dead requests of the request's priority band in **both** queues (before `ZADD NX`: the old dead position of a requeued request is dropped before its new position is added; the reverse order keeps the old position with `NX` and drops it: the request is missing in the queue for the attempt) → `ZADD NX` into the queue of the requested mode (the position is shifted by the priority band) → queue heads of both queues (`WITHSCORES`) → (the enabled lock index only) request queue `PEXPIRE NX`+`GT` → lock index `ZADD GT` → lock index pruning (5 replies, 9 with the lock index; new commands go to the end, replies after the queue heads are not read); eager queueing (`:wait_for_lock`/`:dead_locking` without `fail_fast`) sends these commands in the state pipeline right after its head, so `lock {}` = 4 round trips; the queueing log events are still emitted in the classic branch in the same order) → Step 2.2 (priorities, `:queued`, also in the pre-check, before the obstacle decision): the dead blocking requests of other bands (the write queue head for writers and the earlier opposite-mode request, decoded from their scores by `dead_request_priority`) are dropped with their bands in both queues and both heads are re-read in one more round trip (`drop_dead_requests_ahead`: one or two bands per round trip, the heads are read from the end of the pipeline, up to `MAX_DEAD_REQUEST_REMOVALS` round trips, `try_lock.dead_request_removed` log per dropped request, written by the caller (`log_dropped_requests`) after the queueing events; no round trip while the blocking requests are alive; a live write queue head stops the removal) (`:queued`: write must be first in its queue; any mode waits for an earlier `(score, acq_id)` request of the opposite mode) → lock state (write held → `:lock_is_still_acquired`; live readers for write / own live read for read → `:read_lock_is_still_acquired`) → MULTI (write: `ZREM` + `HSET` + `PEXPIRE` (+ lock index `ZADD GT`); read: `ZREM` + prune registry + `ZADD expiry` + registry `PEXPIRE NX`+`GT` + reader data `DEL`+`HSET`+`PEXPIRE` (+ lock index `ZADD GT`)). With the enabled lock index the `:extendable_work_through` extension `MULTI` ends with the lock index `ZADD XX INCR` (write: 4 replies, 5 with the lock index: `result.size == (lock_index_enabled ? 5 : 4)`).
66
66
  - The try result carries internal `rw_mode` (the **held** lock mode); `acquire_lock` stores it as `acq_process[:held_rw_mode]` and passes it to `yield_expire`, which releases/decreases the held lock. The public `lock` result shape does not include it.
67
67
  - Read lock data (`rql:lock_reader:<name>:<acq_id>`) uses the write-lock field format without `acq_id`/`hst_id` (`ts`, `ini_ttl`, meta, `spc_*`: both ids are derived from the acquirer id, `LockInfo.read_lock_info`/`Locks.read_lock_info` put them first in each reader and still accept older data that contains them) and the read lock TTL; it is a data carrier only (writers never read it). Its key (`read_lock_key`) is built only for read lock requests (`nil` for write requests: they never hold read locks).
68
+ - `timed` budget (`AcquireLock`, `LockSeries`): `ttl - ttl_shift`, `ttl_shift` = time from `acq_process[:try_start_time]` (the start of the successful attempt: the ttl starts later: write: `EXEC`, read: `TIME` of the lock state read) + `REDIS_TIMESHIFT_ERROR` (a margin: it always shortens the budget); no budget left (`0.0`) => `yield_with_timeout` does not invoke the block (`Timeout.timeout(0)` means no timeout): `LockTtlTimeBudgetIsSpentError`; a block that exceeds the budget: `TimedBlockExceedsLockTtlError` (both are `TimedLockTimeoutError` subclasses).
68
69
  - Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
69
70
  - **LockSeries** (`lock_series.rb`, `lock_series/try_to_lock_series.rb`, `lock_series/release_lock_series.rb`; overview "Lock series"):
70
71
  - The attempt is the single lock attempt for N locks, built from the shared `TryToLock` helpers: lock state and queueing commands (`pipeline_lock_state`, `queue_lock_request`), decisions (`same_process_conflict_status`, `find_opposite_request_ahead`, `lock_obstacle`, `find_dead_requests_ahead`, `pipeline_dead_requests_ahead_removal`), obtaining and reentrant commands (`transact_*`). A change of these helpers applies to both attempts; a change of the single lock attempt flow is checked against `try_to_lock_series`/`precheck_lock_series` (two copies of the flow, not of the commands).
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.23.1] = 2026-11-01
4
+ ### Added
5
+ - `timed: true` of `#lock`/`#lock_series`: separate errors of the failures, both are `TimedLockTimeoutError` subclasses: `LockTtlTimeBudgetIsSpentError` (the block was not invoked: no time left) and `TimedBlockExceedsLockTtlError` (the block exceeded the lock TTL);
6
+ ### Fixed
7
+ - `timed: true` of `#lock`/`#lock_series`: in some cases the time budget of the block could have been a little bit longer than the lock lifetime (2ms);
8
+
3
9
  ## [1.23.0] - 2026-11-01
4
10
  ### Changed
5
11
  - [**Breaking**] `#lock_series`/`#lock_series!` are no longer a proof of concept. The final feature design:
data/README.md CHANGED
@@ -589,6 +589,11 @@ def lock(
589
589
  - the block is interrupted by an internal `RedisQueuedLocks::Exception` (an `::Exception`, like `Timeout::ExitException` of Ruby's `Timeout`): `rescue => e`,
590
590
  `rescue StandardError`, `rescue Timeout::Error` and `rescue nil` inside the block can not swallow it (only `rescue Exception`),
591
591
  `ensure` of the block still runs;
592
+ - the block gets the lock's TTL minus the time from the start of the successful acquirement attempt and minus a 2 ms margin;
593
+ with no time left the block is not invoked (`RedisQueuedLocks::LockTtlTimeBudgetIsSpentError`);
594
+ - both failures have the common superclass `RedisQueuedLocks::TimedLockTimeoutError`: `RedisQueuedLocks::TimedBlockExceedsLockTtlError` (the block exceeded the time) and `RedisQueuedLocks::LockTtlTimeBudgetIsSpentError` (the block was not invoked);
595
+ - (limitation) `timed` can only interrupt the block: a long `ensure` of the block, `rescue Exception` in the block and the code that
596
+ can not be interrupted (a C extension holding the GVL) can outlive the lock;
592
597
  - pre-configured in `config['is_timed_by_default']`;
593
598
  - `false` by default;
594
599
  - `retry_count` - (optional) `[Integer,NilClass]`
@@ -837,7 +842,7 @@ rql.lock("my_lock", ttl: 5_000, timed: true) { sleep(4) }
837
842
  # => OK
838
843
 
839
844
  rql.lock("my_lock", ttl: 5_000, timed: true) { sleep(6) }
840
- # => fails with RedisQueuedLocks::TimedLockTimeoutError
845
+ # => fails with RedisQueuedLocks::TimedBlockExceedsLockTtlError (a RedisQueuedLocks::TimedLockTimeoutError)
841
846
  ```
842
847
 
843
848
  - infinite lock obtaining (no retry limit, no timeout limit):
@@ -3528,6 +3533,12 @@ Detalized event semantics and payload structure:
3528
3533
  <sup>\[[back to top](#table-of-contents)\]</sup>
3529
3534
 
3530
3535
  - **Major**:
3536
+ - **Experimental feature**: (non-`timed` locks): per-ruby-block-holding-the-lock sidecar `Ractor` and `in progress queue` in RedisDB that will extend
3537
+ the acquired lock for long-running blocks of code (that invoked "under" the lock
3538
+ whose ttl may expire before the block execution completes). It makes sense for non-`timed` locks *only*;
3539
+ - think about "thread priority" configuration :thinking:;
3540
+ - add `hst_id` to all methods that works with queues info;
3541
+ - try to return the `fiber object id` to the lock host identifier (we cant use fiber object id cuz `ObjectSpace` has no access to the fiber object space after the any ractor object initialization)
3531
3542
  - Swarm:
3532
3543
  - circuit-breaker for long-living failures of your infrastructure inside the swarm elements and supervisor:
3533
3544
  the supervisor will stop (for some period of time or while the some factor will return `true`)
@@ -3553,9 +3564,6 @@ Detalized event semantics and payload structure:
3553
3564
  - an ability to return all insturmentation metrics from the `lock` invocation (and after block `yield`ing);
3554
3565
  - think about a PG-like `stats`-data/table, that should be helpful for lock history acquirement analyzation :thinking: (suitable for cases when we want
3555
3566
  to check if the lock was acquired by some different parallel process durign the some period of time in the past or not);
3556
- - think about "thread priority" configuration :thinking:;
3557
- - add `hst_id` to all methods that works with queues info;
3558
- - try to return the `fiber object id` to the lock host identifier (we cant use fiber object id cuz `ObjectSpace` has no access to the fiber object space after the any ractor object initialization)
3559
3567
  - named RQL's threads (`Thread#name`) and RQL's ractors (`Ractor#name`) in order to have an ability to find and work with RQL's threads and ractors outside of RQL logic (stop threads before process forking, for example);
3560
3568
  - `#lock`/`#lock!` - `timeout:` option: support for granular periods (it supports only `seconds` at the moment, but we need `milliseconds`);
3561
3569
  - Add `after timeout hook` ability for timeout errors with an ability to access the binding context (if it possibly) of the thread where it failed;
@@ -3567,9 +3575,6 @@ Detalized event semantics and payload structure:
3567
3575
  - support for `Dragonfly` database backend (https://github.com/dragonflydb/dragonfly) (https://www.dragonflydb.io/);
3568
3576
  - (_research_) GVL-isolation for lock acquirement logic (try to bypass Ruby's "context-switching" in order to prevent any time-based affects on lock-acquirement logic);
3569
3577
  - Semantic error objects for unexpected Redis errors;
3570
- - **Experimental feature**: (non-`timed` locks): per-ruby-block-holding-the-lock sidecar `Ractor` and `in progress queue` in RedisDB that will extend
3571
- the acquired lock for long-running blocks of code (that invoked "under" the lock
3572
- whose ttl may expire before the block execution completes). It makes sense for non-`timed` locks *only*;
3573
3578
  - sized lock queues (with an ability of dynamically growing size);
3574
3579
  - better code stylization (+ some refactorings);
3575
3580
  - `RedisQueuedLocks::Acquirer::Try.try_to_lock` - detailed successful result analization;
@@ -3,6 +3,7 @@
3
3
  # @api private
4
4
  # @since 1.3.0
5
5
  # @version 1.18.0
6
+ # rubocop:disable Metrics/ModuleLength
6
7
  module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
7
8
  require_relative 'yield_expire/log_visitor'
8
9
 
@@ -158,21 +159,37 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
158
159
  # @return [Any]
159
160
  #
160
161
  # @raise [RedisQueuedLocks::TimedLockTimeoutError]
162
+ # @raise [RedisQueuedLocks::LockTtlTimeBudgetIsSpentError]
163
+ # @raise [RedisQueuedLocks::TimedBlockExceedsLockTtlError]
161
164
  #
162
165
  # @api private
163
166
  # @since 1.3.0
164
- # @version 1.21.0
167
+ # @version 1.23.1
165
168
  def yield_with_timeout(timeout, lock_key, lock_ttl, acquirer_id, host_id, meta, &block)
166
- ::Timeout.timeout(timeout, RedisQueuedLocks::TimedLockIntermediateTimeoutError, &block)
167
- rescue RedisQueuedLocks::TimedLockIntermediateTimeoutError
168
- raise(
169
- RedisQueuedLocks::TimedLockTimeoutError,
170
- "Passed <timed> block of code exceeded the lock TTL " \
171
- "(lock: \"#{lock_key}\", " \
172
- "ttl: #{lock_ttl}, " \
173
- "meta: #{meta ? meta.inspect : '<no-meta>'}, " \
174
- "acq_id: \"#{acquirer_id}\", " \
175
- "hst_id: \"#{host_id}\")"
176
- )
169
+ # NOTE: no budget left => the block is not invoked (`Timeout.timeout(0)` means "no timeout")
170
+ if timeout == 0.0
171
+ raise(
172
+ RedisQueuedLocks::LockTtlTimeBudgetIsSpentError,
173
+ "Passed <timed> block of code was not invoked: the time budget of the lock TTL is spent " \
174
+ "(lock: \"#{lock_key}\", ttl: #{lock_ttl}, " \
175
+ "meta: #{meta ? meta.inspect : '<no-meta>'}, " \
176
+ "acq_id: \"#{acquirer_id}\", " \
177
+ "hst_id: \"#{host_id}\")"
178
+ )
179
+ else
180
+ begin
181
+ ::Timeout.timeout(timeout, RedisQueuedLocks::TimedLockIntermediateTimeoutError, &block)
182
+ rescue RedisQueuedLocks::TimedLockIntermediateTimeoutError
183
+ raise(
184
+ RedisQueuedLocks::TimedBlockExceedsLockTtlError,
185
+ "Passed <timed> block of code exceeded the lock TTL " \
186
+ "(lock: \"#{lock_key}\", ttl: #{lock_ttl}, " \
187
+ "meta: #{meta ? meta.inspect : '<no-meta>'}, " \
188
+ "acq_id: \"#{acquirer_id}\", " \
189
+ "hst_id: \"#{host_id}\")"
190
+ )
191
+ end
192
+ end
177
193
  end
178
194
  end
195
+ # rubocop:enable Metrics/ModuleLength
@@ -193,7 +193,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
193
193
  #
194
194
  # @api private
195
195
  # @since 1.0.0
196
- # @version 1.23.0
196
+ # @version 1.23.1
197
197
  def acquire_lock(
198
198
  redis,
199
199
  lock_name,
@@ -412,6 +412,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
412
412
  tries: 0,
413
413
  acquired: false,
414
414
  result: nil,
415
+ try_start_time: nil,
415
416
  acq_time: nil, # NOTE: in milliseconds
416
417
  hold_time: nil, # NOTE: in milliseconds
417
418
  rel_time: nil # NOTE: in milliseconds
@@ -472,6 +473,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock
472
473
  )
473
474
  end
474
475
 
476
+ acq_process[:try_start_time] = clock_gettime
477
+
475
478
  # NOTE: (steep ignorance) pattern matching is not supported in steep
476
479
  # steep:ignore:start
477
480
  try_to_lock(
@@ -662,8 +665,14 @@ module RedisQueuedLocks::Acquirer::AcquireLock
662
665
  begin
663
666
  yield_time = clock_gettime
664
667
 
668
+ # NOTE: the budget of a `timed` block (see `Resource::REDIS_TIMESHIFT_ERROR`):
669
+ # - the lock ttl starts inside the attempt (write: `EXEC` of the transaction,
670
+ # read: `TIME` of the lock state read), this moment is unknown on the client:
671
+ # the time is counted from the start of the successful attempt (it is
672
+ # earlier, the budget can only be shorter than the real lifetime of the lock);
673
+ # - the Redis time error is a margin: it decreases the budget;
665
674
  ttl_shift = (
666
- (yield_time - acq_process[:acq_end_time]) / 1_000.0 -
675
+ (yield_time - acq_process[:try_start_time]) / 1_000.0 +
667
676
  RedisQueuedLocks::Resource::REDIS_TIMESHIFT_ERROR
668
677
  ).ceil(2)
669
678
 
@@ -126,7 +126,7 @@ module RedisQueuedLocks::Acquirer::LockSeries
126
126
  #
127
127
  # @api private
128
128
  # @since 1.23.0
129
- # @version 1.23.0
129
+ # @version 1.23.1
130
130
  # rubocop:disable Metrics/MethodLength
131
131
  def lock_series(
132
132
  redis,
@@ -356,6 +356,7 @@ module RedisQueuedLocks::Acquirer::LockSeries
356
356
  ts: nil, # NOTE: see `try_to_lock_series`
357
357
  lock_processes: nil, # NOTE: see `try_to_lock_series`
358
358
  held_rw_modes: nil, # NOTE: see `try_to_lock_series`
359
+ try_start_time: nil,
359
360
  acq_time: nil, # NOTE: in milliseconds
360
361
  acq_end_time: nil,
361
362
  rel_ts: nil,
@@ -404,6 +405,8 @@ module RedisQueuedLocks::Acquirer::LockSeries
404
405
  )
405
406
  end
406
407
 
408
+ acq_process[:try_start_time] = clock_gettime
409
+
407
410
  attempt = try_to_lock_series(
408
411
  redis,
409
412
  logger,
@@ -536,8 +539,14 @@ module RedisQueuedLocks::Acquirer::LockSeries
536
539
  host_id, acq_process[:ts], acq_process[:acq_time], reentrant_lock_keys, instrument
537
540
  )
538
541
 
542
+ # NOTE: the budget of a `timed` block (see `Resource::REDIS_TIMESHIFT_ERROR`):
543
+ # - the lock ttl starts inside the attempt (write: `EXEC` of the transaction,
544
+ # read: `TIME` of the lock state read), this moment is unknown on the client:
545
+ # the time is counted from the start of the successful attempt (it is
546
+ # earlier, the budget can only be shorter than the real lifetime of the lock);
547
+ # - the Redis time error is a margin: it decreases the budget;
539
548
  ttl_shift = (
540
- (clock_gettime - acq_process[:acq_end_time]) / 1_000.0 -
549
+ (clock_gettime - acq_process[:try_start_time]) / 1_000.0 +
541
550
  RedisQueuedLocks::Resource::REDIS_TIMESHIFT_ERROR
542
551
  ).ceil(2)
543
552
 
@@ -67,6 +67,14 @@ module RedisQueuedLocks
67
67
  # @since 1.0.0
68
68
  class TimedLockTimeoutError < Error; end
69
69
 
70
+ # @api public
71
+ # @since 1.23.1
72
+ class LockTtlTimeBudgetIsSpentError < TimedLockTimeoutError; end
73
+
74
+ # @api public
75
+ # @since 1.23.1
76
+ class TimedBlockExceedsLockTtlError < TimedLockTimeoutError; end
77
+
70
78
  # @api public
71
79
  # @since 1.3.0
72
80
  class ConflictLockObtainError < Error; end
@@ -5,6 +5,6 @@ module RedisQueuedLocks
5
5
  #
6
6
  # @api public
7
7
  # @since 0.0.1
8
- # @version 1.23.0
9
- VERSION = '1.23.0'
8
+ # @version 1.23.1
9
+ VERSION = '1.23.1'
10
10
  end
@@ -26,6 +26,12 @@ module RedisQueuedLocks
26
26
  class TimedLockTimeoutError < Error
27
27
  end
28
28
 
29
+ class LockTtlTimeBudgetIsSpentError < TimedLockTimeoutError
30
+ end
31
+
32
+ class TimedBlockExceedsLockTtlError < TimedLockTimeoutError
33
+ end
34
+
29
35
  class ConflictLockObtainError < Error
30
36
  end
31
37
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis_queued_locks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.23.0
4
+ version: 1.23.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rustam Ibragimov