AutoIncrementHandler.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl>
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\DB\QueryBuilder\Sharded;
  8. use OCP\ICacheFactory;
  9. use OCP\IMemcache;
  10. use OCP\IMemcacheTTL;
  11. /**
  12. * A helper to atomically determine the next auto increment value for a sharded table
  13. *
  14. * Since we can't use the database's auto-increment (since each db doesn't know about the keys in the other shards)
  15. * we need external logic for doing the auto increment
  16. */
  17. class AutoIncrementHandler {
  18. public const MIN_VALID_KEY = 1000;
  19. public const TTL = 365 * 24 * 60 * 60;
  20. private ?IMemcache $cache = null;
  21. public function __construct(
  22. private ICacheFactory $cacheFactory,
  23. private ShardConnectionManager $shardConnectionManager,
  24. ) {
  25. if (PHP_INT_SIZE < 8) {
  26. throw new \Exception('sharding is only supported with 64bit php');
  27. }
  28. }
  29. private function getCache(): IMemcache {
  30. if (is_null($this->cache)) {
  31. $cache = $this->cacheFactory->createDistributed('shared_autoincrement');
  32. if ($cache instanceof IMemcache) {
  33. $this->cache = $cache;
  34. } else {
  35. throw new \Exception('Distributed cache ' . get_class($cache) . ' is not suitable');
  36. }
  37. }
  38. return $this->cache;
  39. }
  40. /**
  41. * Get the next value for the given shard definition
  42. *
  43. * The returned key is unique and incrementing, but not sequential.
  44. * The shard id is encoded in the first byte of the returned value
  45. *
  46. * @param ShardDefinition $shardDefinition
  47. * @return int
  48. * @throws \Exception
  49. */
  50. public function getNextPrimaryKey(ShardDefinition $shardDefinition, int $shard): int {
  51. $retries = 0;
  52. while ($retries < 5) {
  53. $next = $this->getNextInner($shardDefinition);
  54. if ($next !== null) {
  55. if ($next > ShardDefinition::MAX_PRIMARY_KEY) {
  56. throw new \Exception('Max primary key of ' . ShardDefinition::MAX_PRIMARY_KEY . ' exceeded');
  57. }
  58. // we encode the shard the primary key was originally inserted into to allow guessing the shard by primary key later on
  59. return ($next << 8) | $shard;
  60. } else {
  61. $retries++;
  62. }
  63. }
  64. throw new \Exception('Failed to get next primary key');
  65. }
  66. /**
  67. * auto increment logic without retry
  68. *
  69. * @param ShardDefinition $shardDefinition
  70. * @return int|null either the next primary key or null if the call needs to be retried
  71. */
  72. private function getNextInner(ShardDefinition $shardDefinition): ?int {
  73. $cache = $this->getCache();
  74. // because this function will likely be called concurrently from different requests
  75. // the implementation needs to ensure that the cached value can be cleared, invalidated or re-calculated at any point between our cache calls
  76. // care must be taken that the logic remains fully resilient against race conditions
  77. // in the ideal case, the last primary key is stored in the cache and we can just do an `inc`
  78. // if that is not the case we find the highest used id in the database increment it, and save it in the cache
  79. // prevent inc from returning `1` if the key doesn't exist by setting it to a non-numeric value
  80. $cache->add($shardDefinition->table, 'empty-placeholder', self::TTL);
  81. $next = $cache->inc($shardDefinition->table);
  82. if ($cache instanceof IMemcacheTTL) {
  83. $cache->setTTL($shardDefinition->table, self::TTL);
  84. }
  85. // the "add + inc" trick above isn't strictly atomic, so as a safety we reject any result that to small
  86. // to handle the edge case of the stored value disappearing between the add and inc
  87. if (is_int($next) && $next >= self::MIN_VALID_KEY) {
  88. return $next;
  89. } elseif (is_int($next)) {
  90. // we hit the edge case, so invalidate the cached value
  91. if (!$cache->cas($shardDefinition->table, $next, 'empty-placeholder')) {
  92. // someone else is changing the value concurrently, give up and retry
  93. return null;
  94. }
  95. }
  96. // discard the encoded initial shard
  97. $current = $this->getMaxFromDb($shardDefinition) >> 8;
  98. $next = max($current, self::MIN_VALID_KEY) + 1;
  99. if ($cache->cas($shardDefinition->table, 'empty-placeholder', $next)) {
  100. return $next;
  101. }
  102. // another request set the cached value before us, so we should just be able to inc
  103. $next = $cache->inc($shardDefinition->table);
  104. if (is_int($next) && $next >= self::MIN_VALID_KEY) {
  105. return $next;
  106. } elseif (is_int($next)) {
  107. // key got cleared, invalidate and retry
  108. $cache->cas($shardDefinition->table, $next, 'empty-placeholder');
  109. return null;
  110. } else {
  111. // cleanup any non-numeric value other than the placeholder if that got stored somehow
  112. $cache->ncad($shardDefinition->table, 'empty-placeholder');
  113. // retry
  114. return null;
  115. }
  116. }
  117. /**
  118. * Get the maximum primary key value from the shards
  119. */
  120. private function getMaxFromDb(ShardDefinition $shardDefinition): int {
  121. $max = 0;
  122. foreach ($shardDefinition->getAllShards() as $shard) {
  123. $connection = $this->shardConnectionManager->getConnection($shardDefinition, $shard);
  124. $query = $connection->getQueryBuilder();
  125. $query->select($shardDefinition->primaryKey)
  126. ->from($shardDefinition->table)
  127. ->orderBy($shardDefinition->primaryKey, 'DESC')
  128. ->setMaxResults(1);
  129. $result = $query->executeQuery()->fetchOne();
  130. if ($result) {
  131. $max = max($max, $result);
  132. }
  133. }
  134. return $max;
  135. }
  136. }