このコミットが含まれているのは:
2026-10-18 02:22:00 +09:00
コミット 753a150128
14個のファイルの変更、189行の追加、73行の削除
+1
ファイルの表示
@@ -368,6 +368,7 @@ class TagsController < ApplicationController
return if performed?
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
+1
ファイルの表示
@@ -214,6 +214,7 @@ class WikiPagesController < ApplicationController
base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
old_title = tag_name.name
if title != old_title
+9
ファイルの表示
@@ -1,4 +1,5 @@
class Locale < ApplicationRecord
before_save :lock_tag_creation!, if: :will_generate_tag_names?
after_save :generate_tag_names!, if: :needs_tag_names?
belongs_to :language, foreign_key: :language_code, primary_key: :code
@@ -10,6 +11,14 @@ class Locale < ApplicationRecord
private
def will_generate_tag_names?
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
end
def lock_tag_creation!
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
end
def needs_tag_names?
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
end
+2 -1
ファイルの表示
@@ -259,7 +259,8 @@ class Tag < ApplicationRecord
name = name.to_s.strip
ApplicationRecord.transaction do
tn = TagName.find_by(language_code:, name:)
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tn = TagName.lock.find_by(language_code:, name:)
next tn.tag if tn
tag = Tag.create!(category:)
+43 -30
ファイルの表示
@@ -13,42 +13,14 @@ class SharedResourceIdAllocator
['tag_description_versions', 'tag_id']],
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
LOCK_TIMEOUT = 10
TRANSACTION_LOCKS = ObjectSpace::WeakMap.new
def self.assign! record
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
raise ArgumentError, 'Unknown shared ID resource' unless tables
connection = record.class.connection
transaction = record.class.current_transaction
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
quoted_name = connection.quote(name)
acquired = record.class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
release = lambda do
result = record.class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
end
end
begin
transaction.after_commit(&release)
transaction.after_rollback(&release)
rescue StandardError
release.call
raise
end
lock_space!(record.class, space)
# 明示-tic な Id. 指定は許可する.
return if record.id.present?
@@ -66,6 +38,47 @@ class SharedResourceIdAllocator
record.id = next_id
end
def self.lock_space! record_class, space
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
connection = record_class.connection
transaction = record_class.current_transaction
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
held_locks = TRANSACTION_LOCKS[transaction] ||= {}
return if held_locks.key?(name)
quoted_name = connection.quote(name)
acquired = record_class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
held_locks[name] = true
release = lambda do
held_locks.delete(name)
result = record_class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
end
end
begin
transaction.after_commit(&release)
transaction.after_rollback(&release)
rescue StandardError
release.call
raise
end
end
def self.next_ids connection, tables
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
begin
+2
ファイルの表示
@@ -5,6 +5,7 @@ class TagDescriptionOperation
def self.create! locale, tag:, body:, created_by_user:, message: nil
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: locale.language_code)
@@ -19,6 +20,7 @@ class TagDescriptionOperation
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: locale.language_code)
+49 -22
ファイルの表示
@@ -1,31 +1,50 @@
class TagNameCompletion
class << self
def for_locale! locale
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).find_each do |tag|
for_tag!(tag, locales: [locale])
completed = Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale])
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
end
end
def for_tag! tag, locales: nil
locales ||= Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code)
tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).index_by(&:language_code)
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next if missing_locales.empty?
locales ||=
Locale.where(deprecated_at: nil)
.order(:language_code, :created_at, :script_code).lock
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
completed = tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next true if missing_locales.empty?
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
missing_locales.each do |locale|
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
name = TagName.generate_name(locale, tag, primary_names[source_language])
primary_names[locale.language_code] = create_name!(locale, tag, name)
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
missing_locales.each do |locale|
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
name = TagName.generate_name(locale, tag, primary_names[source_language])
primary_names[locale.language_code] = create_name!(locale, tag, name)
end
tag.association(:tag_names).reset
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
unless version&.persisted?
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
end
tag.association(:tag_names).reset
TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
end
end
@@ -36,18 +55,26 @@ class TagNameCompletion
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }"
invalid_name =
error.is_a?(ActiveRecord::RecordNotUnique) ||
error.record.errors[:name].present?
if error.is_a?(ActiveRecord::RecordNotUnique)
TagName.where(language_code: locale.language_code, name:).lock.exists?
else
error.record.errors[:name].present? &&
error.record.errors.attribute_names.all? { _1 == :name }
end
raise if name == fallback || !(invalid_name)
persist_name!(locale, tag, fallback)
end
def persist_name! locale, tag, name
TagName.transaction(requires_new: true) do
tag_name = TagName.transaction(requires_new: true) do
TagName.create!(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true, auto_generated: true)
end
unless tag_name&.persisted?
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
end
tag_name
end
end
end
+9 -3
ファイルの表示
@@ -1,6 +1,9 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options
new(tag:, event_type:, created_by_user:).record!
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, created_by_user:).record!
end
end
def initialize tag:, event_type:, created_by_user:
@@ -16,17 +19,20 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes
names = snapshot_names
compatibility_names = names['ja'] || names.fetch('ain')
parent_ids =
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
.lock.pluck(:parent_tag_id)
{ name: compatibility_names.fetch('name'),
category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:,
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
parent_tag_ids: parent_ids.join(' ') }
end
def snapshot_names
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
@record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
.transform_values do |tag_names|
primary = tag_names.find(&:primary?)
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
+13 -4
ファイルの表示
@@ -4,14 +4,23 @@ class TagVersioning
end
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
return if tag.tag_versions.exists?
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
next if locked_tag.tag_versions.lock.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
TagVersionRecorder.record!(
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
end
end
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:, language_code:)
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
record!(locked_tag, event_type:, created_by_user:, language_code:)
end
end
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
+1 -1
ファイルの表示
@@ -35,7 +35,7 @@ class VersionRecorder
private
def latest_version = versions.order(version_no: :desc).first
def latest_version = versions.order(version_no: :desc).lock.first
def versions = @record.public_send(version_association)
+7 -6
ファイルの表示
@@ -1,4 +1,7 @@
import { useTranslation } from 'react-i18next'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
@@ -8,8 +11,8 @@ const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
const { t } = useTranslation ('shared')
const text = t ('labels.development', { returnDetails: true })
const { i18n } = useTranslation ('shared')
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
return (
<div
aria-hidden="true"
@@ -31,14 +34,14 @@ const DevModeWatermark: FC = () => {
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
lang={text.usedLng}
lang={text.language}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
{text.res}
{text.text}
</span>))}
</div>))}
</div>
@@ -47,5 +50,3 @@ const DevModeWatermark: FC = () => {
export default DevModeWatermark
import { useTranslation } from 'react-i18next'
+46
ファイルの表示
@@ -1,6 +1,8 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { TOptions } from 'i18next'
import type { LocaleCode } from '@/lib/locales'
export const i18n = createInstance ()
@@ -59,6 +61,50 @@ void i18n.use (initReactI18next).init ({
react: { useSuspense: false } })
export const translateUIMessage = (
key: string,
namespace: string,
locale: string,
values: Record<string, string | number> = { },
): { text: string; language: string } => {
const languages = new Set<string> ()
const visiting = new Set<string> ()
const collectLanguages = (reference: string, options: TOptions): string => {
const details = i18n.t (reference, { ...options,
returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true })
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
if (visiting.has (identity) || typeof details.res !== 'string')
return details.usedLng
visiting.add (identity)
const nestingOptions = { ...i18n.options.interpolation, ...options,
ns: details.usedNS, lng: details.usedLng }
const remainder = i18n.services.interpolator.nest (details.res,
(nestedKey: string, nestedOptions: TOptions) => {
collectLanguages (nestedKey, nestedOptions)
return ' '
}, nestingOptions)
// References, punctuation and supplied values do not define the wrapper's language.
if (/\p{L}/u.test (remainder.replace (/\{\{[^}]*\}\}/g, '')))
languages.add (details.usedLng)
visiting.delete (identity)
return details.usedLng
}
const options = { ...values, ns: namespace, lng: locale }
const resolvedLanguage = collectLanguages (key, options)
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
const mixed = languages.size > 1
const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage
const text = i18n.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
returnDetails: false, returnObjects: false })
if (typeof text !== 'string')
{
console.warn ('[i18n] UI message must be a string', { key, namespace, locale })
return { text: key, language }
}
return { text, language }
}
export const untranslatedKeys = (
locale: LocaleCode,
namespace = 'navigation',
+3 -3
ファイルの表示
@@ -1,4 +1,4 @@
import { i18n } from '@/lib/i18n'
import { translateUIMessage } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json'
import type { LocaleCode } from '@/lib/locales'
@@ -10,10 +10,10 @@ export const uiMessage = (
locale: LocaleCode,
key: UIMessageKey,
values: Record<string, string | number> = { },
): string => i18n.t (key, { ...values, lng: locale, ns: 'navigation' })
): string => translateUIMessage (key, 'navigation', locale, values).text
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
i18n.t (key, { lng: locale, ns: 'navigation', returnDetails: true }).usedLng === 'ain-Kana'
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
? 'ain'
: 'ja'
+3 -3
ファイルの表示
@@ -2,16 +2,16 @@ import { createElement } from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => {
const locale = useLocale ()
const { t } = useTranslation (namespace, { lng: locale })
useTranslation (namespace, { lng: locale })
const message = (key: string, values?: Record<string, string | number>) => {
const result = t (key, { ...values, returnDetails: true })
return { text: result.res, language: result.usedLng }
return translateUIMessage (key, namespace, locale, values)
}
const label = (key: string, values?: Record<string, string | number>) => {