Pubmatic multi inference - #8842
Conversation
Greptile SummaryThis PR adds a Pubmatic-specific "multi-inference" feature to Triton Server: a new
Confidence Score: 2/5Not ready to merge: the campaign mapping double-buffer has an unguarded data race that causes UB under concurrent load, and OnShardDone fires before the FINAL-flag check which can send a truncated HTTP reply for any streaming/multi-response model. Two concurrency bugs affect core correctness of the new endpoint. The double-buffer swap in UpdateTritonModelsData clears the old map while request threads may still hold a raw pointer to it — undefined behaviour on every refresh cycle. The multi_infer shard callback calls OnShardDone before checking TRITONSERVER_RESPONSE_COMPLETE_FINAL, so any model producing more than one response fragment causes a premature reply. Additionally, a declared-but-undefined function in the ODBC header will cause a linker error, and the blocking Acquire() has no timeout to handle a stalled database. src/mysql_odbc_connection_pool.cc (data race + blocking Acquire), src/multi_infer.cc (OnShardDone before FINAL flag), src/mysql_odbc_connection_pool.h (undefined function body)
|
| Filename | Overview |
|---|---|
| src/multi_infer.cc | New file implementing POST /v2/multi_infer. Contains a logic bug where OnShardDone is called before the TRITONSERVER_RESPONSE_COMPLETE_FINAL flag is checked, risking premature HTTP replies for streaming/multi-response models. |
| src/mysql_odbc_connection_pool.cc | New ODBC connection pool and campaign model data layer. Has a data race in UpdateTritonModelsData, an indefinitely-blocking Acquire(), and a narrowing cast without range check in ParseApplicableCampaignIds. |
| src/mysql_odbc_connection_pool.h | Header for ODBC pool and feature mapping types. FetchLightgbmFeatureMappingMaxUpdateUnixSeconds is declared but never implemented. |
| src/transform.cc | Transforms impression+campaign JSON into batched float tensors. Logic appears sound; g_ready_model_names is written once at startup and guarded by an atomic flag. |
| src/database_config.cc | Loads JSON database config from /etc/triton-dmconfig.json with careful field parsing and range checking. |
| src/http_server.cc | Adds ScheduleInferAsync helper, FinalizeResponse json_only_out path, and ExtractFirstJsonOutput* methods. Routes POST /v2/multi_infer to HandleMultiInfer. |
| src/main.cc | Loads database config at startup, initializes ODBC pool, starts the 15-minute model refresh thread, and joins it on shutdown. |
| src/http_error_json.h | Lightweight helper to write {error:...} JSON into an evbuffer, replacing duplicate implementations. Correct and clean. |
| src/http_server_macros.h | Extracts shared HTTP response macros into a header. Functionally correct, but included inside a namespace block in multi_infer.cc. |
Sequence Diagram
sequenceDiagram
participant C as HTTP Client
participant H as HTTPAPIServer
participant MI as HandleMultiInfer
participant T as GenerateImpsInferSlots
participant TS as TRITONSERVER_InferAsync
participant A as MultiInferAggregator
participant DB as MySQL (ODBC)
participant RT as RefreshThread (15min)
RT->>DB: FetchLightgbmBtModelsMaxUpdateUnixSeconds
DB-->>RT: timestamp
RT->>DB: FetchLightgbmBtModelsForDc
DB-->>RT: campaign-to-model mappings
RT->>RT: swap double-buffer (g_triton_models_active)
C->>H: "POST /v2/multi_infer {imps:[...]}"
H->>MI: HandleMultiInfer(req)
MI->>T: GenerateImpsInferSlots(doc, server)
T->>T: read ActiveCampaignToFeatureMappings()
T-->>MI: ImpsInferSlot[] + ImpRoutingTable
loop for each model slot
MI->>TS: TRITONSERVER_ServerInferAsync
TS-->>A: InferResponseComplete callback
A->>A: OnShardDone(slot, err, buf)
end
A->>A: WriteHttpReply (when all n_ slots done)
A-->>C: "JSON response {imps:[{camps:[...]}]}"
Reviews (1): Last reviewed commit: "Removed odbc.ini file in Dockerfile" | Re-trigger Greptile
| const int idx = g_triton_models_active.load(std::memory_order_acquire) & 1; | ||
| const int inactive = 1 - idx; | ||
| g_triton_campaign_feature_mappings[inactive] = std::move(by_campaign); | ||
| g_triton_models_active.store(inactive, std::memory_order_release); | ||
| g_triton_campaign_feature_mappings[idx].clear(); | ||
| return std::nullopt; |
There was a problem hiding this comment.
Data race: writer clears the buffer readers are still using
The double-buffer swap sequence is:
- Write
g_triton_campaign_feature_mappings[inactive] - Store
inactiveas the new active index - Clear
g_triton_campaign_feature_mappings[idx]
Between steps 2 and 3, a request thread can call ActiveCampaignToFeatureMappings(), load the old idx, and start iterating over g_triton_campaign_feature_mappings[idx] in GenerateImpsInferSlots. Step 3 then calls .clear() on that same map while the request thread holds a raw pointer to it — this is a data race (undefined behaviour). A read-lock (e.g. std::shared_mutex) is needed to prevent the writer from clearing the old buffer while any reader is still inside it.
| static void InferResponseComplete(TRITONSERVER_InferenceResponse* response, const uint32_t flags, void* userp) { | ||
| auto* infer_request = reinterpret_cast<MultiInferShardRequest*>(userp); | ||
|
|
||
| if (response != nullptr) { | ||
| ++infer_request->response_count_; | ||
| } | ||
|
|
||
| TRITONSERVER_Error* err = nullptr; | ||
| evbuffer* shard_json = nullptr; | ||
| std::vector<std::vector<double>> pre_parsed_rows; | ||
| std::vector<float> pre_parsed_scalars; | ||
| if (infer_request->response_count_ != 1) { | ||
| const std::string msg = std::string("expected a single response, got ") + std::to_string(infer_request->response_count_); | ||
| err = TRITONSERVER_ErrorNew(TRITONSERVER_ERROR_INTERNAL, msg.c_str()); | ||
| } else if (response != nullptr) { | ||
| bool skip_shard_json = false; | ||
| #ifdef TRITON_ENABLE_MYSQL_ODBC | ||
| if (infer_request->aggregator_->WantsShardParsedRows()) { | ||
| const size_t nrows = infer_request->aggregator_->ExpectedRowsForSlot(infer_request->slot_); | ||
| if (nrows > 0u) { | ||
| TRITONSERVER_Error* ex_err = infer_request->ExtractFirstJsonOutputAsScalars(response, nrows, &pre_parsed_scalars); | ||
| if (ex_err == nullptr) { | ||
| skip_shard_json = true; | ||
| } else { | ||
| TRITONSERVER_ErrorDelete(ex_err); | ||
| pre_parsed_scalars.clear(); | ||
| ex_err = infer_request->ExtractFirstJsonOutputAsRowMajorDoubles(response, nrows, &pre_parsed_rows); | ||
| if (ex_err == nullptr) { | ||
| skip_shard_json = true; | ||
| } else { | ||
| TRITONSERVER_ErrorDelete(ex_err); | ||
| pre_parsed_rows.clear(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| #endif | ||
| if (!skip_shard_json) { | ||
| shard_json = evbuffer_new(); | ||
| err = infer_request->FinalizeResponse(response, shard_json); | ||
| } | ||
| #ifdef TRITON_ENABLE_TRACING | ||
| if (infer_request->trace_ != nullptr) { | ||
| infer_request->trace_->CaptureTimestamp("INFER_RESPONSE_COMPLETE", TraceManager::CaptureTimestamp()); | ||
| } | ||
| #endif // TRITON_ENABLE_TRACING | ||
| } | ||
|
|
||
| LOG_TRITONSERVER_ERROR(TRITONSERVER_InferenceResponseDelete(response), "deleting inference response"); | ||
|
|
||
| if (err != nullptr) { | ||
| if (shard_json != nullptr) { | ||
| evbuffer_free(shard_json); | ||
| } | ||
| infer_request->aggregator_->OnShardDone(infer_request->slot_, err, nullptr); | ||
| } else { | ||
| infer_request->aggregator_->OnShardDone(infer_request->slot_, nullptr, shard_json, std::move(pre_parsed_rows), std::move(pre_parsed_scalars)); | ||
| } | ||
|
|
||
| if ((flags & TRITONSERVER_RESPONSE_COMPLETE_FINAL) == 0) { | ||
| return; | ||
| } | ||
| evthr_defer(infer_request->thread_, DeleteMultiInferShardRequestThunk, infer_request); |
There was a problem hiding this comment.
OnShardDone called before FINAL flag is checked — premature reply for streaming models
OnShardDone (which increments done_count_ and potentially triggers WriteHttpReply) is called unconditionally for every response callback, including non-final ones. The TRITONSERVER_RESPONSE_COMPLETE_FINAL flag is only checked afterward (line 636). For any model that delivers more than one response fragment, done_count_ can reach n_ on the first (non-final) callback, causing the HTTP reply to be sent before all sub-requests have actually completed and before the final cleanup runs.
| std::vector<int32_t> applicable_campaigns; | ||
| }; | ||
|
|
||
| std::optional<std::string> FetchLightgbmFeatureMappingMaxUpdateUnixSeconds(int64_t* out_ts); |
There was a problem hiding this comment.
FetchLightgbmFeatureMappingMaxUpdateUnixSeconds declared but never defined
This function is declared in the header but has no matching definition in mysql_odbc_connection_pool.cc. Any translation unit that includes this header and calls this function will produce a linker error. Either provide an implementation or remove the declaration.
| PooledOdbcConnection MysqlOdbcConnectionPool::Acquire() | ||
| { | ||
| std::unique_lock<std::mutex> lk(mu_); | ||
| cv_.wait(lk, [this] { return !free_.empty(); }); | ||
| SQLHDBC dbc = free_.front(); | ||
| free_.pop_front(); | ||
| return PooledOdbcConnection(this, dbc); | ||
| } |
There was a problem hiding this comment.
Acquire() blocks indefinitely — no timeout on the condition variable wait
cv_.wait(lk, predicate) waits forever until a free connection is available. If all connections are tied up in a stalled ODBC query (network partition, unresponsive DB), the calling thread hangs forever with no way to recover or return an error. Consider using cv_.wait_for(lk, timeout, predicate) and returning an empty PooledOdbcConnection on timeout.
| const long v = std::strtol(tok.c_str(), &endptr, 10); | ||
| if (endptr == tok.c_str() || *endptr != '\0') { | ||
| return std::string("invalid campaign id token: ") + tok; | ||
| } | ||
| out->push_back(static_cast<int32_t>(v)); |
There was a problem hiding this comment.
ParseApplicableCampaignIds: no range check before narrowing cast to int32_t
On 64-bit Linux where long is 64 bits, strtol can return values outside [INT32_MIN, INT32_MAX]. The unchecked cast to int32_t silently truncates them, potentially routing traffic to the wrong campaign model.
| const long v = std::strtol(tok.c_str(), &endptr, 10); | |
| if (endptr == tok.c_str() || *endptr != '\0') { | |
| return std::string("invalid campaign id token: ") + tok; | |
| } | |
| out->push_back(static_cast<int32_t>(v)); | |
| const long v = std::strtol(tok.c_str(), &endptr, 10); | |
| if (endptr == tok.c_str() || *endptr != '\0') { | |
| return std::string("invalid campaign id token: ") + tok; | |
| } | |
| if (v < INT32_MIN || v > INT32_MAX) { | |
| return std::string("campaign id out of int32_t range: ") + tok; | |
| } | |
| out->push_back(static_cast<int32_t>(v)); |
| namespace triton { namespace server { | ||
|
|
||
| #include "http_server_macros.h" | ||
|
|
There was a problem hiding this comment.
#include of a macro header inside a namespace block
http_server_macros.h is #included after namespace triton { namespace server { has been opened. Preprocessor directives are not namespace-scoped, so this works, but placing an #include inside an open namespace is confusing and violates common coding conventions. Move it to the top of the translation unit before any namespace is opened.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| std::string BuildMySqlDriverConnectString(const DatabaseConfig& c) | ||
| { | ||
| std::string driver = c.odbc_driver_name; | ||
| if (driver.empty()) { | ||
| driver = "MySQL ODBC 9.7 Unicode Driver"; | ||
| } | ||
| std::ostringstream conn; | ||
| conn << "DRIVER={" << driver << "};" << "SERVER=" << c.database_ip << ";" << "PORT=" << c.database_port << ";" << "UID={" << c.dsn_user_name << "};" << "PWD={" << c.dsn_user_password << "};"; | ||
| return conn.str(); | ||
| } |
There was a problem hiding this comment.
Thanks for submitting a PR to Triton!
Please go the the
Previewtab above this description box and select the appropriate sub-template:If you already created the PR, please replace this message with one of
and fill it out.