Get started

Engineering NotesVoice and meetings

Ending a meeting drops the last remarks; audio waits for transcription

When a meeting ends, some of its sound has not been transcribed yet. A live meeting transcribes one window each time a 60-second slot fills (“Overlapping meetings stall transcripts, so split queues by resource”), and the last slot always ends short of a full interval. With the default 60-second interval that stretch averages 30 seconds; on OwlAI SaaS, which uses a 5-minute interval, it averages 2 minutes 30 seconds. Some windows may also still be waiting on a speech recognition retry. OwlAI deletes original audio once a meeting ends, so if the audio goes before these stretches are transcribed, the meeting's last remarks cannot be recovered. The same loss happens when the minutes model stops answering for a few minutes and the job queue counts that as a failure: the meeting is closed and its audio is deleted with it.

This post covers the order in which the meeting close-out job transcribes every remaining stretch before it deletes the audio. It also explains how counting waits and failures separately keeps a temporary outage from turning into deleted audio.

Four kinds of stretch are untranscribed when a meeting ends

When you press End meeting, the meeting moves to Finalizing, and after a 10-second grace period (finalizeGraceSeconds) a single close-out job takes over the remaining work. The browser uploads the recording in 5-second chunks, and the grace period gives the last of them time to arrive. The server writes each chunk to object storage as soon as it arrives, so even if the tab closes or the web process is replaced, the close-out job can read everything up to the last uploaded chunk. At this point there are four kinds of stretch with no transcript.

Stretch Why it is left over What the close-out job does
The last stretch A window opens only once its interval has filled Confirms it as a window even though the interval is short, and transcribes it directly
A window with missing audio Sleep mode or a dropped network left gaps in the chunks Since the grace period has passed and no more chunks will come, transcribes only what arrived
A window whose job failed to register The database connection dropped right after the window was reserved, as the job was being queued No job will ever arrive, so transcribes it directly
A window in a speech recognition retry All the audio arrived, but the model call failed Waits until the retry finishes

Waiting changes nothing for the first three; only the fourth finishes if you wait. The fourth is the tricky one because of queue order. Window transcription jobs are queued without a per-meeting lock, so the queue does not put the close-out job behind them. Speech recognition retries after 15, 30, and 45 seconds, so a single model failure is enough for the close-out job to run first.

Three common designs all delete the audio first

The simplest close-out has two steps: generate the minutes, then delete the audio. All four stretches in the table above disappear without a transcript.

Queuing the last stretch as an ordinary transcription job, like any other window, also seems natural, since it keeps a single transcription path. But the close-out job has no way to wait for another queued job to finish, and if it cannot wait, it deletes the audio before that job runs.

Another option is to wait for transcription by riding the job queue's retries. Raise an exception and the queue runs the job again at set intervals, so the code is short. But the retry count measures "failed while processing this meeting." If waiting spends it, the last attempt arrives that much sooner, and a failure on the last attempt closes the meeting and deletes the audio.

The close-out job deletes audio only after transcription is done

The close-out job moves in this order and no other.

  1. Settle windows that have no transcript. Windows with missing audio and windows whose job failed to register are confirmed here.
  2. If any window is still in a speech recognition retry, requeue the close-out job 15 seconds later and end the current run normally. The wait lasts at most 10 minutes.
  3. Transcribe the last stretch directly.
  4. Generate the minutes.
  5. Delete the audio and mark the meeting complete.
Order of the meeting close-out job and the wait and retry paths at each step
Figure 1. Order of the meeting close-out job and the wait and retry paths at each step

The wait cap is measured as real time elapsed since the wait began. Adding 15 seconds per requeue is simpler arithmetic, but it leaves out each run's execution time and queue wait, and the 10-minute cap stretches to several times its setting. The cap has to exist because a window whose job vanished from the queue never finishes. When the cap is reached, the close-out job moves on without that window and records the fact in the logs.

Windows without a transcript are counted two ways: those still being attempted, and those that used up their retries and won't be tried again. Whether to wait depends only on the first. Waiting on windows that won't be retried means the meeting never ends. The warning logged when audio is deleted counts both. A window that won't be retried has no text, so once its audio is gone nothing of that stretch remains. Answer both questions with one number, and the very stretches lost by deleting the audio drop out of the warning.

If transcribing the last stretch fails, only that window is marked as failed and the job moves to the next step. By this point the rest of the meeting has already been transcribed. Holding the whole meeting in Finalizing over one window would keep the user from getting minutes at all.

Waits and temporary outages do not spend retries

The close-out job has 5 retries, with intervals growing by 15 seconds each time (15, 30, 45 seconds). It spends them only on failures. The table shows how each situation is counted.

Situation Retries Requeue interval Cap When the cap is reached
A window is still in a speech recognition retry Not spent 15 seconds 10 minutes Moves on without that window and logs it
The minutes model is unreachable Not spent 1 minute 1 hour The meeting is marked failed
Usage or billing limit reached Not spent Not requeued — Completes without minutes
A worker died and the job stalled Spent Reclaimed every 1 or 5 minutes 3 reclaims The meeting is marked failed
Any other error Spent 15, 30, 45 seconds… Whatever is left of the 5 retries The meeting is marked failed and the audio deleted

Failing to reach the minutes model doesn't count as this meeting's failure. Failures classified as connection failure, timeout, rate limit, 5xx, or overload are requeued every minute for up to an hour. That is far longer than the transcription wait because the thing being waited on recovers from outside. Set the cap short, and a model that is about to come back gets a healthy meeting marked failed and its audio deleted. If the model isn't back after an hour, the problem is no longer one for automatic recovery; it is treated as an operational outage and the meeting is closed. The classification uses the same rules as chat's failure handling. Failures that can't be classified are kept out of this wait. Including them would make product defects look like outside outages, and the meeting would repeat a retry with the same outcome for an hour.

When the usage limit or a provider's billing limit runs out, the result stays the same until the limit resets. By this point transcription is done through the last stretch, so instead of marking the meeting failed, the close-out job completes it without minutes. The minutes area of the meeting screen shows why there are none, and once the limit is back, the user creates the minutes with Regenerate. This check runs before the outside-outage check. One provider reports an exhausted quota as a 429, and in the reverse order that would be read as a temporary rate limit and an hour would be spent waiting for nothing.

If a worker dies mid-job, the job is left stalled in the running state. This happens every time a deployment replaces the container. The worker process every minute, and a periodic cleanup job every 5 minutes, look for stalled jobs and return them to the queue. The same job is returned rather than a new one queued, because the stalled job holds the meeting's lock and a new job could never start. Each return adds one to the retry count, so the close-out job's 5 retries are 2 for errors plus 3 for reclaims. Without the reclaim share, a job reclaimed once after a deployment would reach its last attempt on the next single error and delete the audio. Reclaims have a cap too, because a job that kills workers would kill a worker again every time it was returned.

Any other error re-raises the exception and rides the queue's retry while retries remain. The audio is kept in the meantime, so the next attempt can transcribe again from the last stretch. If the last attempt also fails, the meeting is marked failed, the screen shows "This meeting stopped before it finished" with the reason, and the audio is deleted.

After a database restart, dead connections are discarded and the close-out job fills in missed windows

When the database restarts for an upgrade or failover, every connection the app was holding is cut. On the worker side, the job queue library checks that a connection is alive when it takes one from its pool. The web process keeps running and uses a separate connection pool to queue jobs. If that pool hands out connections without checking them, dead ones stay in line until they fail the next job registration. During a meeting, that means the transcription job for the window that just filled never reaches the queue.

So the job registration pool runs an empty query on every checkout to confirm the connection is alive, discards dead ones, and moves to the next. If it can't get a live connection within 5 seconds, it fails immediately. A window that still fails to register is found by the close-out job, as described above, and transcribed directly. The connection check reduces missed windows, and the close-out job fills in the ones that were missed while the audio still exists.

Design choices

Instead of keeping audio from failed meetings, we delete it there too. Keeping it would leave a way to transcribe it again after the cause is fixed. But that exception would break the promise that original audio is not retained after a meeting ends, once for every failed meeting. Instead, the order leading up to deletion is laid out tightly enough that a brief pause in the minutes model or a worker replaced by a deployment never reaches a failure. When a meeting is marked failed, or its audio is deleted while some stretch has no transcript, that shows up in the logs and as a reason on the meeting screen.

We tied the moment of audio deletion to finished transcription rather than to successful minutes. As long as the transcript remains, minutes can be rewritten without the audio. So even when there are no minutes because a limit ran out or the model returned an empty response, the meeting completes with its full transcript, and the minutes can be created later with Regenerate.

The last stretch is transcribed inside the close-out job rather than queued as a separate job. The cost is that the close-out job competes with live transcription for the GPU, which is why the close-out queue has low concurrency (“Overlapping meetings stall transcripts, so split queues by resource”).

The transcription wait (10 minutes by default) and the minutes-model wait (one hour by default) have different lengths on purpose. Retries for a window transcription job itself are kept short, finishing within about 5 minutes. They are requests to a single GPU, so when failures cluster, retries become load. The minutes model is waiting to recover from outside, so its wait is long. Both waits are installation settings. The longer they are, the longer meetings stay in Finalizing; the shorter, the more meetings are marked failed because of a model that was about to come back. If you self-host the minutes model and restart its server often, set the model wait comfortably longer than a restart takes so that restarts don't turn into deleted audio.