projectfluent / fluent-rs

Rust implementation of Project Fluent
https://projectfluent.org
Apache License 2.0
1.04k stars 95 forks source link

Consider fast-path for warm scenario async fluent-fallback functions #244

Open zbraniecki opened 2 years ago

zbraniecki commented 2 years ago

The error fallbacking fluent-fallback's Localization class performs (both in JS and Rust) is that we fall back onto the next locale if a message is missing, but not if there's an error while resolving a message that is present.

That means that if the user is requesting ["key1", "key2", "key3"] and key3 is missing we will trigger a fallback onto the next locale for it, but if key1 references key3 which is missing, we'll resolve key1 in the first locale with a missing piece, like: Hello, { key3 }.

This is fairly arbitrary and when we designed it we want to retain ability to fine-tune the fallbacking model, which we now have a chance to better define when working on MF2.0.

But if we were to keep this model for MF2.0, there's an interesting optimization that may be useful for Firefox and Fluent DOM application: if we loaded strings (async), and we are asked to translate a list of keys, we can synchronously check if all keys are present in the top locale (by using bundles[0].has_message) and if so, we know we will not trigger any async I/O fallback!

This means, we could then shortcut to resolve this API call synchronously.

So an example DOM Localization call triggered by L10n Mutations could perform something like:

let all_available = document_localization.messages_available(list_of_keys);
if all_available {
    let messages = document_localization.format_messages_sync(list_of_keys);
} else {
   let messages = document_localization.format_messages(list_of_keys).await;
}

This would act the same way for initial call (triggering async), and for incomplete scenarios (still using async), but for the most common scenario it would allow us to execute frame faster.

This came to play in cases like https://bugzilla.mozilla.org/show_bug.cgi?id=1737951 where engineers want to flip l10n-id back and forth and expect that while initial l10n frame may take longer, the "warm" swapping of l10n-id during UI lifecycle should be very fast because the strings are loaded.

We could even do the messages_available check with iteration over the CachedBundles and then even if one of the keys is only available in a fallback but that fallback is already loaded, we still know we can operate in sync mode and no I/O will be needed.

To recap, this optimization would be useful, but it requires us to ensure that:

1) We will in MF2.0 follow Fluent's fallbacking model 2) We would need to re-add some _sync method calling ability in async scenario - for cases where we are certain all messages are available

I'm wondering how far are we from such guarantees and how should the Localization class API be redesigned to model for this use case.

zbraniecki commented 2 years ago

@nordzilla @eemeli @dminor

eemeli commented 2 years ago

I think there are two actions which are currently conflated into one externally visible interface:

  1. Waiting for a message to become available
  2. Formatting a message

Combining these into one async format_message call makes sense when the loading and formatting are e.g. happening in separate threads, but it's clearly disadvantaging the use cases where certainty can somehow be achieved first that loading has completed before formatting is even attempted.

So yes, I would support providing a synchronous message formatting call. Regarding messages_available, I think at least in an MF2 world it'd make more sense as something like resources_available.

zbraniecki commented 2 years ago

The reason they are conflated is that setting an API as sync is a one way street and we didn't want to lock ourselves out of ability to execute async I/O late.

For example, a scenario may happen where you request key1 with a variable that resolves (success), and then request again with a variable that fails. If we wanted to fallback onto a second locale in that case, we'd need to do it right at this moment so we wanted to maintain the method as sync.

Another scenario we wanted to explore is a threshold of messages resolvable in a given locale. If a screen has 100 messages and we resolved 90 in locale A, and 10 in locale B, then that's fine. But if we resolved 3 in locale A and 97 in locale B, then maybe it makes sense to translate the whole screen in B?

What if message exists, but references another message that doesn't? Should the top message fallback to locale B or stay in A? If stay in A, should it resolve the referenced message from locale B creating a mixed locale message, or return partially resolved message?

We never got to rethink the error fallbacking of messages and if MF2.0 will follow Fluent here, then maybe it is ok to strengthen the model by stating that the fallback to a different locale happens only if a directly requested message is missing?

Which, in return, would mean that if a directly requested message(s) are present we can guarantee that we will not perform I/O and call format synchronously?

zbraniecki commented 2 years ago

@stasm

Pike commented 2 years ago

FWIW, my post-l10n mind looks at bundle as a mistake. I think that a mono-lingual abstraction layer in a truly multi-lingual system creates more problems than it solves.

Maybe we're not talking about an entry-level thing, but more about caching already-rendered messages? That obviously raises the question what the cached thing is, as that, in general, still depend on message context, right?

In the context on fallback, term references might be special, as I can see reasons for them to favor terms in the same language over terms in the overall scheme of things. Maybe they even have a different language fallback chain like just [currentMessageLanguage, defaultLanguage]?

zbraniecki commented 2 years ago

FWIW, my post-l10n mind looks at bundle as a mistake. I think that a mono-lingual abstraction layer in a truly multi-lingual system creates more problems than it solves.

Interesting. What do you think should be the layer structure? Back of a napkin blurb is okay, no need to refine, just wondering what your mind holds now.

Maybe we're not talking about an entry-level thing, but more about caching already-rendered messages? That obviously raises the question what the cached thing is, as that, in general, still depend on message context, right?

With Message being a list of parts, we could store it resolved and "replace" dynamically just pieces that come from variables maybe? Not sure how far this will take us.

In the context on fallback, term references might be special, as I can see reasons for them to favor terms in the same language over terms in the overall scheme of things.

How are they special? In MF2.0 we do introduce dynamic references and I imagine you'd want to maintain consistency between message and a referenced message as well (both static and dynamic), am I wrong?

Pike commented 2 years ago

FWIW, my post-l10n mind looks at bundle as a mistake. I think that a mono-lingual abstraction layer in a truly multi-lingual system creates more problems than it solves.

Interesting. What do you think should be the layer structure? Back of a napkin blurb is okay, no need to refine, just wondering what your mind holds now.

My latest thinking was more around the Localization class holding a cloud of entries, bound to the language of their resource. That would also allow for bidi separators being more context sensitive.

The relationship between the Localization class and resources would be that it's directly feeding upon a generator of parsed resources per "source".

Message references would start at the top of the generator, term references might be different. That term refs are different is due to the quality that a term in the same language can bring the translation of a message. That implies that all term attributes and parametrized behavior is abstracted if the message language doesn't match the term language. match might be interesting here in terms of es and es-AR to pick the easy challenge. God forbid zh :-)

Maybe we're not talking about an entry-level thing, but more about caching already-rendered messages? That obviously raises the question what the cached thing is, as that, in general, still depend on message context, right?

With Message being a list of parts, we could store it resolved and "replace" dynamically just pieces that come from variables maybe? Not sure how far this will take us.

Yeah, that'd be a choice. Or only cache primitive messages.

In the context on fallback, term references might be special, as I can see reasons for them to favor terms in the same language over terms in the overall scheme of things.

How are they special? In MF2.0 we do introduce dynamic references and I imagine you'd want to maintain consistency between message and a referenced message as well (both static and dynamic), am I wrong?

This goes back to what I detailed earlier. The term attributes and parametrized terms only make sense within a particular context. That context is constrained by linguists creating an agreement on which meta data has which meaning. For zh that might be just constrained to the script, for es it probably depends more on the translators following a common ontology to represent Spanish grammar.

The straight-forward MVP approach to this would be to draw the lines across locales. Maybe there's a path for particular ecosystems to opt in to blurring those lines between particular locales.

eemeli commented 2 years ago

For example, a scenario may happen where you request key1 with a variable that resolves (success), and then request again with a variable that fails. If we wanted to fallback onto a second locale in that case, we'd need to do it right at this moment so we wanted to maintain the method as sync.

The way that I want to solve this in MF2 is to 1) more clearly associate each message with an identifiable resource that is loaded in a single operation and 2) require term/message references to identify the resource that they're pointing at in a way that's independent of any runtime variables.

With those constraints, an async resource load operation can traverse its messages and find any external resource references that need to be loaded as dependencies of the current resource. This should allow for the above scenario to be identified already during the resource load.

Another scenario we wanted to explore is a threshold of messages resolvable in a given locale. If a screen has 100 messages and we resolved 90 in locale A, and 10 in locale B, then that's fine. But if we resolved 3 in locale A and 97 in locale B, then maybe it makes sense to translate the whole screen in B?

I wonder if "screen" is the right dimension in which to be considering the implementation of this? Sure, that's almost certainly the desired user experience, but it's rather distant from the spaces in which we actually have messages. Is this perhaps related to the multi-resource "context" you included in the DOM localization draft? I could see that as an appropriate place/level at which to configure this sort of fallback. Implementation-wise, I'm thinking of the context attaching an error handler wrapper to formatting calls of its constituent messages that would track overall failures and trigger fallback when some threshold is reached.

What if message exists, but references another message that doesn't? Should the top message fallback to locale B or stay in A? If stay in A, should it resolve the referenced message from locale B creating a mixed locale message, or return partially resolved message?

We never got to rethink the error fallbacking of messages and if MF2.0 will follow Fluent here, then maybe it is ok to strengthen the model by stating that the fallback to a different locale happens only if a directly requested message is missing?

Which, in return, would mean that if a directly requested message(s) are present we can guarantee that we will not perform I/O and call format synchronously?

I think this level of fallbacking should happen within/around resource loading, rather than message formatting. It should be possible to trigger the fallback based on a side effect of a message formatting call, i.e. in an error handler of some description.

In general, I guess I'm thinking that fallbacking to a different locale should be considered error recovery, and that this means that it's okay to even momentarily render broken strings while we're loading the fallback locale's resources.

zbraniecki commented 2 years ago

In general, I guess I'm thinking that fallbacking to a different locale should be considered error recovery, and that this means that it's okay to even momentarily render broken strings while we're loading the fallback locale's resources.

When designing Fluent we actually weren't certain how much we can commit to such claim.

The alternative, non "error recovery" scenario we explored is something we called "micro-locales" or "partial-locales" - for example Spanish has over 20 dialects. The idea was that we could have a "full" es locale with all resources, and then partial es-CL with just 10-20 strings that differ. Your es-CL langpack would contain full es and partial es-CL and use the fallbacking to load strings at runtime.

The challenge is that it means that 90% of strings would have to fallback and fallback becomes "normal" behavior. There's an alternative approach which most l10n system approach - build a "full" es-CL locale by pulling es strings into it and than at runtime the es-CL is complete.

We wanted to explore the no-build-time, runtime-only approach as more flexible and resilient assuming we can make it performant. We never got to use that system in production, but the way Localization class API is designed was meant to make this model possible as a first-class scenario.

eemeli commented 2 years ago

Okay, that makes sense. For micro-locales, fallbacking like this would indeed need to be treated as a normal rather than exceptional event. This does have us using one fallbacking system for two different purposes, though, and I'm not sure if that's optimal. As in, if the fallback chain would be es-CLesen-US, those two steps would be drastically different.

The way I was imagining this in my head would be that a single [es-CL, es] bundle would get built out of es and es-CL resources, i.e. something like your "alternative approach", and that message resolution within this bundle would not be considered fallback.