fix(cluster): make an epoch current only once it is marked used

The previous commit lets markUsed() skip epochs no newer than the
node's current one, on the invariant that the current epoch was marked
used. In production a failed UPDATE returns false instead of throwing,
so the node could have moved to an epoch whose used flag was never
written, and nothing would mark it later: a refresh authenticated with
the epoch before could then replace the node's current epoch.

markUsed() now advances cluster_nodes.epoch only after the used flag
was written; otherwise the next request with that epoch marks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
This commit is contained in:
Claude
2026-09-26 19:01:35 +00:00
parent 3d54642006
commit e77ea25f0f
2 changed files with 35 additions and 3 deletions
+6 -3
View File
@@ -88,8 +88,9 @@ final class TokenService {
* A request authenticated with this epoch: mark it used, and make it the
* node's current epoch when it is newer. An epoch no newer than the
* node's current one was marked when it became current (every newer
* epoch is minted above it), so it costs no write: a heartbeat on the
* cluster bus writes nothing to MySQL.
* epoch is minted above it, and it becomes current only once marked),
* so it costs no write: a heartbeat on the cluster bus writes nothing to
* MySQL.
*
* @param array<string, mixed> $rNode
*/
@@ -97,7 +98,9 @@ final class TokenService {
if ($rEpoch <= (int) $rNode['epoch']) {
return;
}
self::db()->query('UPDATE `cluster_node_epochs` SET `used` = 1 WHERE `server_id` = ? AND `epoch` = ? AND `used` = 0;', (int) $rNode['server_id'], $rEpoch);
if (self::db()->query('UPDATE `cluster_node_epochs` SET `used` = 1 WHERE `server_id` = ? AND `epoch` = ? AND `used` = 0;', (int) $rNode['server_id'], $rEpoch) === false) {
return; // the next request marks it
}
NodeRegistry::update((int) $rNode['server_id'], ['epoch' => $rEpoch, 'token_exp' => $rExp]);
}
+29
View File
@@ -470,6 +470,35 @@ final class ClusterApiTest extends TestCase {
$this->assertSame(3, $this->reply($rRes, $rCtx, $rTok2['keys'])['epoch']);
}
public function testAnEpochBecomesCurrentOnlyOnceMarkedUsedAndIsNotMarkedAgain(): void {
$rKeys = $this->active();
$rEph = random_bytes(32);
[$rRes, $rCtx] = $this->call('token_refresh', ['eph_pub' => base64_encode(sodium_crypto_scalarmult_base($rEph))], 1, $rKeys);
$rTok2 = $this->openToken((string) base64_decode($this->reply($rRes, $rCtx, $rKeys)['token_sealed']), $rEph);
$rLog = new QueryLogDb($this->rDb);
DatabaseFactory::set($rLog);
// MySQL refuses to mark epoch 2 used: it does not become current either.
$rLog->rRefuse = '/^UPDATE `cluster_node_epochs`/';
[$rRes, $rCtx] = $this->call('heartbeat', [], 2, $rTok2['keys']);
$this->reply($rRes, $rCtx, $rTok2['keys']);
$this->assertSame(1, (int) NodeRegistry::byServer(self::SID)['epoch']);
// The next request marks it, and the ones after write nothing for it.
$rLog->rRefuse = null;
[$rRes, $rCtx] = $this->call('heartbeat', [], 2, $rTok2['keys']);
$this->reply($rRes, $rCtx, $rTok2['keys']);
$this->assertSame(2, (int) NodeRegistry::byServer(self::SID)['epoch']);
$this->rDb->query('SELECT `used` FROM `cluster_node_epochs` WHERE `server_id` = 5 AND `epoch` = 2');
$this->assertSame(1, (int) $this->rDb->get_row()['used']);
$rLog->rQueries = [];
foreach ([2 => $rTok2['keys'], 1 => $rKeys] as $rEpoch => $rEpochKeys) {
[$rRes, $rCtx] = $this->call('heartbeat', [], $rEpoch, $rEpochKeys);
$this->reply($rRes, $rCtx, $rEpochKeys);
}
$this->assertSame([], array_values(array_filter($rLog->writes(), static fn(string $rQ): bool => str_contains($rQ, 'cluster_node_epochs'))), 'the current epoch, and the one before it, are not marked again');
}
public function testExpiredEpochIsRefused(): void {
$rKeys = $this->active();
ClusterClock::fix($this->rT0 + (75 * 60 + 1) * 1000);