-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): support shared token file for distributed workers #655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
38fbc7d
feat(auth): support shared token file for distributed workers
RapidPoseidon 2efaa77
feat(auth): expose get_token for coordinator token export
RapidPoseidon 6a637e4
fix(auth): honor leeway for client credentials and reorder distribute…
RapidPoseidon 36c887e
feat(auth): add maintain_token_file helper for the coordinator loop
RapidPoseidon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| # Distributed Training | ||
|
|
||
| When you use Rapidata from a large distributed job — for example a ranking | ||
| flow queried from hundreds or thousands of GPU workers — don't let every | ||
| worker authenticate on its own. Each `RapidataClient()` exchanges the client | ||
| credentials for an access token at startup, and because all those tokens | ||
| expire at the same time (~1 hour later), every worker hits the auth server | ||
| again in the same instant. At scale this looks like a coordinated burst and | ||
| leads to rate limiting and failed requests. | ||
|
|
||
| The fix is to authenticate **once** and share the token: | ||
|
|
||
| - A single **coordinator** process (your launcher, or rank 0) holds the | ||
| client credentials. It fetches the access token, refreshes it *before* it | ||
| expires, and writes it to a file on storage all workers can read (a shared | ||
| filesystem like NFS, or a mounted volume). | ||
| - Every **worker** points the SDK at that file. Workers never see the client | ||
| secret, make no auth-server calls of their own, and automatically re-read | ||
| the file when the current token expires. | ||
|
|
||
| ## Coordinator | ||
|
|
||
| Start the coordinator first — it authenticates with the client credentials | ||
| as usual (see [Authentication](authentication.md)) and keeps the shared file | ||
| fresh with `maintain_token_file()`: | ||
|
|
||
| ```python | ||
| from rapidata import RapidataClient | ||
|
|
||
| coordinator = RapidataClient(leeway=300) # (1)! | ||
| coordinator.maintain_token_file("/shared/rapidata_token.json").join() # (2)! | ||
| ``` | ||
|
|
||
| 1. The coordinator is the only process that holds the client credentials — | ||
| via `RAPIDATA_CLIENT_ID` / `RAPIDATA_CLIENT_SECRET` or the cached | ||
| credentials file. `leeway=300` renews the token once it is within | ||
| 5 minutes of expiry, well before workers need a new one. | ||
| 2. Writes the file immediately, then keeps rewriting it atomically from a | ||
| background thread (every 60 seconds by default), creating the directory | ||
| if needed. `.join()` blocks the process forever — drop it if your | ||
| coordinator also does other work, e.g. when rank 0 both trains and | ||
| refreshes the token. The root-level `/shared` is just for the example — | ||
| use whatever storage all your workers actually mount (an NFS path, a | ||
| shared volume, …). | ||
|
|
||
| !!! warning "Keep the token file secure" | ||
| The file contains a bearer token — anyone who can read it can act on | ||
| your Rapidata account until the token expires. Write it only to storage | ||
| that your training job alone can access, and restrict the file | ||
| permissions accordingly. | ||
|
|
||
| ### Writing the file yourself | ||
|
|
||
| If the built-in helper doesn't fit (different storage backend, custom | ||
| rotation), export the token with `get_token()` and write it however you | ||
| like. `get_token()` is cheap to call at any frequency: it returns the | ||
| current token and only contacts the auth server once the token is within | ||
| `leeway` of expiry. | ||
|
|
||
| ```python | ||
| import json | ||
| import os | ||
| import time | ||
|
|
||
| from rapidata import RapidataClient | ||
|
|
||
| TOKEN_FILE = "/shared/rapidata_token.json" | ||
|
|
||
| coordinator = RapidataClient(leeway=300) | ||
|
|
||
| os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) | ||
|
|
||
|
|
||
| def write_token(token: dict) -> None: | ||
| tmp_path = TOKEN_FILE + ".tmp" | ||
| with open(tmp_path, "w") as f: | ||
| json.dump(token, f) | ||
| # Atomic replace: a worker reading concurrently sees either the old | ||
| # token or the new one, never a partially written file. | ||
| os.replace(tmp_path, TOKEN_FILE) | ||
|
|
||
|
|
||
| while True: | ||
| write_token(coordinator.get_token()) | ||
| time.sleep(60) | ||
| ``` | ||
|
|
||
| Two things to keep when rolling your own: | ||
|
|
||
| - **Write atomically** (temp file + `os.replace`, as above) so workers never | ||
| read a half-written file. | ||
| - **Keep the absolute `expires_at` field** in the JSON — it's how workers | ||
| decide when to re-read the file. If you produce the token without the SDK | ||
| (e.g. a `curl` call to the token endpoint), the response only carries the | ||
| relative `expires_in`, so add `expires_at` yourself. | ||
|
|
||
| ## Workers | ||
|
|
||
| Once the coordinator has written the file, workers pass its path to the | ||
| client — or set the `RAPIDATA_TOKEN_FILE` environment variable and construct | ||
| the client with no arguments: | ||
|
|
||
| ```python | ||
| from rapidata import RapidataClient | ||
|
|
||
| client = RapidataClient(token_file="/shared/rapidata_token.json") | ||
| ``` | ||
|
|
||
| The SDK reads the token from the file at startup and re-reads it whenever the | ||
| in-memory token is within 60 seconds of expiry (configurable via `leeway`). | ||
| As long as the coordinator keeps the file fresh, workers never need to be | ||
| restarted or re-authenticated. | ||
|
|
||
| ## Practical tips | ||
|
|
||
| - **Stagger worker startup.** Even with a shared token, thousands of workers | ||
| constructing their first client and firing their first API call in the | ||
| same second is an unnecessary burst. A few seconds of random jitter before | ||
| the first call smooths it out. | ||
| - **Create one client per process and keep it.** The client refreshes its | ||
| token from the file transparently; there is no need to reconstruct it. | ||
| - **Keep the coordinator alive for the whole run.** If it stops refreshing, | ||
| workers fail with an invalid-token error once the token in the file | ||
| expires. | ||
|
|
||
| ## Older SDK versions | ||
|
|
||
| Before `token_file` was available, the same pattern worked by passing the | ||
| token dict directly — but the SDK never re-reads the file, so each worker | ||
| must construct a new client when the token expires: | ||
|
|
||
| ```python | ||
| import json | ||
|
|
||
| from rapidata import RapidataClient | ||
|
|
||
| with open("/shared/rapidata_token.json") as f: | ||
| client = RapidataClient(token=json.load(f)) | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering whether we want to implement this as well. I mean it's quite boilerplatey. We could have a method that can take a path and a duration and we would continously write to it. If the customer would want to write the token differently they could still write this part themselves. But i think having a default implementation would make adoption of this pattern easier
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed — added
RapidataClient.maintain_token_file(path, interval=60)in 36c887e. It writes the file once synchronously (so it's guaranteed to exist — or fail loudly — before workers start), creates the directory, then keeps rewriting it atomically from a daemon thread; transient failures inside the loop are logged and retried on the next interval instead of killing the thread. It returns the thread so a dedicated coordinator can just.join()it, while a rank-0-that-also-trains drops the join. The manual loop moved to a 'Writing the file yourself' subsection for custom setups.