Documentation
¶
Overview ¶
Package poll implements polls in group chats (see pollpb.PollServer).
A poll belongs to the group chat it was created for. It is shown in that chat as a message carrying a messagingpb.PollWidget, which names the poll by ID alone: the question, options and state come from this package's RPCs.
A poll in a group that is not private is created unsent: only its creator sees it, and it takes no votes, until it goes live when first sent in a message (see Store.MarkLive), and one that is never sent is discarded when it ends. It is sent at most once: the send that makes it live is the only one (see PrepareSend). A private group's poll text is encrypted with the chat key, and its widget is sent inside encrypted content the server never reads, so the server cannot tell when it is sent: it is live from creation.
A poll closes at its PollDefinition.ends_at and at no other time. Votes are refused from then on; TallyDelay later, once every vote accepted before the end has landed, its votes are counted and its results recorded (see Store.Close). Until then nobody sees any totals, and individual votes are never shown to anyone but the voter.
Index ¶
- Constants
- Variables
- func MustDerivePollID(creatorID *commonpb.UserId, key *pollpb.IdempotencyKey) *pollpb.PollId
- func NormalizeSelection(options []uint32) []uint32
- func PrepareSend(ctx context.Context, polls Store, chatID *commonpb.ChatId, ...) error
- func ResultsProto(results *pollpb.Results) *pollpb.Results
- func TallyDueAt(p *Poll) time.Time
- type Poll
- func (p *Poll) Clone() *Poll
- func (p *Poll) EndsAt() time.Time
- func (p *Poll) IsDiscardedAt(now time.Time) bool
- func (p *Poll) IsOpenAt(now time.Time) bool
- func (p *Poll) StateProto(now time.Time, selfVote []uint32) *pollpb.PollState
- func (p *Poll) StatusAt(now time.Time) Status
- func (p *Poll) ToProto(now time.Time, selfVote []uint32) *pollpb.PollMetadata
- type Server
- func (s *Server) CreatePoll(ctx context.Context, req *pollpb.CreatePollRequest) (*pollpb.CreatePollResponse, error)
- func (s *Server) GetPoll(ctx context.Context, req *pollpb.GetPollRequest) (*pollpb.GetPollResponse, error)
- func (s *Server) GetPolls(ctx context.Context, req *pollpb.GetPollsRequest) (*pollpb.GetPollsResponse, error)
- func (s *Server) Vote(ctx context.Context, req *pollpb.VoteRequest) (*pollpb.VoteResponse, error)
- type ServerOption
- type Status
- type Store
- type TallyQueueStats
- type TallyTask
- type Vote
- type Worker
- type WorkerOption
Constants ¶
const ( // MinDuration and MaxDuration bound how long after its creation a poll may // end (see PollDefinition.ends_at). MinDuration = 5 * time.Minute MaxDuration = 30 * 24 * time.Hour )
const ( TallyDelay = time.Minute VoteWriteTimeout = 10 * time.Second )
TallyDelay is how long after a poll ends the tally takes it up, and VoteWriteTimeout bounds the write of a vote accepted before the end.
A vote is accepted while the server taking it reads the clock before the poll's end (see Poll.IsOpenAt), and its write is a single item of the voter's own, which nothing about the poll gates (see Store.SetVote). What makes the tally see every accepted vote is time: a vote's write is abandoned VoteWriteTimeout after it is accepted, so every accepted vote has landed, or never will, well before TallyDelay has passed since the end. The margin between the two covers clock skew between the server that took a vote and the one tallying it. The alternative, conditioning each vote on the poll's item in a transaction, would make every concurrent vote in a poll a transaction conflict on that one item, which a poll sent to a large group would hit at once.
const IDSize = 16
IDSize is the length, in bytes, of a PollId.
const IdempotencyKeySize = 16
IdempotencyKeySize is the length, in bytes, of a client's IdempotencyKey.
const MaxTallyQueueStatsDepth = 1000
MaxTallyQueueStatsDepth bounds the walk behind Store.GetTallyQueueStats: a backlog deeper than this reports this depth, so the gauges cost one bounded read even mid-incident.
const SendRetryWindow = MaxDuration
SendRetryWindow is how long after a poll's creation the send that made it live may be retried (see PrepareSend). A retry is safe only while messaging still holds the send's client message ID, which is what makes it a replay of the original message rather than a second one, so every messaging store must keep a client message ID at least this long after its send. The send comes after the poll's creation, so a client message ID kept that long is kept past the window. It is MaxDuration, so the send can be retried at least until the poll ends.
Variables ¶
var ( // ErrPollNotFound is returned for a poll that does not exist, including an // unsent poll that has ended and so been discarded. ErrPollNotFound = errors.New("poll not found") // ErrPollExists is returned by CreatePoll when a poll with the same ID // already exists: an earlier attempt of the same request created it. ErrPollExists = errors.New("poll already exists") // ErrPollNotLive is returned by Close for a poll that never went live, // which is never tallied. ErrPollNotLive = errors.New("poll is not live") // ErrPollAlreadyLive is returned by MarkLive for a poll that is live, but // not by the given send: another send made it live, or it was created live. ErrPollAlreadyLive = errors.New("poll is already live") )
var ErrNotSendable = errors.New("poll cannot be sent")
ErrNotSendable is returned by PrepareSend for a poll the sender may not send in the chat: it does not exist, it is someone else's, it belongs to another chat, it is no longer open, or it has been sent already.
Functions ¶
func MustDerivePollID ¶
MustDerivePollID returns the ID of the poll that a CreatePoll from creatorID carrying key creates.
Like a group chat's ID (see chat.MustDeriveGroupChatID), a poll's ID is derived from its creator and the request's idempotency key rather than minted at random, so the ID is itself the idempotency record: a retry derives the same ID, and the store's uniqueness condition turns the duplicate into a read of the original. The creator is part of the input so no client can derive another user's poll ID. The digest is truncated to IDSize bytes and stamped as a version 8 UUID, as a group ID is.
It panics if either input is not its fixed width, which would be a programming error: user IDs are UUIDs, and a request's key is checked to width before it reaches here.
func NormalizeSelection ¶
NormalizeSelection returns options sorted ascending, the order every store holds a selection in, so equal selections compare equal however a client ordered them.
func PrepareSend ¶
func PrepareSend(ctx context.Context, polls Store, chatID *commonpb.ChatId, senderID *commonpb.UserId, pollID *pollpb.PollId, clientMessageID *messagingpb.ClientMessageId, now time.Time) error
PrepareSend readies pollID to be sent by senderID in chatID at now, as a messagingpb.PollWidget in plain WidgetContent in the message with clientMessageID, which only a group that is not private takes. The poll must be the sender's and created for this chat, or ErrNotSendable is returned.
A poll is sent at most once: the send that makes it live is the only one. An unsent poll that is still open goes live as sent by this message (see Store.MarkLive), so the caller sends the message only after this returns: everyone who sees the message can then see the poll and vote. Any other send of a live poll is ErrNotSendable, except a retry of the one that made it live (the same clientMessageID) within SendRetryWindow of the poll's creation, which passes whether or not the poll is still open: messaging answers it with the original message, or, if the first attempt failed after the poll went live, writes the one message that carries it. Of sends racing to make a poll live, the one whose MarkLive lands goes through and the rest are refused.
That is what keeps a poll to one message: messaging writes at most one message per client message ID in a chat, and the poll accepts one client message ID. Its message, once deleted, is not replaced. A private group's poll is sent inside encrypted content the server never reads, so it never reaches this, and nothing limits how often it is sent; it is live from creation.
func ResultsProto ¶
ResultsProto projects stored results onto what a reader is shown: a BALANCE poll's totals as their ranking and its voter count alone (see pollpb.RankedResults), and every other variant as stored.
The ranking is by quarks, not by value: every holding in a poll is of its one mint, so the quark totals order the options exactly as their value would, without the float error a sum of values carries (equal balances must share a rank) or the rate moving between the batches a large tally is read in (see balance.Client.GetMintHoldingsForUsers).
The totals stay stored, so the server keeps what the ranking was made from; no reader is ever sent them.
func TallyDueAt ¶
TallyDueAt is when a live poll first comes due for the tally: TallyDelay after it ends.
Types ¶
type Poll ¶
type Poll struct {
ID *pollpb.PollId
ChatID *commonpb.ChatId
CreatorID *commonpb.UserId
Definition *pollpb.PollDefinition
CreatedAt time.Time
Live bool
ClientMessageID *messagingpb.ClientMessageId
Status Status
Results *pollpb.Results
}
Poll is a poll as stored.
Definition is exactly what the creator chose, and never changes. Its text is plaintext in a group that is not private and encrypted in a private group (see PollDefinition.text); the store holds either as given.
Live reports whether the poll is visible to the chat and takes votes. A poll in a private group is created live; any other is created unsent and goes live when it is first sent in a message (see Store.MarkLive). An unsent poll is discarded once it ends, and no store returns one after that.
ClientMessageID is the client message ID of the send that made the poll live, the one message that carries it (see PrepareSend). A poll created live, or not yet sent, has none.
Results are set once Status is StatusClosed, and never before. They are the results as recorded, which for a BALANCE poll are totals no reader is shown (see ResultsProto).
func (*Poll) IsDiscardedAt ¶
IsDiscardedAt reports whether the poll is gone at now: it ended unsent. A store may still hold it for a while (a DynamoDB TTL deletes lazily), but never returns it.
func (*Poll) IsOpenAt ¶
IsOpenAt reports whether the poll takes votes at now: it is live, stored open, and has not ended (see TallyDelay).
func (*Poll) StateProto ¶
StateProto projects the poll's state onto pollpb.PollState as a viewer sees it at now (see StatusAt), with selfVote as its self_vote.
closed_at is the poll's end, which is when it stopped accepting votes (see IsOpenAt). Results are carried only once the poll is closed, as a reader is shown them (see ResultsProto).
type Server ¶
type Server struct {
pollpb.UnimplementedPollServer
// contains filtered or unexported fields
}
Server implements pollpb.PollServer.
Who may do what is the chat's to say, through the shared chat.Access:
- Creating a poll is speaking in the chat (Access.SpeakerStanding), and the standing also says whether the chat takes the poll's text in plaintext (a public group) or encrypted under the chat key (a keyed private group).
- Seeing a poll is reading the chat in full (Access.CanListen): its members, and a non-member of a public group who satisfies its listener rules. An unsent poll is its creator's alone (see Poll.Live).
- Voting is a member's alone (Access.IsMember), whatever the chat's speaker rules: a member who cannot send in the chat still votes.
func NewServer ¶
func NewServer( log *zap.Logger, authz auth.Authorizer, polls Store, access *chat.Access, moderator moderation.Client, ocpData ocp_data.Provider, opts ...ServerOption, ) *Server
NewServer returns a Poll service over polls. access is the chat.Access the chat and messaging servers share, so a poll is gated by exactly what gates the chat it belongs to.
func (*Server) CreatePoll ¶
func (s *Server) CreatePoll(ctx context.Context, req *pollpb.CreatePollRequest) (*pollpb.CreatePollResponse, error)
CreatePoll creates a poll for a group chat.
The request is checked in the order a client can act on: the chat must be a group (CHAT_NOT_SUPPORTED); the caller must be able to speak in it (DENIED); the text must be in the form the chat takes, plaintext in a public group and encrypted under the chat key in a private one (ENCRYPTION_NOT_ALLOWED, ENCRYPTION_REQUIRED); plaintext must carry as many options as the definition counts (OPTION_COUNT_MISMATCH); the poll must end between MinDuration and MaxDuration from now (INVALID_ENDS_AT); a BALANCE poll's parameters must name a mint and a currency the tally can value, and a COUNT poll must carry none (INVALID_BALANCE_PARAMETERS); and plaintext must pass moderation (TEXT_MODERATED, see moderate). Only then is anything written. Local checks come first, the currency lookup next, and moderation, a call out, last.
A poll in a private group is live from creation, since the server cannot see the message that sends it; any other is unsent until its creator sends it (see Poll.Live).
The RPC is retry-safe. The poll's ID is derived from the caller and the request's idempotency key (see MustDerivePollID), so a retry names the same poll, and one that already exists is answered from the store before any check runs, whatever parameters the retry carries. A retry that loses a race with its twin is caught by the store's uniqueness condition and answered the same way.
func (*Server) GetPoll ¶
func (s *Server) GetPoll(ctx context.Context, req *pollpb.GetPollRequest) (*pollpb.GetPollResponse, error)
GetPoll returns a poll as the caller sees it, with their own vote.
A poll that does not exist, and an unsent one to anyone but its creator, is NOT_FOUND, checked before the caller's standing so a non-member learns nothing of a poll that is not live. Otherwise the caller must be able to read the poll's chat in full (DENIED). Results are carried once the poll is closed (see Poll.StateProto).
func (*Server) GetPolls ¶
func (s *Server) GetPolls(ctx context.Context, req *pollpb.GetPollsRequest) (*pollpb.GetPollsResponse, error)
GetPolls is the batch form of GetPoll. Every poll GetPoll would not answer OK is left out, and the caller's standing is asked once per chat the polls belong to, not once per poll.
func (*Server) Vote ¶
func (s *Server) Vote(ctx context.Context, req *pollpb.VoteRequest) (*pollpb.VoteResponse, error)
Vote replaces the caller's selection in an open poll.
A poll that does not exist or is not live is NOT_FOUND, and the caller must be a member of its chat (DENIED): membership alone, never the chat's rules. A poll that has ended is POLL_CLOSED, carrying its state, before the selection is looked at, since no selection would be taken. The selection must name options the poll has, and only one unless it allows several (INVALID_SELECTION); an empty one removes the caller's vote.
The vote is accepted as of the clock read that found the poll open, and its write must land within VoteWriteTimeout of it, or it is abandoned and the RPC fails: that bound is what lets the tally count every accepted vote without the poll's item being part of each (see TallyDelay). The bound is a context timeout, so it runs on the real clock from just after the read, whatever clock the server decides status by (see WithClock).
type ServerOption ¶
type ServerOption func(*Server)
ServerOption configures a Server at construction beyond its required dependencies.
func WithClock ¶
func WithClock(now func() time.Time) ServerOption
WithClock overrides the clock the server decides a poll's status by, for tests.
type Status ¶
type Status uint8
Status is a poll's status. It only moves forward: open, then tallying, then closed.
Only open and closed are stored. Tallying is what a poll stored open is from its end until its results are recorded, decided off the clock (see Poll.StatusAt), so no write is needed for a poll to stop taking votes.
func (Status) ToProto ¶
func (s Status) ToProto() pollpb.PollState_Status
ToProto projects the status onto pollpb.PollState_Status.
type Store ¶
type Store interface {
// CreatePoll writes a new poll as given, live or not (see Poll.Live), with
// status open and no results, and a live one enqueued for the tally.
// ErrPollExists is returned, and nothing is written, when a poll with its
// ID already exists.
CreatePoll(ctx context.Context, p *Poll) error
// GetPoll returns a poll, or ErrPollNotFound.
GetPoll(ctx context.Context, pollID *pollpb.PollId) (*Poll, error)
// GetPolls returns each of the given polls that exists, keyed by
// string(pollID.Value). Polls that do not exist are absent from the map,
// as are duplicates beyond the first.
GetPolls(ctx context.Context, pollIDs []*pollpb.PollId) (map[string]*Poll, error)
// MarkLive makes an unsent poll live (see Poll.Live) as sent by the
// message with clientMessageID, recording it as Poll.ClientMessageID and
// enqueuing the poll for the tally, and reports whether it changed
// anything: a poll already live by that message is a no-op, and one live
// otherwise, by another send or from creation, is ErrPollAlreadyLive, so of
// any number of sends racing to make a poll live exactly one does. ErrPollNotFound is returned for a poll that does not
// exist, or that ended unsent at or before now and so has been discarded.
MarkLive(ctx context.Context, pollID *pollpb.PollId, clientMessageID *messagingpb.ClientMessageId, now time.Time) (changed bool, err error)
// SetVote replaces userID's vote in a poll with options, as cast at
// votedAt, or removes it when options is empty. Nothing is checked against
// the poll, which need not even exist: the caller has established that the
// poll is open and the options are a valid selection in it.
SetVote(ctx context.Context, pollID *pollpb.PollId, userID *commonpb.UserId, options []uint32, votedAt time.Time) error
// GetVotes returns userID's vote in each of the given polls they have voted
// in, as its options in ascending order, keyed by string(pollID.Value).
// Polls they have not voted in are absent.
GetVotes(ctx context.Context, userID *commonpb.UserId, pollIDs []*pollpb.PollId) (map[string][]uint32, error)
// GetVotesPage returns up to limit of a poll's votes in ascending order of
// voter ID, strictly after the voter after (from the first when nil). An
// empty page means there are no more.
GetVotesPage(ctx context.Context, pollID *pollpb.PollId, after *commonpb.UserId, limit int) ([]Vote, error)
// GetDueForTally returns up to limit polls on the tally queue whose due time
// is at or before asOf, soonest first. The queue is eventually consistent:
// a poll just claimed or closed may still be listed, which ClaimForTally
// settles.
GetDueForTally(ctx context.Context, asOf time.Time, limit int) ([]*TallyTask, error)
// GetTallyQueueStats reports the tally's backlog as of asOf: how many
// queued polls are due, counting at most MaxTallyQueueStatsDepth, and the
// due time of the most overdue. Polls not yet due are never read, so its
// cost follows the backlog, not the number of open polls. It is eventually
// consistent, like GetDueForTally.
GetTallyQueueStats(ctx context.Context, asOf time.Time) (*TallyQueueStats, error)
// ClaimForTally pushes a queued poll's due time out to until, provided it
// is still queued and due as of asOf. It reports whether the claim was
// performed: false means the poll left the queue (it was closed) or is no
// longer due (another server claimed or delayed it first).
//
// A claim is an efficiency guard, not a lock: a tally is idempotent and
// safe to run concurrently, and claiming only keeps servers from
// duplicating its reads. A crashed claimant needs no recovery: the claim
// expires when its until passes and the poll simply becomes due again.
ClaimForTally(ctx context.Context, pollID *pollpb.PollId, asOf, until time.Time) (bool, error)
// DelayTally reschedules a queued poll after a failed attempt: its due
// time moves to nextAttemptAt and its failed-attempt count increments. It
// is a no-op on a poll that is no longer queued (a concurrent tally closed
// it, dequeuing it).
DelayTally(ctx context.Context, pollID *pollpb.PollId, nextAttemptAt time.Time) error
// Close records a live poll's results, moves it to closed and dequeues it
// from the tally queue, and reports whether it changed anything: a poll already
// closed is a no-op that keeps its results. ErrPollNotFound is returned for
// a poll that does not exist, and ErrPollNotLive for one that never went
// live. Whether the poll has ended, and its votes are all in, is the
// caller's (see TallyDelay).
Close(ctx context.Context, pollID *pollpb.PollId, results *pollpb.Results) (changed bool, err error)
}
Store persists polls and their votes.
A poll is written once with everything its creator chose (see Poll), and what changes afterward moves forward only: it goes live (MarkLive), and is closed with its results (Close). Both are conditional and idempotent, so a retry, or two servers doing the same step, is harmless.
Votes are kept per (poll, voter) and replaced whole, each a single write of the voter's own item: the poll's item is never part of a vote, so a burst of votes in a large group contends on nothing. Whether a poll still takes a vote is the caller's to decide off the poll's end (see Poll.IsOpenAt and TallyDelay), not the store's. No version or count of votes is kept anywhere, since one that moved with each vote would tell a watcher when votes are cast.
Every live poll that has yet to be closed is on a tally queue (GetDueForTally, ClaimForTally, DelayTally), due TallyDelay after it ends, and leaves it when it is closed: going live enqueues it, and closing it dequeues it in the same write.
Reads of polls and votes are strongly consistent: a creator reads their poll right after creating it, a voter their vote right after casting it, and the tally every vote cast before the poll ended. The tally queue is not, and need not be: a claim re-checks the poll it names.
type TallyQueueStats ¶
type TallyQueueStats struct {
// DueDepth is how many queued polls are due, at most
// MaxTallyQueueStatsDepth.
DueDepth uint64
// OldestDueAt is the due time of the most overdue poll, or the zero time
// when none is due.
OldestDueAt time.Time
}
TallyQueueStats is a snapshot of the tally's backlog, backing its gauges (see Store.GetTallyQueueStats). Only due polls count: one waiting for its end is not work the tally is behind on. A claimed poll is not due until its lease ends, and one whose attempt failed not until its retry, so a poll stuck retrying shows here only between its retry coming due and a worker claiming it; its failures are logged by the worker.
type TallyTask ¶
type TallyTask struct {
ID *pollpb.PollId
// Attempts is the number of failed tally attempts recorded so far (via
// Store.DelayTally). The tally uses it to pace backoff.
Attempts uint32
// NextAttemptAt is when the task next becomes due: TallyDelay after the
// poll ends at first (see TallyDueAt), then whenever a claim or a delay
// moves it.
NextAttemptAt time.Time
}
TallyTask is a queued unit of tally work: a live poll that has yet to be closed, with the retry bookkeeping the tally schedules off of (see Store.GetDueForTally).
type Vote ¶
Vote is one user's current selection in a poll: the positions of the options they chose, in ascending order, never empty (a removed vote is no vote at all), and when they last chose it.
type Worker ¶
type Worker struct {
// contains filtered or unexported fields
}
Worker drains the tally queue: it polls the poll store for live polls whose tally is due (TallyDelay after they end), counts each one's votes (see tally), and closes it with its results.
It implements the OCP worker.Runtime interface, so the parent application registers it alongside its other background runtimes and controls the poll interval. It is safe to run on every server instance: workers claim a poll before tallying it, a tally writes nothing until it closes the poll, and closing is conditional, so overlap costs duplicate reads at worst, never a second set of results.
Construct with NewWorker and either run it via Start (the OCP runtime entry point) or drive single ticks with Process (tests).
func NewWorker ¶
func NewWorker(log *zap.Logger, polls Store, balances *balance.Client, opts ...WorkerOption) *Worker
NewWorker returns a Worker draining the tally queue of polls, reading BALANCE polls' balances through balances.
func (*Worker) Process ¶
Process runs one worker tick: it pulls the due polls and tallies them with bounded concurrency, reporting how many this worker took on (claimed). Zero means the due queue is drained: polls other workers hold claims on are not counted.
func (*Worker) Start ¶
Start satisfies the OCP worker.Runtime interface: it polls the tally queue every interval until ctx is cancelled, whose error it returns. The cadence is fixed-rate, not fixed-delay: each sleep is the interval minus the time the tick's processing took. A full batch polls again immediately, so a burst of polls ending together drains at processing speed rather than one batch per interval. It also runs the queue gauges (depth, max age) alongside the processing loop.
type WorkerOption ¶
type WorkerOption func(*Worker)
WorkerOption overrides one of the worker's tuning knobs.
func WithWorkerBackoff ¶
func WithWorkerBackoff(base, maxDelay time.Duration) WorkerOption
WithWorkerBackoff overrides the retry backoff's base and maximum delay.
func WithWorkerBatchSize ¶
func WithWorkerBatchSize(n int) WorkerOption
WithWorkerBatchSize overrides how many due polls one tick pulls from the queue.
func WithWorkerClaimLease ¶
func WithWorkerClaimLease(lease time.Duration) WorkerOption
WithWorkerClaimLease overrides how far a claim pushes a poll's due time out.
func WithWorkerMaxConcurrency ¶
func WithWorkerMaxConcurrency(n int) WorkerOption
WithWorkerMaxConcurrency overrides how many polls are tallied at once.
func WithWorkerTallyTimeout ¶
func WithWorkerTallyTimeout(timeout time.Duration) WorkerOption
WithWorkerTallyTimeout overrides the bound on a single tally attempt.