Skip to content

Commit bf0c7df

Browse files
author
HugoFara
committed
fix(tags): race condition when creating tags (#120)
1 parent 960406a commit bf0c7df

File tree

3 files changed

+82
-4
lines changed

3 files changed

+82
-4
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ ones are marked like "v1.0.0-fork".
6767

6868
### Fixed
6969

70+
* **Tag Duplicate Key Error** ([#120](https://github.com/HugoFara/lwt/issues/120)):
71+
Fixed rare error when updating a word with tags. When the session cache was
72+
stale, saving a word with an existing tag would fail with "Duplicate entry
73+
for key 'TgText'" and remove the tag from the word. Changed tag insertion
74+
to use `INSERT IGNORE` to handle race conditions and stale cache gracefully.
7075
* **Japanese Annotations** ([#101](https://github.com/HugoFara/lwt/issues/101)):
7176
Fixed annotations not displaying correctly in Japanese texts. The
7277
`annotationToJson()` function was using an off-by-one index that didn't

src/backend/Services/TagService.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,11 @@ public static function saveWordTags(int $wordId): void
630630
foreach ($tagList as $tag) {
631631
$tag = (string) $tag;
632632
if (!in_array($tag, $_SESSION['TAGS'])) {
633-
QueryBuilder::table('tags')->insertPrepared(['TgText' => $tag]);
633+
// Use INSERT IGNORE to handle race condition / stale cache (Issue #120)
634+
Connection::preparedExecute(
635+
'INSERT IGNORE INTO tags (TgText) VALUES (?)',
636+
[$tag]
637+
);
634638
}
635639
// Use raw SQL for INSERT...SELECT subquery
636640
Connection::preparedExecute(
@@ -676,7 +680,11 @@ public static function saveTextTags(int $textId, ?array $textTags = null): void
676680
foreach ($tagList as $tag) {
677681
$tag = (string) $tag;
678682
if (!in_array($tag, $_SESSION['TEXTTAGS'])) {
679-
QueryBuilder::table('tags2')->insertPrepared(['T2Text' => $tag]);
683+
// Use INSERT IGNORE to handle race condition / stale cache (Issue #120)
684+
Connection::preparedExecute(
685+
'INSERT IGNORE INTO tags2 (T2Text) VALUES (?)',
686+
[$tag]
687+
);
680688
}
681689
// Use raw SQL for INSERT...SELECT subquery
682690
Connection::preparedExecute(
@@ -719,7 +727,11 @@ public static function saveArchivedTextTags(int $textId): void
719727
foreach ($tagList as $tag) {
720728
$tag = (string) $tag;
721729
if (!in_array($tag, $_SESSION['TEXTTAGS'])) {
722-
QueryBuilder::table('tags2')->insertPrepared(['T2Text' => $tag]);
730+
// Use INSERT IGNORE to handle race condition / stale cache (Issue #120)
731+
Connection::preparedExecute(
732+
'INSERT IGNORE INTO tags2 (T2Text) VALUES (?)',
733+
[$tag]
734+
);
723735
}
724736
// Use raw SQL for INSERT...SELECT subquery
725737
Connection::preparedExecute(
@@ -1472,8 +1484,12 @@ public static function saveWordTagsFromArray(int $wordId, array $tagNames): void
14721484
}
14731485

14741486
// Create tag if it doesn't exist
1487+
// Use INSERT IGNORE to handle race condition / stale cache (Issue #120)
14751488
if (!in_array($tag, $_SESSION['TAGS'])) {
1476-
QueryBuilder::table('tags')->insertPrepared(['TgText' => $tag]);
1489+
Connection::preparedExecute(
1490+
'INSERT IGNORE INTO tags (TgText) VALUES (?)',
1491+
[$tag]
1492+
);
14771493
}
14781494

14791495
// Link tag to word using raw SQL for INSERT...SELECT

tests/backend/Core/Tag/TagsTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
namespace Lwt\Tests\Core\Tag;
44

55
require_once __DIR__ . '/../../../../src/backend/Core/Bootstrap/EnvLoader.php';
6+
require_once __DIR__ . '/../../../../src/backend/Core/Http/url_utilities.php';
67

78
use Lwt\Core\EnvLoader;
89
use Lwt\Core\Globals;
10+
use Lwt\Database\Connection;
11+
use Lwt\Database\QueryBuilder;
912
use Lwt\Services\TagService;
1013
use PHPUnit\Framework\TestCase;
1114

@@ -750,4 +753,58 @@ public function testRemoveTagFromArchivedTextsEmptyTag(): void
750753
$this->assertIsString($result);
751754
$this->assertStringContainsString('not found', $result);
752755
}
756+
757+
/**
758+
* Test saveWordTagsFromArray handles duplicate tags gracefully (Issue #120)
759+
*
760+
* This tests the scenario where a tag exists in the database but the
761+
* session cache is stale. The function should not throw a duplicate
762+
* key error.
763+
*/
764+
public function testSaveWordTagsFromArrayHandlesDuplicateTags(): void
765+
{
766+
if (!Globals::getDbConnection()) {
767+
$this->markTestSkipped('Database connection not available');
768+
}
769+
770+
// Use a highly unique tag name with microseconds
771+
$uniqueTagName = 'DupTest_' . uniqid('', true);
772+
773+
// Clean up any pre-existing tag with this name (shouldn't exist, but be safe)
774+
Connection::preparedExecute(
775+
'DELETE FROM tags WHERE TgText = ?',
776+
[$uniqueTagName]
777+
);
778+
779+
try {
780+
// Insert the tag directly into the database
781+
Connection::preparedExecute(
782+
'INSERT INTO tags (TgText) VALUES (?)',
783+
[$uniqueTagName]
784+
);
785+
786+
// Clear the session cache to simulate stale cache
787+
$_SESSION['TAGS'] = [];
788+
789+
// Try to save a word with this tag - should NOT throw exception
790+
// even though the tag exists but is not in the cache
791+
TagService::saveWordTagsFromArray(1, [$uniqueTagName]);
792+
$this->assertTrue(true, 'saveWordTagsFromArray should handle duplicate tags gracefully');
793+
} catch (\RuntimeException $e) {
794+
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
795+
$this->fail('Issue #120: saveWordTagsFromArray throws duplicate key error when cache is stale: ' . $e->getMessage());
796+
}
797+
throw $e;
798+
} finally {
799+
// Cleanup: remove the test tag and wordtags associations
800+
Connection::preparedExecute(
801+
'DELETE FROM wordtags WHERE WtTgID IN (SELECT TgID FROM tags WHERE TgText = ?)',
802+
[$uniqueTagName]
803+
);
804+
Connection::preparedExecute(
805+
'DELETE FROM tags WHERE TgText = ?',
806+
[$uniqueTagName]
807+
);
808+
}
809+
}
753810
}

0 commit comments

Comments
 (0)