use std::time::Duration;
use onetaskgraph_plugin_api::WaitCause;
use serde_json::Value;
use crate::{
CONTENT_CREATION_PER_HOUR, CONTENT_CREATION_PER_MINUTE, SECONDARY_POINTS_PER_MINUTE,
SECONDARY_POINTS_PER_MUTATION, SECONDARY_POINTS_PER_QUERY,
};
const MINUTE: Duration = Duration::from_secs(60);
const HOUR: Duration = Duration::from_secs(3_600);
pub(crate) mod cause {
use super::WaitCause;
pub(crate) const CREATION_MINUTE: WaitCause = WaitCause::new("content-creation-minute");
pub(crate) const CREATION_HOUR: WaitCause = WaitCause::new("content-creation-hour");
pub(crate) const POINTS_MINUTE: WaitCause = WaitCause::new("secondary-points-minute");
pub(crate) const MIN_INTERVAL: WaitCause = WaitCause::new("min-mutation-interval");
pub(crate) const RETRY_AFTER: WaitCause = WaitCause::new("retry-after");
pub(crate) const PRIMARY_RESET: WaitCause = WaitCause::new("primary-reset");
pub(crate) const BACKOFF: WaitCause = WaitCause::new("backoff");
pub(crate) const BOARD_FILING: WaitCause = WaitCause::new("board-filing");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Charge {
pub(crate) creations: u64,
pub(crate) points: u64,
}
impl Charge {
pub(crate) fn graphql(document: &str, variables: &Value) -> Self {
if crate::is_mutation(document) {
Self {
creations: included_fields(document, variables),
points: SECONDARY_POINTS_PER_MUTATION,
}
} else {
Self {
creations: 0,
points: SECONDARY_POINTS_PER_QUERY,
}
}
}
pub(crate) const fn upload() -> Self {
Self {
creations: 1,
points: 0,
}
}
}
pub(crate) fn included_fields(document: &str, variables: &Value) -> u64 {
let bytes = document.as_bytes();
let Some(open) = selection_start(bytes) else {
return 0;
};
let mut fields: Vec<bool> = Vec::new();
let mut depth = 1_usize;
let mut at = open + 1;
let mut after_name = false;
while at < bytes.len() && depth > 0 {
let byte = bytes[at];
match byte {
b'"' => {
at = past_string(bytes, at);
continue;
}
b'(' => {
at = past_group(bytes, at, b'(', b')');
continue;
}
b'{' => depth += 1,
b'}' => depth -= 1,
b'@' if depth == 1 => {
let (name, next) = name_at(bytes, at + 1);
let (argument, next) = match skip_space(bytes, next) {
Some(paren) if bytes[paren] == b'(' => {
let end = past_group(bytes, paren, b'(', b')');
(Some(&document[paren + 1..end - 1]), end)
}
_ => (None, next),
};
if let (Some(field), Some(argument)) = (fields.last_mut(), argument) {
let condition = condition(argument, variables);
match name {
"include" => *field &= condition,
"skip" => *field &= !condition,
_ => {}
}
}
at = next;
after_name = false;
continue;
}
byte if depth == 1 && (byte.is_ascii_alphabetic() || byte == b'_') => {
let (_, next) = name_at(bytes, at);
let alias = skip_space(bytes, next).is_some_and(|colon| bytes[colon] == b':');
if !after_name {
fields.push(true);
}
after_name = alias;
at = match skip_space(bytes, next) {
Some(colon) if alias => colon + 1,
_ => next,
};
continue;
}
_ => {}
}
at += 1;
}
fields.into_iter().filter(|sent| *sent).count() as u64
}
fn selection_start(bytes: &[u8]) -> Option<usize> {
let mut at = 0;
while at < bytes.len() {
match bytes[at] {
b'"' => at = past_string(bytes, at),
b'(' => at = past_group(bytes, at, b'(', b')'),
b'{' => return Some(at),
_ => at += 1,
}
}
None
}
fn past_group(bytes: &[u8], at: usize, open: u8, close: u8) -> usize {
let mut depth = 0_usize;
let mut index = at;
while index < bytes.len() {
match bytes[index] {
b'"' => {
index = past_string(bytes, index);
continue;
}
byte if byte == open => depth += 1,
byte if byte == close => {
depth -= 1;
if depth == 0 {
return index + 1;
}
}
_ => {}
}
index += 1;
}
bytes.len()
}
fn past_string(bytes: &[u8], at: usize) -> usize {
let mut index = at + 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'"' => return index + 1,
_ => index += 1,
}
}
bytes.len()
}
fn name_at(bytes: &[u8], at: usize) -> (&str, usize) {
let end = bytes[at..]
.iter()
.position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'_'))
.map_or(bytes.len(), |length| at + length);
(
std::str::from_utf8(&bytes[at..end]).unwrap_or_default(),
end,
)
}
fn skip_space(bytes: &[u8], at: usize) -> Option<usize> {
(at..bytes.len()).find(|index| !bytes[*index].is_ascii_whitespace() && bytes[*index] != b',')
}
fn condition(argument: &str, variables: &Value) -> bool {
let value = argument
.trim()
.strip_prefix("if")
.map(str::trim_start)
.and_then(|rest| rest.strip_prefix(':'))
.map_or("", str::trim);
match value {
"false" => false,
"true" => true,
variable => variable
.strip_prefix('$')
.and_then(|name| variables.get(name))
.and_then(Value::as_bool)
.unwrap_or(true),
}
}
#[derive(Debug, Default)]
pub(crate) struct Windows {
creations: Vec<(Duration, u64)>,
points: Vec<(Duration, u64, u64)>,
next: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Ticket(u64);
impl Windows {
pub(crate) fn room(&self, now: Duration, charge: Charge) -> (Duration, Option<WaitCause>) {
let creations: Vec<(Duration, u64)> =
self.creations.iter().map(|(at, _)| (*at, 1)).collect();
let points: Vec<(Duration, u64)> = self
.points
.iter()
.map(|(at, points, _)| (*at, *points))
.collect();
let mut free = (now, None);
for (entries, window, limit, wanted, why) in [
(
&creations,
MINUTE,
CONTENT_CREATION_PER_MINUTE,
charge.creations,
cause::CREATION_MINUTE,
),
(
&creations,
HOUR,
CONTENT_CREATION_PER_HOUR,
charge.creations,
cause::CREATION_HOUR,
),
(
&points,
MINUTE,
SECONDARY_POINTS_PER_MINUTE,
charge.points,
cause::POINTS_MINUTE,
),
] {
if wanted == 0 {
continue;
}
let at = earliest(entries, now, window, limit, wanted);
if at > free.0 {
free = (at, Some(why));
}
}
free
}
pub(crate) fn charge(&mut self, at: Duration, charge: Charge) -> Ticket {
let ticket = self.next;
self.next += 1;
for _ in 0..charge.creations {
self.creations.push((at, ticket));
}
if charge.points > 0 {
self.points.push((at, charge.points, ticket));
}
Ticket(ticket)
}
pub(crate) fn finish(&mut self, ticket: Ticket, now: Duration) {
for entry in &mut self.creations {
if entry.1 == ticket.0 {
entry.0 = entry.0.max(now);
}
}
for entry in &mut self.points {
if entry.2 == ticket.0 {
entry.0 = entry.0.max(now);
}
}
self.creations.retain(|(at, _)| *at + HOUR > now);
self.points.retain(|(at, _, _)| *at + MINUTE > now);
}
}
fn earliest(
entries: &[(Duration, u64)],
now: Duration,
window: Duration,
limit: u64,
wanted: u64,
) -> Duration {
let mut inside: Vec<(Duration, u64)> = entries
.iter()
.copied()
.filter(|(at, _)| *at + window > now)
.collect();
let mut total: u64 = inside.iter().map(|(_, amount)| amount).sum();
if total + wanted <= limit {
return now;
}
inside.sort_by_key(|(at, _)| *at);
for (at, amount) in &inside {
total -= amount;
if total + wanted <= limit {
return (*at + window).max(now);
}
}
inside.last().map_or(now, |(at, _)| (*at + window).max(now))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_mutation_counts_each_field_it_sends_and_none_its_directives_leave_out() {
let document = "mutation($a:Input!,$b:Input!,$c:Input!,$sendB:Boolean!,$skipC:Boolean!){\
first:createIssue(input:$a){issue{id}} second:addBlockedBy(input:$b) @include(if:$sendB){issue{id}}\
third:addBlockedBy(input:$c) @skip(if:$skipC){issue{id}} fourth: updateIssue(input:{title:\"a { brace\"}){issue{id}}}";
for (sent_b, skip_c, expected) in [
(true, false, 4),
(false, false, 3),
(true, true, 3),
(false, true, 2),
] {
assert_eq!(
included_fields(document, &json!({"sendB": sent_b, "skipC": skip_c})),
expected,
"sendB {sent_b} skipC {skip_c}"
);
}
assert_eq!(
included_fields(crate::graphql::CREATE_ISSUE, &json!({})),
1,
"a lone mutation"
);
}
#[test]
fn a_window_has_room_once_enough_of_its_oldest_have_left_and_not_before() {
let mut windows = Windows::default();
let tenth = Duration::from_millis(100);
for at in 0..79 {
let ticket = windows.charge(tenth * at, Charge::upload());
windows.finish(ticket, tenth * at);
}
let now = tenth * 79;
assert_eq!(
windows.room(now, Charge::upload()),
(now, None),
"the 80th fits"
);
let eleven = Charge {
creations: 11,
points: SECONDARY_POINTS_PER_MUTATION,
};
assert_eq!(
windows.room(now, eleven),
(tenth * 9 + MINUTE, Some(cause::CREATION_MINUTE))
);
assert_eq!(
windows.room(now, Charge::graphql("query{viewer{id}}", &json!({}))),
(now, None)
);
}
}