mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 11:03:22 +01:00
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:
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user