Skip to content

Commit e6de06f

Browse files
authored
Merge pull request #135 from vazarkevych/fis-sticky-bucketing-sbdocs
Sticky Bucketing Docs saving algorithm optimization
2 parents 1cfa850 + 0588341 commit e6de06f

File tree

3 files changed

+102
-104
lines changed

3 files changed

+102
-104
lines changed

Sources/CommonMain/Evaluators/ExperimentEvaluator.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,11 @@ class ExperimentEvaluator {
162162
if changed {
163163
context.userContext.stickyBucketAssignmentDocs = context.userContext.stickyBucketAssignmentDocs ?? [:]
164164
context.userContext.stickyBucketAssignmentDocs?[key] = doc
165-
context.options.stickyBucketService?.saveAssignments(doc: doc, completion: { _ in })
165+
context.options.stickyBucketService?.saveAssignments(doc: doc, completion: { error in
166+
if let error {
167+
logger.error("Sticky bucketing error: \(error.localizedDescription)")
168+
}
169+
})
166170
}
167171
}
168172

Sources/CommonMain/Model/GlobalContext.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import Foundation
5050
}
5151

5252
@objc public class UserContext: NSObject {
53-
public let attributes: JSON
53+
public var attributes: JSON
5454
public var stickyBucketAssignmentDocs: [String: StickyAssignmentsDocument]?
5555
public var forcedVariations: JSON?
5656
public var forcedFeatureValues: JSON?

Sources/CommonMain/Utils/Utils.swift

Lines changed: 96 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -72,59 +72,59 @@ public class Utils {
7272
return true
7373
}
7474
}
75-
75+
7676
/// This checks if a userId is within an experiment namespace or not.
7777
static func inNamespace(userId: String, namespace: NameSpace) -> Bool {
7878
guard let hash = hash(seed: namespace.0, value: userId + "__", version: 1.0) else { return false }
7979
return inRange(n: hash, range: BucketRange(number1: namespace.1, number2: namespace.2))
8080
}
81-
81+
8282
/// Returns an array of floats with numVariations items that are all equal and sum to 1. For example, getEqualWeights(2) would return [0.5, 0.5].
8383
static func getEqualWeights(numVariations: Int) -> [Float] {
8484
if numVariations <= 0 { return [] }
8585
return Array(repeating: 1.0 / Float(numVariations), count: numVariations)
8686
}
87-
87+
8888
/// This converts and experiment's coverage and variation weights into an array of bucket ranges.
8989
static func getBucketRanges(numVariations: Int, coverage: Float, weights: [Float]?) -> [BucketRange] {
9090
var bucketRange: [BucketRange]
91-
91+
9292
var targetCoverage = coverage
93-
93+
9494
// Clamp the value of coverage to between 0 and 1 inclusive.
9595
if coverage < 0 { targetCoverage = 0 }
9696
if coverage > 1 { targetCoverage = 1 }
97-
97+
9898
// Default to equal weights if the weights don't match the number of variations.
9999
let equal = getEqualWeights(numVariations: numVariations)
100100
var targetWeights = weights ?? equal
101101
if targetWeights.count != numVariations {
102102
targetWeights = equal
103103
}
104-
104+
105105
// Default to equal weights if the sum is not equal 1 (or close enough when rounding errors are factored in):
106106
let weightsSum = targetWeights.sum()
107107
if weightsSum < 0.99 || weightsSum > 1.01 {
108108
targetWeights = equal
109109
}
110-
110+
111111
// Convert weights to ranges and return
112112
var cumulative: Float = 0
113-
113+
114114
bucketRange = targetWeights.map { weight in
115115
let start = cumulative
116116
cumulative += weight
117-
117+
118118
return BucketRange(number1: start.roundTo(numFractionDigits: 4), number2: (start + (targetCoverage * weight)).roundTo(numFractionDigits: 4))
119119
}
120-
120+
121121
return bucketRange
122122
}
123123

124124
static func inRange(n: Float, range: BucketRange) -> Bool {
125125
return n >= range.number1 && n < range.number2
126126
}
127-
127+
128128
/// Choose Variation from List of ranges which matches particular number
129129
static func chooseVariation(n: Float, ranges: [BucketRange]) -> Int {
130130
for (index, range) in ranges.enumerated() {
@@ -134,23 +134,23 @@ public class Utils {
134134
}
135135
return -1
136136
}
137-
137+
138138
/// Convert JsonArray to NameSpace
139139
static func getGBNameSpace(namespace: [JSON]) -> NameSpace? {
140140
if namespace.count >= 3 {
141-
141+
142142
let title = namespace[0].string
143143
let start = namespace[1].float
144144
let end = namespace[2].float
145-
145+
146146
if let title = title, let start = start, let end = end {
147147
return NameSpace(title, start, end)
148148
}
149-
149+
150150
}
151151
return nil
152152
}
153-
153+
154154
static func paddedVersionString(input: String) -> String {
155155
var parts = input.replacingOccurrences(of: "[v]", with: "", options: .regularExpression)
156156

@@ -184,7 +184,7 @@ public class Utils {
184184
}
185185
return nil
186186
}
187-
187+
188188
static private func digest(_ string: String) -> UInt32 {
189189
return Common.fnv1a(Array(string.utf8), offsetBasis: Common.offsetBasis32, prime: Common.prime32)
190190
}
@@ -223,7 +223,7 @@ public class Utils {
223223
return [:]
224224
}
225225

226-
let (hashAttribute, hashValue) = getHashAttribute(
226+
let (hashAttribute, hashValue) = getHashAttribute(
227227
attr: expHashAttribute,
228228
fallback: nil,
229229
attributes: context.userContext.attributes
@@ -250,7 +250,7 @@ public class Utils {
250250
}
251251
}
252252
}
253-
253+
254254
var mergedAssignments: [String: String] = [:]
255255

256256
if let fallbackKey = fallbackKey, let fallbackAssignments = stickyBucketAssignmentDocs[fallbackKey] {
@@ -296,7 +296,7 @@ public class Utils {
296296

297297
let features = data?.features ?? context.globalContext.features
298298
let experiments = data?.experiments ?? context.globalContext.experiments
299-
299+
300300
features.keys.forEach({ id in
301301
let feature = features[id]
302302
if let rules = feature?.rules {
@@ -364,123 +364,117 @@ public class Utils {
364364

365365
// Create assignment document
366366
static func generateStickyBucketAssignmentDoc(context: EvalContext, attributeName: String,
367-
attributeValue: String,
368-
assignments: [String: String]) -> (key: String, doc: StickyAssignmentsDocument, changed: Bool) {
367+
attributeValue: String,
368+
assignments: [String: String]) -> (key: String, doc: StickyAssignmentsDocument, changed: Bool) {
369369
let key = "\(attributeName)||\(attributeValue)"
370370
let existingAssignments: [String: String] = (context.userContext.stickyBucketAssignmentDocs?[key]?.assignments) ?? [:]
371-
var newAssignments = existingAssignments
372-
assignments.forEach { newAssignments[$0] = $1 }
371+
var newAssignments = existingAssignments
372+
assignments.forEach { newAssignments[$0] = $1 }
373373

374374
let changed = NSDictionary(dictionary: existingAssignments).isEqual(to: newAssignments) == false
375375

376376
return (
377-
key: key,
378-
doc: StickyAssignmentsDocument(
379-
attributeName: attributeName,
380-
attributeValue: attributeValue,
381-
assignments: newAssignments
382-
),
383-
changed: changed
384-
)
377+
key: key,
378+
doc: StickyAssignmentsDocument(
379+
attributeName: attributeName,
380+
attributeValue: attributeValue,
381+
assignments: newAssignments
382+
),
383+
changed: changed
384+
)
385385
}
386386

387387
static func parseQueryString(_ queryString: String?) -> [String: String] {
388-
var map: [String: String] = [:]
388+
var map: [String: String] = [:]
389+
390+
guard let queryString = queryString, !queryString.isEmpty else {
391+
return map
392+
}
393+
394+
let params = queryString.split(separator: "&")
395+
for param in params {
396+
let keyValuePair = param.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
389397

390-
guard let queryString = queryString, !queryString.isEmpty else {
391-
return map
398+
guard let name = keyValuePair.first?.removingPercentEncoding,
399+
!name.isEmpty else {
400+
continue
392401
}
393402

394-
let params = queryString.split(separator: "&")
395-
for param in params {
396-
let keyValuePair = param.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
397-
398-
guard let name = keyValuePair.first?.removingPercentEncoding,
399-
!name.isEmpty else {
400-
continue
401-
}
402-
403-
let value = keyValuePair.count > 1
404-
? keyValuePair[1].removingPercentEncoding ?? ""
405-
: ""
406-
407-
map[name] = value
408-
}
403+
let value = keyValuePair.count > 1
404+
? keyValuePair[1].removingPercentEncoding ?? ""
405+
: ""
409406

410-
return map
407+
map[name] = value
411408
}
409+
410+
return map
411+
}
412412

413413
static func getQueryStringOverride(id: String, url: URL, numberOfVariations: Int) -> Int? {
414-
let queryMap = parseQueryString(url.query)
415-
416-
guard let possibleValue = queryMap[id] else {
417-
return nil
418-
}
419-
420-
if let variationValue = Int(possibleValue),
421-
variationValue >= 0 && variationValue < numberOfVariations {
422-
return variationValue
423-
} else {
424-
return nil
425-
}
414+
let queryMap = parseQueryString(url.query)
415+
416+
guard let possibleValue = queryMap[id] else {
417+
return nil
426418
}
427419

428-
static func getQueryStringOverride(id: String, urlString: String?, numberOfVariations: Int) -> Int? {
429-
guard let urlString = urlString, !urlString.isEmpty else {
430-
return nil
431-
}
432-
433-
guard let url = URL(string: urlString) else {
434-
return nil
435-
}
436-
437-
return getQueryStringOverride(id: id, url: url, numberOfVariations: numberOfVariations)
420+
if let variationValue = Int(possibleValue),
421+
variationValue >= 0 && variationValue < numberOfVariations {
422+
return variationValue
423+
} else {
424+
return nil
425+
}
426+
}
427+
428+
static func getQueryStringOverride(id: String, urlString: String?, numberOfVariations: Int) -> Int? {
429+
guard let urlString = urlString, !urlString.isEmpty else {
430+
return nil
438431
}
432+
433+
guard let url = URL(string: urlString) else {
434+
return nil
435+
}
436+
437+
return getQueryStringOverride(id: id, url: url, numberOfVariations: numberOfVariations)
438+
}
439439

440-
static func initializeEvalContext(context: Context) -> EvalContext {
441-
let options = ClientOptions(isEnabled: context.isEnabled,
440+
static func initializeEvalContext(context: Context) -> EvalContext {
441+
let options = ClientOptions(isEnabled: context.isEnabled,
442442
stickyBucketAssignmentDocs: context.stickyBucketAssignmentDocs,
443443
stickyBucketIdentifierAttributes: context.stickyBucketIdentifierAttributes,
444444
stickyBucketService: context.stickyBucketService,
445445
isQaMode: context.isQaMode,
446446
url: context.url,
447447
trackingClosure: context.trackingClosure)
448448

449-
let globalContext = GlobalContext(features: context.features, savedGroups: context.savedGroups)
449+
let globalContext = GlobalContext(features: context.features, savedGroups: context.savedGroups)
450450

451451
// should create manual force features
452452
let userContext = UserContext(attributes: context.attributes, stickyBucketAssignmentDocs: context.stickyBucketAssignmentDocs, forcedVariations: context.forcedVariations, forcedFeatureValues: context.forcedFeatureValues)
453453

454454
let evalContext = EvalContext(globalContext: globalContext, userContext: userContext, stackContext: StackContext(), options: options)
455455
return evalContext
456456
}
457-
457+
458458
/// Propagates sticky bucket assignments from child evaluation context to parent context
459459
static func propagateStickyAssignments(from childContext: EvalContext, to parentContext: EvalContext) {
460-
if let childAssignments = childContext.userContext.stickyBucketAssignmentDocs,
461-
!childAssignments.isEmpty {
462-
// Merge child assignments into parent context
463-
if parentContext.userContext.stickyBucketAssignmentDocs == nil {
464-
parentContext.userContext.stickyBucketAssignmentDocs = [:]
465-
}
466-
467-
for (key, doc) in childAssignments {
468-
if let existingDoc = parentContext.userContext.stickyBucketAssignmentDocs?[key] {
469-
// Merge assignments from both documents
470-
var mergedAssignments = existingDoc.assignments
471-
for (expKey, assignment) in doc.assignments {
472-
mergedAssignments[expKey] = assignment
473-
}
474-
let mergedDoc = StickyAssignmentsDocument(
475-
attributeName: doc.attributeName,
476-
attributeValue: doc.attributeValue,
477-
assignments: mergedAssignments
478-
)
479-
parentContext.userContext.stickyBucketAssignmentDocs?[key] = mergedDoc
480-
} else {
481-
// Add new document
482-
parentContext.userContext.stickyBucketAssignmentDocs?[key] = doc
483-
}
460+
guard let childAssignments = childContext.userContext.stickyBucketAssignmentDocs,
461+
!childAssignments.isEmpty else { return }
462+
463+
if parentContext.userContext.stickyBucketAssignmentDocs == nil {
464+
parentContext.userContext.stickyBucketAssignmentDocs = [:]
465+
}
466+
467+
for (key, childDoc) in childAssignments {
468+
if let existingDoc = parentContext.userContext.stickyBucketAssignmentDocs?[key] {
469+
let mergedAssignments = existingDoc.assignments.merging(childDoc.assignments) { _, new in new }
470+
let mergedDoc = StickyAssignmentsDocument(
471+
attributeName: childDoc.attributeName,
472+
attributeValue: childDoc.attributeValue,
473+
assignments: mergedAssignments
474+
)
475+
parentContext.userContext.stickyBucketAssignmentDocs?[key] = mergedDoc
476+
} else {
477+
parentContext.userContext.stickyBucketAssignmentDocs?[key] = childDoc
484478
}
485479
}
486480
}

0 commit comments

Comments
 (0)