reuseOrInvalidate method

Future<EmbeddingModel?> reuseOrInvalidate(
  1. ActiveEmbedderParams requested, {
  2. required String label,
})

The cached embedder when it matches requested, else null for "build one".

A mismatch closes the cached model before returning, so the caller only ever has to handle "reuse this" or "build a new one".

"Build one" is never answered while a close the cache started or joined is still running, so a replacement never shares memory with the model it replaces while that model is being torn down. That holds for models whose close() returns the teardown already in progress, as CommonEmbeddingModel's does; a model whose second close() returns at once can only be waited for by the caller that closed it. It is no guarantee against what a close leaves behind: a worker that died, or a native close that failed, may leave its model resident, and only a warning says so.

The wait is bounded by one deadline, set when the close starts: callers share it rather than each waiting the full limit. Past it a caller gets a TimeoutException naming the model still closing, at once and until the close finishes. That close keeps running — nothing is killed, since a killed worker never frees its native model. Unbounded, one wedged native call would hang every embedder request in the process behind serialize.

Implementation

Future<EmbeddingModel?> reuseOrInvalidate(
  ActiveEmbedderParams requested, {
  required String label,
}) async {
  // A close that outlived an earlier caller's wait is still running. With it
  // pending there is no cached model, so this caller would build — and must
  // not until that close is done.
  await _awaitPendingClose(label);

  final cached = _cached;
  if (cached == null) return null;

  // Checked, not trusted. Eviction rides the close listener, so a model that
  // never fires one — or that was already closed when it was recorded, after
  // which `fireCloseListeners` has nothing left to call — would be handed to
  // every later caller, and every `generateEmbedding` on it throws. Only as
  // good as the model's own `isClosed`: the interface default is false, for
  // implementations that predate it.
  if (cached.model.isClosed) {
    edgeAiLog('ℹ️  Cached embedder is closed; building a new one for $label');
    _cached = null;
    // Closed by someone else — the app, often without awaiting, or a worker
    // that died — so its teardown may still be running. `close()` again
    // joins it (CommonEmbeddingModel hands every caller the same teardown),
    // and the rebuild waits for it like any other.
    await _closeAndWait(cached, label);
    return null;
  }

  final changedParam = cached.params.firstDifference(requested);
  if (changedParam == null) {
    edgeAiLog('ℹ️  Reusing existing embedding model instance for $label');
    return cached.model;
  }

  edgeAiLog(
    '⚠️  Embedder config changed ($changedParam) for $label — rebuilding',
  );
  // Dropped BEFORE the await, not after: while a close is in flight the
  // cached model is no longer a valid answer to anybody.
  _cached = null;
  edgeAiLog('🔄 Closing old embedding model and creating new one...');
  await _closeAndWait(cached, label);
  return null;
}