Skip to content

Commit ba0f927

Browse files
authored
Allow dev i18n to be more concurrent (#20159)
The recent changes to add live-reloading to the i18n translation files made the i18n code totally non-concurrent when using dev. This will make discovering other concurrency related issues far more difficult. This PR fixes these, adds some more comments to the code and slightly restructures a few functions. Signed-off-by: Andrew Thornton <[email protected]>
1 parent 33f6f91 commit ba0f927

File tree

1 file changed

+168
-74
lines changed

1 file changed

+168
-74
lines changed

modules/translation/i18n/i18n.go

+168-74
Original file line numberDiff line numberDiff line change
@@ -25,174 +25,268 @@ var (
2525
)
2626

2727
type locale struct {
28+
// This mutex will be set if we have live-reload enabled (e.g. dev mode)
29+
reloadMu *sync.RWMutex
30+
2831
store *LocaleStore
2932
langName string
30-
textMap map[int]string // the map key (idx) is generated by store's textIdxMap
33+
34+
idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
3135

3236
sourceFileName string
3337
sourceFileInfo os.FileInfo
3438
lastReloadCheckTime time.Time
3539
}
3640

3741
type LocaleStore struct {
38-
reloadMu *sync.Mutex // for non-prod(dev), use a mutex for live-reload. for prod, no mutex, no live-reload.
42+
// This mutex will be set if we have live-reload enabled (e.g. dev mode)
43+
reloadMu *sync.RWMutex
3944

4045
langNames []string
4146
langDescs []string
47+
localeMap map[string]*locale
4248

43-
localeMap map[string]*locale
44-
textIdxMap map[string]int
49+
// this needs to be locked when live-reloading
50+
trKeyToIdxMap map[string]int
4551

4652
defaultLang string
4753
}
4854

4955
func NewLocaleStore(isProd bool) *LocaleStore {
50-
ls := &LocaleStore{localeMap: make(map[string]*locale), textIdxMap: make(map[string]int)}
56+
store := &LocaleStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
5157
if !isProd {
52-
ls.reloadMu = &sync.Mutex{}
58+
store.reloadMu = &sync.RWMutex{}
5359
}
54-
return ls
60+
return store
5561
}
5662

5763
// AddLocaleByIni adds locale by ini into the store
58-
// if source is a string, then the file is loaded. in dev mode, the file can be live-reloaded
64+
// if source is a string, then the file is loaded. In dev mode, this file will be checked for live-reloading
5965
// if source is a []byte, then the content is used
60-
func (ls *LocaleStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
61-
if _, ok := ls.localeMap[langName]; ok {
66+
// Note: this is not concurrent safe
67+
func (store *LocaleStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
68+
if _, ok := store.localeMap[langName]; ok {
6269
return ErrLocaleAlreadyExist
6370
}
6471

65-
lc := &locale{store: ls, langName: langName}
72+
l := &locale{store: store, langName: langName}
73+
if store.reloadMu != nil {
74+
l.reloadMu = &sync.RWMutex{}
75+
l.reloadMu.Lock() // Arguably this is not necessary as AddLocaleByIni isn't concurrent safe - but for consistency we do this
76+
defer l.reloadMu.Unlock()
77+
}
78+
6679
if fileName, ok := source.(string); ok {
67-
lc.sourceFileName = fileName
68-
lc.sourceFileInfo, _ = os.Stat(fileName) // live-reload only works for regular files. the error can be ignored
80+
l.sourceFileName = fileName
81+
l.sourceFileInfo, _ = os.Stat(fileName) // live-reload only works for regular files. the error can be ignored
82+
}
83+
84+
var err error
85+
l.idxToMsgMap, err = store.readIniToIdxToMsgMap(source)
86+
if err != nil {
87+
return err
6988
}
7089

71-
ls.langNames = append(ls.langNames, langName)
72-
ls.langDescs = append(ls.langDescs, langDesc)
73-
ls.localeMap[lc.langName] = lc
90+
store.langNames = append(store.langNames, langName)
91+
store.langDescs = append(store.langDescs, langDesc)
92+
93+
store.localeMap[l.langName] = l
7494

75-
return ls.reloadLocaleByIni(langName, source)
95+
return nil
7696
}
7797

78-
func (ls *LocaleStore) reloadLocaleByIni(langName string, source interface{}) error {
98+
// readIniToIdxToMsgMap will read a provided ini and creates an idxToMsgMap
99+
func (store *LocaleStore) readIniToIdxToMsgMap(source interface{}) (map[int]string, error) {
79100
iniFile, err := ini.LoadSources(ini.LoadOptions{
80101
IgnoreInlineComment: true,
81102
UnescapeValueCommentSymbols: true,
82103
}, source)
83104
if err != nil {
84-
return fmt.Errorf("unable to load ini: %w", err)
105+
return nil, fmt.Errorf("unable to load ini: %w", err)
85106
}
86107
iniFile.BlockMode = false
87108

88-
lc := ls.localeMap[langName]
89-
lc.textMap = make(map[int]string)
109+
idxToMsgMap := make(map[int]string)
110+
111+
if store.reloadMu != nil {
112+
store.reloadMu.Lock()
113+
defer store.reloadMu.Unlock()
114+
}
115+
90116
for _, section := range iniFile.Sections() {
91117
for _, key := range section.Keys() {
118+
92119
var trKey string
93120
if section.Name() == "" || section.Name() == "DEFAULT" {
94121
trKey = key.Name()
95122
} else {
96123
trKey = section.Name() + "." + key.Name()
97124
}
98-
textIdx, ok := ls.textIdxMap[trKey]
125+
126+
// Instead of storing the key strings in multiple different maps we compute a idx which will act as numeric code for key
127+
// This reduces the size of the locale idxToMsgMaps
128+
idx, ok := store.trKeyToIdxMap[trKey]
99129
if !ok {
100-
textIdx = len(ls.textIdxMap)
101-
ls.textIdxMap[trKey] = textIdx
130+
idx = len(store.trKeyToIdxMap)
131+
store.trKeyToIdxMap[trKey] = idx
102132
}
103-
lc.textMap[textIdx] = key.Value()
133+
idxToMsgMap[idx] = key.Value()
104134
}
105135
}
106136
iniFile = nil
107-
return nil
137+
return idxToMsgMap, nil
138+
}
139+
140+
func (store *LocaleStore) idxForTrKey(trKey string) (int, bool) {
141+
if store.reloadMu != nil {
142+
store.reloadMu.RLock()
143+
defer store.reloadMu.RUnlock()
144+
}
145+
idx, ok := store.trKeyToIdxMap[trKey]
146+
return idx, ok
108147
}
109148

110-
func (ls *LocaleStore) HasLang(langName string) bool {
111-
_, ok := ls.localeMap[langName]
149+
// HasLang reports if a language is available in the store
150+
func (store *LocaleStore) HasLang(langName string) bool {
151+
_, ok := store.localeMap[langName]
112152
return ok
113153
}
114154

115-
func (ls *LocaleStore) ListLangNameDesc() (names, desc []string) {
116-
return ls.langNames, ls.langDescs
155+
// ListLangNameDesc reports if a language available in the store
156+
func (store *LocaleStore) ListLangNameDesc() (names, desc []string) {
157+
return store.langNames, store.langDescs
117158
}
118159

119160
// SetDefaultLang sets default language as a fallback
120-
func (ls *LocaleStore) SetDefaultLang(lang string) {
121-
ls.defaultLang = lang
161+
func (store *LocaleStore) SetDefaultLang(lang string) {
162+
store.defaultLang = lang
122163
}
123164

124165
// Tr translates content to target language. fall back to default language.
125-
func (ls *LocaleStore) Tr(lang, trKey string, trArgs ...interface{}) string {
126-
l, ok := ls.localeMap[lang]
166+
func (store *LocaleStore) Tr(lang, trKey string, trArgs ...interface{}) string {
167+
l, ok := store.localeMap[lang]
127168
if !ok {
128-
l, ok = ls.localeMap[ls.defaultLang]
169+
l, ok = store.localeMap[store.defaultLang]
129170
}
171+
130172
if ok {
131173
return l.Tr(trKey, trArgs...)
132174
}
133175
return trKey
134176
}
135177

178+
// reloadIfNeeded will check if the locale needs to be reloaded
179+
// this function will assume that the l.reloadMu has been RLocked if it already exists
180+
func (l *locale) reloadIfNeeded() {
181+
if l.reloadMu == nil {
182+
return
183+
}
184+
185+
now := time.Now()
186+
if now.Sub(l.lastReloadCheckTime) < time.Second || l.sourceFileInfo == nil || l.sourceFileName == "" {
187+
return
188+
}
189+
190+
l.reloadMu.RUnlock()
191+
l.reloadMu.Lock() // (NOTE: a pre-emption can occur between these two locks so we need to recheck)
192+
defer l.reloadMu.RLock()
193+
defer l.reloadMu.Unlock()
194+
195+
if now.Sub(l.lastReloadCheckTime) < time.Second || l.sourceFileInfo == nil || l.sourceFileName == "" {
196+
return
197+
}
198+
199+
l.lastReloadCheckTime = now
200+
sourceFileInfo, err := os.Stat(l.sourceFileName)
201+
if err != nil || sourceFileInfo.ModTime().Equal(l.sourceFileInfo.ModTime()) {
202+
return
203+
}
204+
205+
idxToMsgMap, err := l.store.readIniToIdxToMsgMap(l.sourceFileName)
206+
if err == nil {
207+
l.idxToMsgMap = idxToMsgMap
208+
} else {
209+
log.Error("Unable to live-reload the locale file %q, err: %v", l.sourceFileName, err)
210+
}
211+
212+
// We will set the sourceFileInfo to this file to prevent repeated attempts to re-load this broken file
213+
l.sourceFileInfo = sourceFileInfo
214+
}
215+
136216
// Tr translates content to locale language. fall back to default language.
137217
func (l *locale) Tr(trKey string, trArgs ...interface{}) string {
138-
if l.store.reloadMu != nil {
139-
l.store.reloadMu.Lock()
140-
defer l.store.reloadMu.Unlock()
141-
now := time.Now()
142-
if now.Sub(l.lastReloadCheckTime) >= time.Second && l.sourceFileInfo != nil && l.sourceFileName != "" {
143-
l.lastReloadCheckTime = now
144-
if sourceFileInfo, err := os.Stat(l.sourceFileName); err == nil && !sourceFileInfo.ModTime().Equal(l.sourceFileInfo.ModTime()) {
145-
if err = l.store.reloadLocaleByIni(l.langName, l.sourceFileName); err == nil {
146-
l.sourceFileInfo = sourceFileInfo
147-
} else {
148-
log.Error("unable to live-reload the locale file %q, err: %v", l.sourceFileName, err)
149-
}
150-
}
151-
}
218+
if l.reloadMu != nil {
219+
l.reloadMu.RLock()
220+
defer l.reloadMu.RUnlock()
221+
l.reloadIfNeeded()
152222
}
223+
153224
msg, _ := l.tryTr(trKey, trArgs...)
154225
return msg
155226
}
156227

157228
func (l *locale) tryTr(trKey string, trArgs ...interface{}) (msg string, found bool) {
158229
trMsg := trKey
159-
textIdx, ok := l.store.textIdxMap[trKey]
230+
231+
// convert the provided trKey to a common idx from the store
232+
idx, ok := l.store.idxForTrKey(trKey)
233+
160234
if ok {
161-
if msg, found = l.textMap[textIdx]; found {
162-
trMsg = msg // use current translation
235+
if msg, found = l.idxToMsgMap[idx]; found {
236+
trMsg = msg // use the translation that we have found
163237
} else if l.langName != l.store.defaultLang {
238+
// No translation available in our current language... fallback to the default language
239+
240+
// Attempt to get the default language from the locale store
164241
if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
165-
return def.tryTr(trKey, trArgs...)
242+
243+
if def.reloadMu != nil {
244+
def.reloadMu.RLock()
245+
def.reloadIfNeeded()
246+
}
247+
if msg, found = def.idxToMsgMap[idx]; found {
248+
trMsg = msg // use the translation that we have found
249+
}
250+
if def.reloadMu != nil {
251+
def.reloadMu.RUnlock()
252+
}
166253
}
167-
} else if !setting.IsProd {
168-
log.Error("missing i18n translation key: %q", trKey)
169254
}
170255
}
171256

172-
if len(trArgs) > 0 {
173-
fmtArgs := make([]interface{}, 0, len(trArgs))
174-
for _, arg := range trArgs {
175-
val := reflect.ValueOf(arg)
176-
if val.Kind() == reflect.Slice {
177-
// before, it can accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f), it's an unstable behavior
178-
// now, we restrict the strange behavior and only support:
179-
// 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
180-
// 2. Tr(lang, key, args...) as Sprintf(msg, args...)
181-
if len(trArgs) == 1 {
182-
for i := 0; i < val.Len(); i++ {
183-
fmtArgs = append(fmtArgs, val.Index(i).Interface())
184-
}
185-
} else {
186-
log.Error("the args for i18n shouldn't contain uncertain slices, key=%q, args=%v", trKey, trArgs)
187-
break
257+
if !found && !setting.IsProd {
258+
log.Error("missing i18n translation key: %q", trKey)
259+
}
260+
261+
if len(trArgs) == 0 {
262+
return trMsg, found
263+
}
264+
265+
fmtArgs := make([]interface{}, 0, len(trArgs))
266+
for _, arg := range trArgs {
267+
val := reflect.ValueOf(arg)
268+
if val.Kind() == reflect.Slice {
269+
// Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f)
270+
// but this is an unstable behavior.
271+
//
272+
// So we restrict the accepted arguments to either:
273+
//
274+
// 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
275+
// 2. Tr(lang, key, args...) as Sprintf(msg, args...)
276+
if len(trArgs) == 1 {
277+
for i := 0; i < val.Len(); i++ {
278+
fmtArgs = append(fmtArgs, val.Index(i).Interface())
188279
}
189280
} else {
190-
fmtArgs = append(fmtArgs, arg)
281+
log.Error("the args for i18n shouldn't contain uncertain slices, key=%q, args=%v", trKey, trArgs)
282+
break
191283
}
284+
} else {
285+
fmtArgs = append(fmtArgs, arg)
192286
}
193-
return fmt.Sprintf(trMsg, fmtArgs...), found
194287
}
195-
return trMsg, found
288+
289+
return fmt.Sprintf(trMsg, fmtArgs...), found
196290
}
197291

198292
// ResetDefaultLocales resets the current default locales

0 commit comments

Comments
 (0)