blob: 0f81e394132b38908c9924959ac829614c085126 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.adservices.service.adselection;
import static com.android.adservices.service.js.JSScriptArgument.arrayArg;
import static com.android.adservices.service.js.JSScriptArgument.jsonArg;
import static com.android.adservices.service.js.JSScriptArgument.recordArg;
import static com.android.adservices.service.js.JSScriptArgument.stringArg;
import static com.android.adservices.service.js.JSScriptArgument.stringArrayArg;
import static com.google.common.util.concurrent.Futures.transform;
import android.adservices.adselection.AdSelectionConfig;
import android.adservices.adselection.AdWithBid;
import android.adservices.common.AdData;
import android.adservices.common.AdSelectionSignals;
import android.annotation.NonNull;
import android.content.Context;
import com.android.adservices.LoggerFactory;
import com.android.adservices.data.adselection.CustomAudienceSignals;
import com.android.adservices.data.common.DBAdData;
import com.android.adservices.data.customaudience.DBCustomAudience;
import com.android.adservices.service.exception.JSExecutionException;
import com.android.adservices.service.js.IsolateSettings;
import com.android.adservices.service.js.JSScriptArgument;
import com.android.adservices.service.js.JSScriptEngine;
import com.android.adservices.service.profiling.Tracing;
import com.android.adservices.service.stats.AdSelectionExecutionLogger;
import com.android.adservices.service.stats.RunAdBiddingPerCAExecutionLogger;
import com.android.internal.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* Utility class to execute an auction script. Current implementation is thread safe but relies on a
* singleton JS execution environment and will serialize calls done either using the same or
* different instances of {@link AdSelectionScriptEngine}. This will change once we will use the new
* WebView API.
*
* <p>This class is thread safe but, for performance reasons, it is suggested to use one instance
* per thread. See the threading comments for {@link JSScriptEngine}.
*/
public class AdSelectionScriptEngine {
private static final LoggerFactory.Logger sLogger = LoggerFactory.getFledgeLogger();
private static final String JS_EXECUTION_STATUS_UNSUCCESSFUL =
"Outcome selection script failed with status '%s' or returned unexpected result '%s'";
private static final String JS_EXECUTION_RESULT_INVALID =
"Result of outcome selection script result is invalid: %s";
// TODO: (b/228094391): Put these common constants in a separate class
private static final String SCRIPT_ARGUMENT_NAME_IGNORED = "ignored";
public static final String FUNCTION_NAMES_ARG_NAME = "__rb_functionNames";
public static final String RESULTS_FIELD_NAME = "results";
public static final String STATUS_FIELD_NAME = "status";
// This is a local variable and doesn't need any prefix.
public static final String CUSTOM_AUDIENCE_ARG_NAME = "__rb_custom_audience";
public static final String AD_VAR_NAME = "ad";
public static final String ADS_ARG_NAME = "__rb_ads";
public static final String AUCTION_SIGNALS_ARG_NAME = "__rb_auction_signals";
public static final String PER_BUYER_SIGNALS_ARG_NAME = "__rb_per_buyer_signals";
public static final String TRUSTED_BIDDING_SIGNALS_ARG_NAME = "__rb_trusted_bidding_signals";
public static final String CONTEXTUAL_SIGNALS_ARG_NAME = "__rb_contextual_signals";
public static final String CUSTOM_AUDIENCE_BIDDING_SIGNALS_ARG_NAME =
"__rb_custom_audience_bidding_signals";
public static final String CUSTOM_AUDIENCE_SCORING_SIGNALS_ARG_NAME =
"__rb_custom_audience_scoring_signals";
public static final String AUCTION_CONFIG_ARG_NAME = "__rb_auction_config";
public static final String SELLER_SIGNALS_ARG_NAME = "__rb_seller_signals";
public static final String TRUSTED_SCORING_SIGNALS_ARG_NAME = "__rb_trusted_scoring_signals";
public static final String GENERATE_BID_FUNCTION_NAME = "generateBid";
public static final String SCORE_AD_FUNCTION_NAME = "scoreAd";
public static final String USER_SIGNALS_ARG_NAME = "__rb_user_signals";
/**
* Template for the iterative invocation function. The two tokens to expand are the list of
* parameters and the invocation of the actual per-ad function.
*/
public static final String AD_SELECTION_ITERATIVE_PROCESSING_JS =
"function "
+ JSScriptEngine.ENTRY_POINT_FUNC_NAME
+ "(%s) {\n"
+ " let status = 0;\n"
+ " const results = []; \n"
+ " for (const "
+ AD_VAR_NAME
+ " of "
+ ADS_ARG_NAME
+ ") {\n"
+ " //Short circuit the processing of all ads if there was any failure.\n"
+ " const script_result = %s;\n"
+ " if (script_result === Object(script_result) && \n"
+ " 'status' in script_result) {\n"
+ " status = script_result.status;\n"
+ " } else {\n"
+ " // invalid script\n"
+ " status = -1;\n"
+ " } \n"
+ " if (status != 0) break;\n"
+ " results.push(script_result);\n"
+ " }\n"
+ " return { 'status': status, 'results': results};\n"
+ "};";
/**
* Template for the batch invocation function. The two tokens to expand are the list of
* parameters and the invocation of the actual per-ad function.
*/
public static final String AD_SELECTION_BATCH_PROCESSING_JS =
"function "
+ JSScriptEngine.ENTRY_POINT_FUNC_NAME
+ "(%s) {\n"
+ " let status = 0;\n"
+ " const results = []; \n"
+ " const script_result = %s;\n"
+ " if (script_result === Object(script_result) && \n"
+ " 'status' in script_result && \n"
+ " 'result' in script_result) {\n"
+ " status = script_result.status;\n"
+ " results.push(script_result.result)\n"
+ " } else {\n"
+ " // invalid script\n"
+ " status = -1;\n"
+ " }\n"
+ " return { 'status': status, 'results': results};\n"
+ "};";
public static final String AD_SELECTION_GENERATE_BID_JS_V3 =
"function "
+ JSScriptEngine.ENTRY_POINT_FUNC_NAME
+ "(%s) {\n"
+ " let status = 0;\n"
+ " let results = null;\n"
+ " const script_result = %s;\n"
+ " if (script_result === Object(script_result) &&\n"
+ " 'ad' in script_result &&\n"
+ " 'bid' in script_result &&\n"
+ " 'render' in script_result) {\n"
+ " results = [{'ad': script_result.ad,'bid': script_result.bid},];\n"
+ " } else {\n"
+ " // invalid script\n"
+ " status = -1;\n"
+ " }\n"
+ " return { 'status': status, 'results': results };\n"
+ "};";
public static final String CHECK_FUNCTIONS_EXIST_JS =
"function "
+ JSScriptEngine.ENTRY_POINT_FUNC_NAME
+ "(names) {\n"
+ " for (const name of names) {\n"
+ " try {\n"
+ " if (typeof eval(name) != 'function') return false;\n"
+ " } catch(e) {\n"
+ " if (e instanceof ReferenceError) return false;\n"
+ " }\n"
+ " }\n"
+ " return true;\n"
+ "}";
public static final String GET_FUNCTION_ARGUMENT_COUNT =
"function "
+ JSScriptEngine.ENTRY_POINT_FUNC_NAME
+ "(names) {\n"
+ " for (const name of names) {\n"
+ " try {\n"
+ " if (typeof eval(name) != 'function') return -1;\n"
+ " } catch(e) {\n"
+ " if (e instanceof ReferenceError) return -1;\n"
+ " }\n"
+ " if (typeof eval(name) === 'function') return eval(name).length;\n"
+ " }\n"
+ " return -1;\n"
+ "}";
private static final String TAG = AdSelectionScriptEngine.class.getName();
private static final int JS_SCRIPT_STATUS_SUCCESS = 0;
private static final String ARG_PASSING_SEPARATOR = ", ";
private final JSScriptEngine mJsEngine;
// Used for the Futures.transform calls to compose futures.
private final Executor mExecutor = MoreExecutors.directExecutor();
private final Supplier<Boolean> mEnforceMaxHeapSizeFeatureSupplier;
private final Supplier<Long> mMaxHeapSizeBytesSupplier;
public AdSelectionScriptEngine(
Context context,
Supplier<Boolean> enforceMaxHeapSizeFeatureSupplier,
Supplier<Long> maxHeapSizeBytesSupplier) {
mJsEngine = JSScriptEngine.getInstance(context);
mEnforceMaxHeapSizeFeatureSupplier = enforceMaxHeapSizeFeatureSupplier;
mMaxHeapSizeBytesSupplier = maxHeapSizeBytesSupplier;
}
/**
* @return The result of invoking the {@code generateBid} function in the given {@code
* generateBidJS} JS script for the list of {@code ads} and signals provided. Will return an
* empty list if the script fails for any reason.
* @throws JSONException If any of the signals is not a valid JSON object.
*/
public ListenableFuture<List<AdWithBid>> generateBids(
@NonNull String generateBidJS,
@NonNull List<AdData> ads,
@NonNull AdSelectionSignals auctionSignals,
@NonNull AdSelectionSignals perBuyerSignals,
@NonNull AdSelectionSignals trustedBiddingSignals,
@NonNull AdSelectionSignals contextualSignals,
@NonNull CustomAudienceSignals customAudienceSignals,
@NonNull RunAdBiddingPerCAExecutionLogger runAdBiddingPerCAExecutionLogger)
throws JSONException {
Objects.requireNonNull(generateBidJS);
Objects.requireNonNull(ads);
Objects.requireNonNull(auctionSignals);
Objects.requireNonNull(perBuyerSignals);
Objects.requireNonNull(trustedBiddingSignals);
Objects.requireNonNull(contextualSignals);
Objects.requireNonNull(customAudienceSignals);
Objects.requireNonNull(runAdBiddingPerCAExecutionLogger);
int traceCookie = Tracing.beginAsyncSection(Tracing.GENERATE_BIDS);
ImmutableList<JSScriptArgument> signals =
ImmutableList.<JSScriptArgument>builder()
.add(jsonArg(AUCTION_SIGNALS_ARG_NAME, auctionSignals.toString()))
.add(jsonArg(PER_BUYER_SIGNALS_ARG_NAME, perBuyerSignals.toString()))
.add(
jsonArg(
TRUSTED_BIDDING_SIGNALS_ARG_NAME,
trustedBiddingSignals.toString()))
.add(jsonArg(CONTEXTUAL_SIGNALS_ARG_NAME, contextualSignals.toString()))
.add(
CustomAudienceBiddingSignalsArgument.asScriptArgument(
CUSTOM_AUDIENCE_BIDDING_SIGNALS_ARG_NAME,
customAudienceSignals))
.build();
ImmutableList.Builder<JSScriptArgument> adDataArguments = new ImmutableList.Builder<>();
for (AdData currAd : ads) {
// Ads are going to be in an array their individual name is ignored.
adDataArguments.add(AdDataArgument.asScriptArgument("ignored", currAd));
}
runAdBiddingPerCAExecutionLogger.startGenerateBids();
return FluentFuture.from(
transform(
runAuctionScriptIterative(
generateBidJS,
adDataArguments.build(),
signals,
this::callGenerateBid),
result -> {
List<AdWithBid> bids = handleGenerateBidsOutput(result);
runAdBiddingPerCAExecutionLogger.endGenerateBids();
Tracing.endAsyncSection(Tracing.GENERATE_BIDS, traceCookie);
return bids;
},
mExecutor))
.catchingAsync(
JSExecutionException.class,
e -> {
Tracing.endAsyncSection(Tracing.GENERATE_BIDS, traceCookie);
sLogger.e(
e,
"Encountered exception when generating bids, attempting to run"
+ " backward compatible JS");
return handleBackwardIncompatibilityScenario(
generateBidJS,
signals,
adDataArguments.build(),
runAdBiddingPerCAExecutionLogger,
e);
},
mExecutor);
}
/**
* @return The result of invoking the {@code generateBidV3} function in the given {@code
* generateBidJS} JS script for the args provided. Will return an empty list if the script
* fails for any reason.
* @throws JSONException If any of the signals is not a valid JSON object.
*/
@NonNull
public ListenableFuture<List<AdWithBid>> generateBidsV3(
@NonNull String generateBidJS,
@NonNull DBCustomAudience customAudience,
@NonNull AdSelectionSignals auctionSignals,
@NonNull AdSelectionSignals perBuyerSignals,
@NonNull AdSelectionSignals trustedBiddingSignals,
@NonNull AdSelectionSignals contextualSignals,
@NonNull RunAdBiddingPerCAExecutionLogger runAdBiddingPerCAExecutionLogger)
throws JSONException {
Objects.requireNonNull(generateBidJS);
Objects.requireNonNull(customAudience);
Objects.requireNonNull(auctionSignals);
Objects.requireNonNull(perBuyerSignals);
Objects.requireNonNull(trustedBiddingSignals);
Objects.requireNonNull(contextualSignals);
Objects.requireNonNull(runAdBiddingPerCAExecutionLogger);
int traceCookie = Tracing.beginAsyncSection(Tracing.GENERATE_BIDS);
ImmutableList<JSScriptArgument> signals =
ImmutableList.<JSScriptArgument>builder()
.add(translateCustomAudience(customAudience))
.add(jsonArg(AUCTION_SIGNALS_ARG_NAME, auctionSignals))
.add(jsonArg(PER_BUYER_SIGNALS_ARG_NAME, perBuyerSignals))
.add(jsonArg(TRUSTED_BIDDING_SIGNALS_ARG_NAME, trustedBiddingSignals))
.add(jsonArg(CONTEXTUAL_SIGNALS_ARG_NAME, contextualSignals))
.build();
runAdBiddingPerCAExecutionLogger.startGenerateBids();
return FluentFuture.from(
transform(
runAuctionScriptGenerateBidV3(
generateBidJS, signals, this::callGenerateBidV3),
result -> {
List<AdWithBid> bids = handleGenerateBidsOutput(result);
runAdBiddingPerCAExecutionLogger.endGenerateBids();
Tracing.endAsyncSection(Tracing.GENERATE_BIDS, traceCookie);
return bids;
},
mExecutor));
}
/**
* @return The scored ads for this custom audiences given the list of Ads with associated bid
* and the set of signals. Will return an empty list if the script fails for any reason.
* @throws JSONException If any of the data is not a valid JSON object.
*/
public ListenableFuture<List<Double>> scoreAds(
@NonNull String scoreAdJS,
@NonNull List<AdWithBid> adsWithBid,
@NonNull AdSelectionConfig adSelectionConfig,
@NonNull AdSelectionSignals sellerSignals,
@NonNull AdSelectionSignals trustedScoringSignals,
@NonNull AdSelectionSignals contextualSignals,
@NonNull List<CustomAudienceSignals> customAudienceSignalsList,
@NonNull AdSelectionExecutionLogger adSelectionExecutionLogger)
throws JSONException {
Objects.requireNonNull(scoreAdJS);
Objects.requireNonNull(adsWithBid);
Objects.requireNonNull(adSelectionConfig);
Objects.requireNonNull(sellerSignals);
Objects.requireNonNull(trustedScoringSignals);
Objects.requireNonNull(contextualSignals);
Objects.requireNonNull(customAudienceSignalsList);
Objects.requireNonNull(adSelectionExecutionLogger);
ImmutableList<JSScriptArgument> args =
ImmutableList.<JSScriptArgument>builder()
.add(
AdSelectionConfigArgument.asScriptArgument(
adSelectionConfig, AUCTION_CONFIG_ARG_NAME))
.add(jsonArg(SELLER_SIGNALS_ARG_NAME, sellerSignals.toString()))
.add(
jsonArg(
TRUSTED_SCORING_SIGNALS_ARG_NAME,
trustedScoringSignals.toString()))
.add(jsonArg(CONTEXTUAL_SIGNALS_ARG_NAME, contextualSignals.toString()))
.add(
CustomAudienceScoringSignalsArgument.asScriptArgument(
CUSTOM_AUDIENCE_SCORING_SIGNALS_ARG_NAME,
customAudienceSignalsList))
.build();
ImmutableList.Builder<JSScriptArgument> adWithBidArguments = new ImmutableList.Builder<>();
for (AdWithBid currAdWithBid : adsWithBid) {
// Ad with bids are going to be in an array their individual name is ignored.
adWithBidArguments.add(
AdWithBidArgument.asScriptArgument(
SCRIPT_ARGUMENT_NAME_IGNORED, currAdWithBid));
}
// Start scoreAds script execution process.
adSelectionExecutionLogger.startScoreAds();
return FluentFuture.from(
runAuctionScriptIterative(
scoreAdJS, adWithBidArguments.build(), args, this::callScoreAd))
.transform(
result -> handleScoreAdsOutput(result, adSelectionExecutionLogger),
mExecutor);
}
/**
* Runs selection logic on map of {@code long} ad selection id {@code double} bid
*
* @return either one of the ad selection ids passed in {@code adSelectionIdBidPairs} or {@code
* null}
* @throws JSONException if any input or the result is failed to parse
* @throws IllegalStateException If JS script fails to run or returns an illegal results (i.e.
* two ad selection ids or empty)
*/
public ListenableFuture<Long> selectOutcome(
@NonNull String selectionLogic,
@NonNull List<AdSelectionIdWithBidAndRenderUri> adSelectionIdWithBidAndRenderUris,
@NonNull AdSelectionSignals selectionSignals)
throws JSONException, IllegalStateException {
Objects.requireNonNull(selectionLogic);
Objects.requireNonNull(adSelectionIdWithBidAndRenderUris);
Objects.requireNonNull(selectionSignals);
ImmutableList<JSScriptArgument> args =
ImmutableList.<JSScriptArgument>builder()
.add(jsonArg("selection_signals", selectionSignals.toString()))
.build();
sLogger.v("Other args creates " + args);
ImmutableList.Builder<JSScriptArgument> adSelectionIdWithBidArguments =
new ImmutableList.Builder<>();
for (AdSelectionIdWithBidAndRenderUri curr : adSelectionIdWithBidAndRenderUris) {
// Ad with bids are going to be in an array their individual name is ignored.
adSelectionIdWithBidArguments.add(
SelectAdsFromOutcomesArgument.asScriptArgument(
SCRIPT_ARGUMENT_NAME_IGNORED, curr));
}
ImmutableList<JSScriptArgument> advertArgs = adSelectionIdWithBidArguments.build();
sLogger.v("Advert args created " + advertArgs);
return transform(
runAuctionScriptBatch(selectionLogic, advertArgs, args, this::callSelectOutcome),
this::handleSelectOutcomesOutput,
mExecutor);
}
/**
* Parses the output from the invocation of the {@code generateBid} JS function on a list of ads
* and convert it to a list of {@link AdWithBid} objects. The script output has been pre-parsed
* into an {@link AuctionScriptResult} object that will contain the script status code and the
* list of ads. The method will return an empty list of ads if the status code is not {@link
* #JS_SCRIPT_STATUS_SUCCESS} or if there has been any problem parsing the JS response.
*/
private List<AdWithBid> handleGenerateBidsOutput(AuctionScriptResult batchBidResult) {
if (batchBidResult.status != JS_SCRIPT_STATUS_SUCCESS) {
sLogger.v("Bid script failed, returning empty result.");
return ImmutableList.of();
} else {
try {
ImmutableList.Builder<AdWithBid> result = ImmutableList.builder();
for (int i = 0; i < batchBidResult.results.length(); i++) {
result.add(
AdWithBidArgument.parseJsonResponse(
batchBidResult.results.optJSONObject(i)));
}
return result.build();
} catch (IllegalArgumentException e) {
sLogger.w(
e,
"Invalid ad with bid returned by a generateBid script. Returning empty"
+ " list of ad with bids.");
return ImmutableList.of();
}
}
}
/**
* Parses the output from the invocation of the {@code scoreAd} JS function on a list of ad with
* associated bids {@link Double}. The script output has been pre-parsed into an {@link
* AuctionScriptResult} object that will contain the script status code and the list of scores.
* The method will return an empty list of ads if the status code is not {@link
* #JS_SCRIPT_STATUS_SUCCESS} or if there has been any problem parsing the JS response.
*/
private List<Double> handleScoreAdsOutput(
AuctionScriptResult batchBidResult,
AdSelectionExecutionLogger adSelectionExecutionLogger) {
ImmutableList.Builder<Double> result = ImmutableList.builder();
if (batchBidResult.status != JS_SCRIPT_STATUS_SUCCESS) {
sLogger.v("Scoring script failed, returning empty result.");
} else {
for (int i = 0; i < batchBidResult.results.length(); i++) {
// If the output of the score for this advert is invalid JSON or doesn't have a
// score we are dropping the advert by scoring it with 0.
result.add(batchBidResult.results.optJSONObject(i).optDouble("score", 0.0));
}
}
adSelectionExecutionLogger.endScoreAds();
return result.build();
}
/**
* Parses the output from the invocation of the {@code selectOutcome} JS function on a list of
* ad selection ids {@link Double} with associated bids {@link Double}. The script output has
* been pre-parsed into an {@link AuctionScriptResult} object that will contain the script
* status code and the results as a list. This handler expects a single result in the {@code
* results} or an empty list which is also valid as long as {@code status} is {@link
* #JS_SCRIPT_STATUS_SUCCESS}
*
* <p>The method will return a status code is not {@link #JS_SCRIPT_STATUS_SUCCESS} if there has
* been any problem executing JS script or parsing the JS response.
*
* @throws IllegalStateException is thrown in case the status is not success or the results has
* more than one item.
*/
private Long handleSelectOutcomesOutput(AuctionScriptResult scriptResults)
throws IllegalStateException {
if (scriptResults.status != JS_SCRIPT_STATUS_SUCCESS
|| scriptResults.results.length() != 1) {
String errorMsg =
String.format(
JS_EXECUTION_STATUS_UNSUCCESSFUL,
scriptResults.status,
scriptResults.results);
sLogger.v(errorMsg);
throw new IllegalStateException(errorMsg);
}
if (scriptResults.results.isNull(0)) {
return null;
}
try {
JSONObject resultOutcomeJson = scriptResults.results.getJSONObject(0);
// Use Long class to parse from string
return Long.valueOf(
resultOutcomeJson.optString(SelectAdsFromOutcomesArgument.ID_FIELD_NAME));
} catch (JSONException e) {
String errorMsg = String.format(JS_EXECUTION_RESULT_INVALID, scriptResults.results);
sLogger.v(errorMsg);
throw new IllegalStateException(errorMsg);
}
}
/**
* Runs the function call generated by {@code auctionFunctionCallGenerator} in the JS script
* {@code jsScript} for the list of {code ads} provided. The function will be called by a
* generated extra function that is responsible for iterating through all arguments and causing
* an early failure if the result of the function invocations is not an object containing a
* 'status' field or the value of the 'status' is not 0. In case of success status is 0, if the
* result doesn't have a status field, status is -1 otherwise the status is the non-zero status
* returned by the failed invocation. The 'results' field contains the JSON array with the
* results of the function invocations. The parameter {@code auctionFunctionCallGenerator} is
* responsible for generating the call to the auction function by splitting the advert data
*
* <p>The inner function call generated by {@code auctionFunctionCallGenerator} will receive for
* every call one of the ads or ads with bid and the extra arguments specified using {@code
* otherArgs} in the order they are specified.
*
* @return A future with the result of the function or failing with {@link
* IllegalArgumentException} if the script is not valid, doesn't contain {@code
* auctionFunctionName}.
*/
ListenableFuture<AuctionScriptResult> runAuctionScriptIterative(
String jsScript,
List<JSScriptArgument> ads,
List<JSScriptArgument> otherArgs,
Function<List<JSScriptArgument>, String> auctionFunctionCallGenerator) {
try {
return transform(
callAuctionScript(
jsScript,
ads,
otherArgs,
auctionFunctionCallGenerator,
AD_SELECTION_ITERATIVE_PROCESSING_JS),
this::parseAuctionScriptResult,
mExecutor);
} catch (JSONException e) {
throw new JSExecutionException(
"Illegal result returned by our internal iterative calling function.", e);
}
}
ListenableFuture<AuctionScriptResult> runAuctionScriptGenerateBidV3(
String jsScript,
List<JSScriptArgument> args,
Function<List<JSScriptArgument>, String> auctionFunctionCallGenerator) {
try {
return transform(
callAuctionScript(
jsScript,
args,
auctionFunctionCallGenerator,
AD_SELECTION_GENERATE_BID_JS_V3),
this::parseAuctionScriptResult,
mExecutor);
} catch (JSONException e) {
throw new JSExecutionException(
"Illegal result returned by our internal batch calling function.", e);
}
}
/**
* Runs the function call generated by {@code auctionFunctionCallGenerator} in the JS script
* {@code jsScript} for the list of {@code ads} provided. The function will be called by a
* generated extra function that is responsible for calling the JS script.
*
* <p>If the result of the function invocations is not an object containing a 'status' or
* 'results' field, or the value of the 'status' is not 0 then will return failure status.
*
* <p>In case of success status is 0. The 'results' field contains the JSON array with the
* results of the function invocations. The parameter {@code auctionFunctionCallGenerator} is
* responsible for generating the call to the auction function by passing the list of advert
* data
*
* <p>The inner function call generated by {@code auctionFunctionCallGenerator} will receive the
* list of ads and the extra arguments specified using {@code otherArgs} in the order they are
* specified.
*
* @return A future with the result of the function or failing with {@link
* IllegalArgumentException} if the script is not valid, doesn't contain {@code
* auctionFunctionName}.
*/
ListenableFuture<AuctionScriptResult> runAuctionScriptBatch(
String jsScript,
List<JSScriptArgument> ads,
List<JSScriptArgument> otherArgs,
Function<List<JSScriptArgument>, String> auctionFunctionCallGenerator) {
try {
return transform(
callAuctionScript(
jsScript,
ads,
otherArgs,
auctionFunctionCallGenerator,
AD_SELECTION_BATCH_PROCESSING_JS),
this::parseAuctionScriptResult,
mExecutor);
} catch (JSONException e) {
throw new JSExecutionException(
"Illegal result returned by our internal batch calling function.", e);
}
}
/**
* @return A {@link ListenableFuture} containing the result of the validation of the given
* {@code jsScript} script. A script is valid if it is valid JS code and it contains all the
* functions specified in {@code expectedFunctionsNames} are defined in the script. There is
* no validation of the expected signature.
*/
ListenableFuture<Boolean> validateAuctionScript(
String jsScript, List<String> expectedFunctionsNames) {
IsolateSettings isolateSettings =
mEnforceMaxHeapSizeFeatureSupplier.get()
? IsolateSettings.forMaxHeapSizeEnforcementEnabled(
mMaxHeapSizeBytesSupplier.get())
: IsolateSettings.forMaxHeapSizeEnforcementDisabled();
return transform(
mJsEngine.evaluate(
jsScript + "\n" + CHECK_FUNCTIONS_EXIST_JS,
ImmutableList.of(
stringArrayArg(FUNCTION_NAMES_ARG_NAME, expectedFunctionsNames)),
isolateSettings),
Boolean::parseBoolean,
mExecutor);
}
// TODO(b/260786980) remove the patch added to make bidding JS backward compatible
private ListenableFuture<List<AdWithBid>> handleBackwardIncompatibilityScenario(
String generateBidJS,
List<JSScriptArgument> signals,
List<JSScriptArgument> adDataArguments,
RunAdBiddingPerCAExecutionLogger runAdBiddingPerCAExecutionLogger,
JSExecutionException jsExecutionException) {
ListenableFuture<AdSelectionScriptEngine.AuctionScriptResult> biddingResult =
updateArgsIfNeeded(generateBidJS, signals, jsExecutionException)
.transformAsync(
args ->
runAuctionScriptIterative(
generateBidJS,
adDataArguments,
args,
this::callGenerateBid),
mExecutor);
return transform(
biddingResult,
result -> {
List<AdWithBid> bids = handleGenerateBidsOutput(result);
runAdBiddingPerCAExecutionLogger.endGenerateBids();
return bids;
},
mExecutor);
}
/**
* @return the number of arguments taken by {@code functionName} in a given {@code jsScript}
* falls-back to -1 if there is no function found the with given name.
*/
@VisibleForTesting
ListenableFuture<Integer> getAuctionScriptArgCount(String jsScript, String functionName) {
IsolateSettings isolateSettings =
mEnforceMaxHeapSizeFeatureSupplier.get()
? IsolateSettings.forMaxHeapSizeEnforcementEnabled(
mMaxHeapSizeBytesSupplier.get())
: IsolateSettings.forMaxHeapSizeEnforcementDisabled();
return transform(
mJsEngine.evaluate(
jsScript + "\n" + GET_FUNCTION_ARGUMENT_COUNT,
ImmutableList.of(
stringArrayArg(
FUNCTION_NAMES_ARG_NAME, ImmutableList.of(functionName))),
isolateSettings),
Integer::parseInt,
mExecutor);
}
/**
* @return Updates the args passed to bidding JS to maintain backward compatibility
* (b/259718738), this shall be removed with TODO(b/260786980). Throws back the original
* {@link JSExecutionException} if the js method does not match signature of the backward
* compat.
*/
private FluentFuture<List<JSScriptArgument>> updateArgsIfNeeded(
String generateBidJS,
List<JSScriptArgument> originalArgs,
JSExecutionException jsExecutionException) {
final int previousJSArgumentCount = 7;
final int previousJSUserSignalsIndex = 5;
return FluentFuture.from(
transform(
getAuctionScriptArgCount(generateBidJS, GENERATE_BID_FUNCTION_NAME),
argCount -> {
List<JSScriptArgument> updatedArgList =
originalArgs.stream().collect(Collectors.toList());
if (argCount == previousJSArgumentCount) {
try {
// This argument needs to be placed at the second last position
updatedArgList.add(
previousJSUserSignalsIndex,
jsonArg(
USER_SIGNALS_ARG_NAME,
AdSelectionSignals.EMPTY));
return updatedArgList;
} catch (JSONException e) {
sLogger.e(
"Could not create JS argument: %s",
USER_SIGNALS_ARG_NAME);
}
}
throw jsExecutionException;
},
mExecutor));
}
private AuctionScriptResult parseAuctionScriptResult(String auctionScriptResult) {
try {
if (auctionScriptResult.isEmpty()) {
throw new IllegalArgumentException(
"The auction script either doesn't contain the required function or the"
+ " function returns null");
}
JSONObject jsonResult = new JSONObject(auctionScriptResult);
return new AuctionScriptResult(
jsonResult.getInt(STATUS_FIELD_NAME),
jsonResult.getJSONArray(RESULTS_FIELD_NAME));
} catch (JSONException e) {
throw new RuntimeException(
"Illegal result returned by our internal batch calling function.", e);
}
}
/**
* @return a {@link ListenableFuture} containing the string representation of a JSON object
* containing two fields:
* <p>
* <ul>
* <li>{@code status} field that will be 0 in case of successful processing of all ads or
* non-zero if any of the calls to processed an ad returned a non-zero status. In the
* last case the returned status will be the same returned in the failing invocation.
* The function {@code auctionFunctionName} is assumed to return a JSON object
* containing at least a {@code status} field.
* <li>{@code results} with the results of the invocation of {@code auctionFunctionName}
* to all the given ads.
* </ul>
* <p>
*/
private ListenableFuture<String> callAuctionScript(
String jsScript,
List<JSScriptArgument> args,
Function<List<JSScriptArgument>, String> auctionFunctionCallGenerator,
String adSelectionProcessorJS)
throws JSONException {
String argPassing =
args.stream()
.map(JSScriptArgument::name)
.collect(Collectors.joining(ARG_PASSING_SEPARATOR));
IsolateSettings isolateSettings =
mEnforceMaxHeapSizeFeatureSupplier.get()
? IsolateSettings.forMaxHeapSizeEnforcementEnabled(
mMaxHeapSizeBytesSupplier.get())
: IsolateSettings.forMaxHeapSizeEnforcementDisabled();
return mJsEngine.evaluate(
jsScript
+ "\n"
+ String.format(
adSelectionProcessorJS,
argPassing,
auctionFunctionCallGenerator.apply(args)),
args,
isolateSettings);
}
/**
* @return a {@link ListenableFuture} containing the string representation of a JSON object
* containing two fields:
* <p>
* <ul>
* <li>{@code status} field that will be 0 in case of successful processing of all ads or
* non-zero if any of the calls to processed an ad returned a non-zero status. In the
* last case the returned status will be the same returned in the failing invocation.
* The function {@code auctionFunctionName} is assumed to return a JSON object
* containing at least a {@code status} field.
* <li>{@code results} with the results of the invocation of {@code auctionFunctionName}
* to all the given ads.
* </ul>
* <p>
*/
private ListenableFuture<String> callAuctionScript(
String jsScript,
List<JSScriptArgument> adverts,
List<JSScriptArgument> otherArgs,
Function<List<JSScriptArgument>, String> auctionFunctionCallGenerator,
String adSelectionProcessorJS)
throws JSONException {
ImmutableList.Builder<JSScriptArgument> advertsArg = ImmutableList.builder();
advertsArg.addAll(adverts);
sLogger.v(
"script: %s%nadverts: %s%nother args: %s%nprocessor script: %s%n",
jsScript, advertsArg, otherArgs, adSelectionProcessorJS);
List<JSScriptArgument> allArgs =
ImmutableList.<JSScriptArgument>builder()
.add(arrayArg(ADS_ARG_NAME, advertsArg.build()))
.addAll(otherArgs)
.build();
String argPassing =
allArgs.stream()
.map(JSScriptArgument::name)
.collect(Collectors.joining(ARG_PASSING_SEPARATOR));
IsolateSettings isolateSettings =
mEnforceMaxHeapSizeFeatureSupplier.get()
? IsolateSettings.forMaxHeapSizeEnforcementEnabled(
mMaxHeapSizeBytesSupplier.get())
: IsolateSettings.forMaxHeapSizeEnforcementDisabled();
return mJsEngine.evaluate(
jsScript
+ "\n"
+ String.format(
adSelectionProcessorJS,
argPassing,
auctionFunctionCallGenerator.apply(otherArgs)),
allArgs,
isolateSettings);
}
private String callGenerateBid(List<JSScriptArgument> otherArgs) {
// The first argument is the local variable "ad" defined in AD_SELECTION_BATCH_PROCESSING_JS
StringBuilder callArgs = new StringBuilder(AD_VAR_NAME);
for (JSScriptArgument currArg : otherArgs) {
callArgs.append(String.format(",%s", currArg.name()));
}
return String.format(GENERATE_BID_FUNCTION_NAME + "(%s)", callArgs);
}
private String callGenerateBidV3(List<JSScriptArgument> args) {
return String.format(
GENERATE_BID_FUNCTION_NAME + "(%s)",
args.stream()
.map(JSScriptArgument::name)
.collect(Collectors.joining(ARG_PASSING_SEPARATOR)));
}
private String callScoreAd(List<JSScriptArgument> otherArgs) {
StringBuilder callArgs =
new StringBuilder(
String.format(
"%s.%s, %s.%s",
AD_VAR_NAME,
AdWithBidArgument.AD_FIELD_NAME,
AD_VAR_NAME,
AdWithBidArgument.BID_FIELD_NAME));
for (JSScriptArgument currArg : otherArgs) {
callArgs.append(String.format(",%s", currArg.name()));
}
return String.format(SCORE_AD_FUNCTION_NAME + "(%s)", callArgs);
}
private String callSelectOutcome(List<JSScriptArgument> otherArgs) {
StringBuilder callArgs = new StringBuilder(ADS_ARG_NAME);
for (JSScriptArgument currArg : otherArgs) {
callArgs.append(String.format(",%s", currArg.name()));
}
return String.format("selectOutcome(%s)", callArgs);
}
static class AuctionScriptResult {
public final int status;
public final JSONArray results;
AuctionScriptResult(int status, JSONArray results) {
this.status = status;
this.results = results;
}
}
JSScriptArgument translateCustomAudience(DBCustomAudience customAudience) throws JSONException {
ImmutableList.Builder<JSScriptArgument> adsArg = ImmutableList.builder();
for (DBAdData ad : customAudience.getAds()) {
adsArg.add(AdDataArgument.asRecordArgument("ignored", ad));
}
// TODO(b/273357664): Verify with product on the set of fields we want to include.
return recordArg(
CUSTOM_AUDIENCE_ARG_NAME,
stringArg("owner", customAudience.getOwner()),
stringArg("name", customAudience.getName()),
jsonArg("userBiddingSignals", customAudience.getUserBiddingSignals()),
arrayArg("ads", adsArg.build()));
}
}