add behavioral targeting structs (#5205)

* add IntervalData struct
* add interval counters
* add tests and enums
* update from feedback, add event store
* small fix for large rotation case
This commit is contained in:
Charlie Humphreys 2022-10-28 16:55:05 -05:00 коммит произвёл GitHub
Родитель 75b40c85b2
Коммит f37d6d50d6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 565 добавлений и 1 удалений

Просмотреть файл

@ -50,6 +50,7 @@ Use the template below to make assigning a version number during the release cut
### What's Changed
- Disabled Glean events recorded when the SDK is not ready for a feature ([#5185](https://github.com/mozilla/application-services/pull/5185))
- Add struct for IntervalData (behavioral targeting) ([#5205](https://github.com/mozilla/application-services/pull/5205))
- Calls to `log::error` have been replaced with `error_support::report_error` ([#5204](https://github.com/mozilla/application-services/pull/5204))
## Places

Просмотреть файл

@ -0,0 +1,550 @@
#![allow(dead_code)]
use crate::error::{BehaviorError, NimbusError, Result};
use chrono::{DateTime, Timelike, Utc};
use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug)]
pub enum Interval {
Hours,
Days,
Weeks,
Months,
Years,
}
impl fmt::Display for Interval {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
impl PartialEq for Interval {
fn eq(&self, other: &Self) -> bool {
self.to_string() == other.to_string()
}
}
impl Eq for Interval {}
impl Hash for Interval {
fn hash<H: Hasher>(&self, state: &mut H) {
self.to_string().as_bytes().hash(state);
}
}
#[derive(Clone)]
struct IntervalConfig {
bucket_count: usize,
interval: Interval,
}
impl Default for IntervalConfig {
fn default() -> Self {
Self::new(7, Interval::Days)
}
}
impl IntervalConfig {
pub fn new(bucket_count: usize, interval: Interval) -> Self {
Self {
bucket_count,
interval,
}
}
}
#[derive(Clone)]
pub struct IntervalData {
buckets: VecDeque<u64>,
bucket_count: usize,
starting_instant: DateTime<Utc>,
}
impl Default for IntervalData {
fn default() -> Self {
Self::new(1)
}
}
impl IntervalData {
fn new(bucket_count: usize) -> Self {
let mut data = Self {
buckets: VecDeque::with_capacity(bucket_count),
bucket_count,
starting_instant: Utc::now(),
};
data.buckets.push_front(0);
data
}
pub fn from(
buckets: VecDeque<u64>,
bucket_count: usize,
starting_instant: DateTime<Utc>,
) -> Self {
Self {
buckets,
bucket_count,
starting_instant,
}
}
pub fn increment(&mut self) -> Result<()> {
match self.buckets.front_mut() {
Some(x) => *x += 1,
None => {
return Err(NimbusError::BehaviorError(BehaviorError::InvalidState(
"Interval buckets cannot be empty".to_string(),
)))
}
};
Ok(())
}
pub fn rotate(&mut self, num_rotations: i32) -> Result<()> {
let num_rotations = usize::min(self.bucket_count, num_rotations as usize);
if num_rotations as usize + self.buckets.len() > self.bucket_count {
self.buckets.drain((self.bucket_count - num_rotations)..);
}
for _ in 1..=num_rotations {
self.buckets.push_front(0);
}
Ok(())
}
}
#[derive(Clone)]
struct SingleIntervalCounter {
pub data: IntervalData,
pub config: IntervalConfig,
}
impl SingleIntervalCounter {
pub fn new(config: IntervalConfig) -> Self {
Self {
data: IntervalData::new(config.bucket_count),
config,
}
}
pub fn from(data: IntervalData, config: IntervalConfig) -> Self {
Self { data, config }
}
pub fn from_config(bucket_count: usize, interval: Interval) -> Self {
let config = IntervalConfig {
bucket_count,
interval,
};
Self::new(config)
}
pub fn increment(&mut self) -> Result<()> {
self.data.increment()
}
fn num_rotations(&self, now: DateTime<Utc>) -> Result<i32> {
let hour_diff = i32::try_from(self.data.starting_instant.hour() - now.hour())?;
let date_diff = i32::try_from((now.date() - self.data.starting_instant.date()).num_days())?;
Ok(match self.config.interval {
Interval::Hours => (date_diff * 24) + hour_diff,
Interval::Days => date_diff,
Interval::Weeks => date_diff / 7,
Interval::Months => date_diff / 28,
Interval::Years => date_diff / 365,
})
}
pub fn maybe_advance(&mut self, now: DateTime<Utc>) -> Result<()> {
let rotations = self.num_rotations(now)?;
self.data.starting_instant = now;
if rotations > 0 {
return self.data.rotate(rotations);
}
Ok(())
}
}
struct MultiIntervalCounter {
intervals: HashMap<Interval, SingleIntervalCounter>,
}
impl MultiIntervalCounter {
pub fn new(intervals: Vec<SingleIntervalCounter>) -> Self {
Self {
intervals: intervals
.into_iter()
.map(|v| (v.config.interval.clone(), v))
.collect::<HashMap<Interval, SingleIntervalCounter>>(),
}
}
pub fn from(intervals: HashMap<Interval, SingleIntervalCounter>) -> Self {
Self { intervals }
}
pub fn increment(&mut self) -> Result<()> {
self.intervals
.iter_mut()
.try_for_each(|(_, v)| v.increment())
}
pub fn maybe_advance(&mut self, now: DateTime<Utc>) -> Result<()> {
self.intervals
.iter_mut()
.try_for_each(|(_, v)| v.maybe_advance(now))
}
}
type EventId = String;
struct EventStore {
events: HashMap<EventId, MultiIntervalCounter>,
}
impl EventStore {
pub fn new(events: Vec<(EventId, MultiIntervalCounter)>) -> Self {
Self {
events: HashMap::from_iter(events.into_iter()),
}
}
pub fn from(events: HashMap<EventId, MultiIntervalCounter>) -> Self {
Self { events }
}
pub fn record_event(&mut self, event_id: EventId, now: Option<DateTime<Utc>>) -> Result<()> {
let now = now.unwrap_or_else(Utc::now);
let counter = self.events.get_mut(&event_id).unwrap();
counter.maybe_advance(now)?;
counter.increment()
}
}
#[cfg(test)]
mod interval_data_tests {
use super::*;
#[test]
fn increment_works_if_no_buckets_present() -> Result<()> {
let mut interval = IntervalData {
buckets: VecDeque::new(),
bucket_count: 7,
starting_instant: Utc::now(),
};
let result = interval.increment();
assert!(matches!(result.is_err(), true));
Ok(())
}
#[test]
fn increment_increments_front_bucket_if_it_exists() -> Result<()> {
let mut interval = IntervalData::new(7);
interval.increment().ok();
assert!(matches!(interval.buckets[0], 1));
Ok(())
}
#[test]
fn rotate_adds_buckets_for_each_rotation() -> Result<()> {
let mut interval = IntervalData::new(7);
interval.rotate(3).ok();
assert!(matches!(interval.buckets.len(), 4));
Ok(())
}
#[test]
fn rotate_removes_buckets_when_max_is_reached() -> Result<()> {
let mut interval = IntervalData::new(3);
interval.increment().ok();
interval.rotate(2).ok();
interval.increment().ok();
interval.rotate(1).ok();
interval.increment().ok();
interval.increment().ok();
assert!(matches!(interval.buckets.len(), 3));
assert!(matches!(interval.buckets[0], 2));
assert!(matches!(interval.buckets[1], 1));
assert!(matches!(interval.buckets[2], 0));
Ok(())
}
#[test]
fn rotate_handles_large_rotation() -> Result<()> {
let mut interval = IntervalData::new(3);
interval.rotate(10).ok();
assert!(matches!(interval.buckets.len(), 3));
assert!(matches!(interval.buckets[0], 0));
assert!(matches!(interval.buckets[1], 0));
assert!(matches!(interval.buckets[2], 0));
Ok(())
}
}
#[cfg(test)]
mod single_interval_counter_tests {
use chrono::Duration;
use super::*;
#[test]
fn test_increment() -> Result<()> {
let mut counter = SingleIntervalCounter::new(IntervalConfig::new(7, Interval::Days));
counter.increment().ok();
assert!(matches!(counter.data.buckets[0], 1));
Ok(())
}
#[test]
fn test_advance_do_not_advance() -> Result<()> {
let mut counter = SingleIntervalCounter::new(IntervalConfig::new(7, Interval::Days));
let date = Utc::now();
counter.maybe_advance(date).ok();
assert!(matches!(counter.data.buckets.len(), 1));
Ok(())
}
#[test]
fn test_advance_do_advance() -> Result<()> {
let mut counter = SingleIntervalCounter::new(IntervalConfig::new(7, Interval::Days));
let date = Utc::now() + Duration::days(1);
counter.maybe_advance(date).ok();
assert!(matches!(counter.data.buckets.len(), 2));
Ok(())
}
}
#[cfg(test)]
mod multi_interval_counter_tests {
use chrono::Duration;
use super::*;
#[test]
fn test_increment_many() -> Result<()> {
let mut counter = MultiIntervalCounter::new(vec![
SingleIntervalCounter::new(IntervalConfig::new(12, Interval::Months)),
SingleIntervalCounter::new(IntervalConfig::new(28, Interval::Days)),
]);
counter.increment().ok();
assert!(matches!(
counter
.intervals
.get(&Interval::Months)
.unwrap()
.data
.buckets[0],
1
));
assert!(matches!(
counter.intervals.get(&Interval::Days).unwrap().data.buckets[0],
1
));
Ok(())
}
#[test]
fn test_advance_do_not_advance() -> Result<()> {
let mut counter = MultiIntervalCounter::new(vec![
SingleIntervalCounter::new(IntervalConfig::new(12, Interval::Months)),
SingleIntervalCounter::new(IntervalConfig::new(28, Interval::Days)),
]);
let date = Utc::now();
counter.maybe_advance(date).ok();
assert!(matches!(
counter
.intervals
.get(&Interval::Months)
.unwrap()
.data
.buckets
.len(),
1
));
assert!(matches!(
counter
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets
.len(),
1
));
Ok(())
}
#[test]
fn test_advance_advance_some() -> Result<()> {
let mut counter = MultiIntervalCounter::new(vec![
SingleIntervalCounter::new(IntervalConfig::new(12, Interval::Months)),
SingleIntervalCounter::new(IntervalConfig::new(28, Interval::Days)),
]);
let date = Utc::now() + Duration::days(1);
counter.maybe_advance(date).ok();
assert!(matches!(
counter
.intervals
.get(&Interval::Months)
.unwrap()
.data
.buckets
.len(),
1
));
assert!(matches!(
counter
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets
.len(),
2
));
Ok(())
}
}
#[cfg(test)]
mod event_store_tests {
use chrono::Duration;
use super::*;
#[test]
fn record_event_should_function() -> Result<()> {
let counter1 = MultiIntervalCounter::new(vec![
SingleIntervalCounter::new(IntervalConfig::new(12, Interval::Months)),
SingleIntervalCounter::new(IntervalConfig::new(28, Interval::Days)),
]);
let counter2 = MultiIntervalCounter::new(vec![
SingleIntervalCounter::new(IntervalConfig::new(12, Interval::Months)),
SingleIntervalCounter::new(IntervalConfig::new(28, Interval::Days)),
]);
let mut store = EventStore::new(vec![
("event-1".to_string(), counter1),
("event-2".to_string(), counter2),
]);
store.record_event("event-1".to_string(), Some(Utc::now() + Duration::days(2)))?;
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Months)
.unwrap()
.data
.buckets
.len(),
1
));
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Months)
.unwrap()
.data
.buckets[0],
1
));
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets
.len(),
3
));
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets[0],
1
));
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets[1],
0
));
assert!(matches!(
store
.events
.get(&"event-1".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets[2],
0
));
assert!(matches!(
store
.events
.get(&"event-2".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets
.len(),
1
));
assert!(matches!(
store
.events
.get(&"event-2".to_string())
.unwrap()
.intervals
.get(&Interval::Days)
.unwrap()
.data
.buckets[0],
0
));
Ok(())
}
}

Просмотреть файл

@ -7,6 +7,8 @@
//! This is where the error definitions can go
//! TODO: Implement proper error handling, this would include defining the error enum,
//! impl std::error::Error using `thiserror` and ensuring all errors are handled appropriately
use std::num::TryFromIntError;
#[derive(Debug, thiserror::Error)]
pub enum NimbusError {
#[error("Invalid persisted data")]
@ -53,6 +55,16 @@ pub enum NimbusError {
DatabaseNotReady,
#[error("Error parsing a sting into a version {0}")]
VersionParsingError(String),
#[error("Behavior error: {0}")]
BehaviorError(#[from] BehaviorError),
#[error("TryFromIntError: {0}")]
TryFromIntError(#[from] TryFromIntError),
}
#[derive(Debug, thiserror::Error)]
pub enum BehaviorError {
#[error("Invalid state: {0}")]
InvalidState(String),
}
impl<'a> From<jexl_eval::error::EvaluationError<'a>> for NimbusError {

Просмотреть файл

@ -2,6 +2,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
mod behavior;
mod dbcache;
mod enrollment;
pub mod error;

Просмотреть файл

@ -78,7 +78,7 @@ enum NimbusError {
"TryFromSliceError", "EmptyRatiosError", "OutOfBoundsError","UrlParsingError",
"RequestError", "ResponseError", "UuidError", "InvalidExperimentFormat",
"InvalidPath", "InternalError", "NoSuchExperiment", "NoSuchBranch", "BackoffError",
"DatabaseNotReady", "VersionParsingError"
"DatabaseNotReady", "VersionParsingError", "BehaviorError", "TryFromIntError"
};
interface NimbusClient {