Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

redis backend to zipkin, CR by Dan Simon #201

Closed
wants to merge 5 commits into from

4 participants

@mosesn

motivation

we didn't want to use the cassandra backend, so we used made an alternative storage backend in redis.

implementation

redis data structures are generally pretty good, but we needed slightly different ways of thinking about them for zipkin. I have slightly different abstractions around them, which comprise most of this code. Index and Storage end up mostly being just about manipulating those data structures.

@johanoskarsson

Thanks for contributing this back. Great to have support for more storage systems.
I'll add a few comments inline, but one general request would be to add more comments. Class level describing what the class does and how + method level if it's not immediately clear what is going on (err on the side of adding too many comments).

...la/com/twitter/zipkin/config/RedisStorageConfig.scala
((10 lines not shown))
+ * 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.twitter.zipkin.config
+
+import com.twitter.zipkin.storage.redis.RedisStorage
+import com.twitter.finagle.redis.Client
+import com.twitter.util.Duration
+import com.twitter.conversions.time.intToTimeableNumber
+
+trait RedisStorageConfig extends StorageConfig {
+ lazy val _client: Client = Client("0.0.0.0:%d".format(port))

Seems restrictive to hardcode 0.0.0.0 here. Add another val for host?

@mosesn
mosesn added a note

Good point, done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
.../com/twitter/zipkin/storage/redis/ExpiringValue.scala
((9 lines not shown))
+ *
+ * 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.twitter.zipkin.storage.redis
+
+import com.twitter.util.Time
+
+case class ExpiringValue[A](expiresAt: Time, value: A)
+
+object ExpiringValue {
+ def apply[A](expiresAt: Long, value: A): ExpiringValue[A] =

Comment here describing what the expiresAt is seconds would be nice

@mosesn
mosesn added a note

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((194 lines not shown))
+
+ /**
+ * Store the service name, so that we easily can
+ * find out which services have been called from now and back to the ttl
+ */
+ override def indexServiceName(span: Span): Future[Unit] = Future.join(
+ for (serviceName <- span.serviceNames.toSeq
+ if serviceName != "")
+ yield serviceArray.add(serviceName))
+
+ /**
+ * Index the span name on the service name. This is so we
+ * can get a list of span names when given a service name.
+ * Mainly for UI purposes
+ */
+ override def indexSpanNameByService(span: Span): Future[Unit] = if (span.name != "") Future.join(

I'd generally prefer to not cram in too much in addition to the method declaration on one line

@mosesn
mosesn added a note

Moved it all over to the next line

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@johanoskarsson johanoskarsson commented on the diff
.../scala/com/twitter/zipkin/storage/redis/package.scala
((18 lines not shown))
+
+import com.twitter.zipkin.conversions.thrift._
+import java.nio.ByteBuffer
+import com.twitter.zipkin.gen
+import com.twitter.zipkin.common.Span
+import com.twitter.scrooge.BinaryThriftStructSerializer
+import com.twitter.util.Time
+import org.jboss.netty.buffer.ChannelBuffers
+import org.jboss.netty.util.CharsetUtil
+import java.nio.charset.Charset
+import org.jboss.netty.buffer.ChannelBuffer
+
+/**
+ * Useful conversions for encoding/decoding.
+ */
+package object redis {

Some of these don't seem to be tested, worth adding a few specs

@mosesn
mosesn added a note

pretty much fully tested now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...com/twitter/zipkin/storage/redis/RedisIndexSpec.scala
((79 lines not shown))
+ doAfter {
+ redisIndex.close()
+ }
+
+ "index and get span names" in {
+ redisIndex.indexSpanNameByService(span1)()
+ redisIndex.getSpanNames("service")() mustEqual Set(span1.name)
+ }
+
+ "index and get service names" in {
+ redisIndex.indexServiceName(span1)()
+ redisIndex.getServiceNames() mustEqual Set(span1.serviceNames.head)
+ }
+
+ /*
+ "index only on annotation in each span with the same value" in {

There's some old Cassandra references here that I assume can be safely removed

@mosesn
mosesn added a note

Yep, completely forgot to clean up this class. Much nicer now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@johanoskarsson

For those of us that aren't familiar with redis a quick readme describing how to use this module with redis would be cool (including a quick starter on how to get redis running or a link to such a doc).

@mosesn

re comments:
do you want me to add comments to private methods too, or only non-private? also, should methods which override methods in traits (which already have comments) also be commented?

@johanoskarsson

For the comments you don't have to add one if the trait you extend have comments already, unless your implementation does something worth bringing up.

For private methods that are complex or hard to understand I would add a comment, but not needed for the ones where the name and parameters or the code is self explanatory. For public methods I would do the same, but lower your sensitivity to complex methods, meaning: unless it's absolutely clear what is going on I'd add a comment. Methods like getTimeToLive: Duration you don't have to add a comment to for example.

...cala/com/twitter/zipkin/config/RedisIndexConfig.scala
((7 lines not shown))
+ *
+ * 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.twitter.zipkin.config
+
+import com.twitter.zipkin.storage.redis.RedisIndex
+import com.twitter.finagle.redis.Client
+import com.twitter.util.Duration
+import com.twitter.conversions.time.intToTimeableNumber

should sort the imports on all files

@mosesn
mosesn added a note

can't wait until organize imports for scala-ide can handle effective scala conventions . . . but done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...cala/com/twitter/zipkin/storage/redis/RedisHash.scala
((9 lines not shown))
+ *
+ * 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.twitter.zipkin.storage.redis
+
+import com.twitter.finagle.redis.Client
+import com.twitter.util.Future
+import java.nio.ByteBuffer
+import org.jboss.netty.buffer.ChannelBuffer
+
+class RedisHash(database: Client, name: String) {

docs about what this does?

@mosesn
mosesn added a note

added them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@franklinhu franklinhu commented on the diff
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((162 lines not shown))
+ */
+ override def indexSpanByAnnotations(span: Span) : Future[Unit] = Future.join(
+ {
+ def encodeAnnotation(bin: BinaryAnnotation): String = bin.annotationType match {
+ case AnnotationType.Bool => (if (bin.value.get() != 0) true else false).toString
+ case AnnotationType.Double => bin.value.getDouble.toString
+ case AnnotationType.I16 => bin.value.getShort.toString
+ case AnnotationType.I32 => bin.value.getInt.toString
+ case AnnotationType.I64 => bin.value.getLong.toString
+ case _ => ChannelBuffers.copiedBuffer(bin.value)
+ }
+
+ def binaryAnnoStringify(bin: BinaryAnnotation, service: String): String =
+ redisJoin(service, bin.key, encodeAnnotation(bin))
+
+ val time = span.lastAnnotation.get.timestamp

probably shouldn't be calling .get on the Option

@mosesn
mosesn added a note

good point, made it a map instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
.../twitter/zipkin/storage/redis/RedisArrayMapSpec.scala
((19 lines not shown))
+import com.twitter.conversions.time.intToTimeableNumber
+import org.jboss.netty.buffer.ChannelBuffers
+
+class RedisArrayMapSpec extends RedisSpecification {
+ "RedisArrayMapSpec" should {
+ var arrayMap: RedisArrayMap = null
+ val buf1 = ChannelBuffers.copiedBuffer("value1")
+ val buf2 = ChannelBuffers.copiedBuffer("value2")
+ val buf3 = ChannelBuffers.copiedBuffer("value3")
+
+ doBefore {
+ _client.flushDB()
+ arrayMap= new RedisArrayMap(_client, "prefix", None)
+ }
+
+ "be able to insert an element properly" in {

for brevity we can omit the "be able to"

@mosesn
mosesn added a note

done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((170 lines not shown))
+ case AnnotationType.I64 => bin.value.getLong.toString
+ case _ => ChannelBuffers.copiedBuffer(bin.value)
+ }
+
+ def binaryAnnoStringify(bin: BinaryAnnotation, service: String): String =
+ redisJoin(service, bin.key, encodeAnnotation(bin))
+
+ val time = span.lastAnnotation.get.timestamp
+ val binaryAnnos: Seq[Future[Unit]] = span.serviceNames.toSeq flatMap { serviceName =>
+ span.binaryAnnotations map { binaryAnno =>
+ binaryAnnotationsListMap.add(
+ binaryAnnoStringify(binaryAnno, serviceName),
+ time,
+ span.traceId
+ )
+ Future.Unit

This breaks the linking of the futures. If you want to convert a Future[A] into Future[Unit] you can use myFuture.unit

@mosesn
mosesn added a note

neat, didn't know about that, thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((187 lines not shown))
+ }
+ val annos = for (serviceName <- span.serviceNames toSeq;
+ anno <- span.annotations)
+ yield annotationsListMap.add(redisJoin(serviceName, anno.value), time, span.traceId)
+ annos ++ binaryAnnos
+ }
+ )
+
+ /**
+ * Store the service name, so that we easily can
+ * find out which services have been called from now and back to the ttl
+ */
+ override def indexServiceName(span: Span): Future[Unit] = Future.join(
+ for (serviceName <- span.serviceNames.toSeq
+ if serviceName != "")
+ yield serviceArray.add(serviceName))

Not a huge fan of the for(..) syntax. might be clearer as something like

span.serviceNames collect { case name if name != "" => serviceArray.add(name) }
@mosesn
mosesn added a note

In general I like the for syntax, but I think you're right that collect makes it cleaner in this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...la/com/twitter/zipkin/storage/redis/RedisSetMap.scala
((15 lines not shown))
+ */
+
+package com.twitter.zipkin.storage.redis
+
+import com.twitter.finagle.redis.Client
+import com.twitter.finagle.redis.protocol.ZRangeResults
+import com.twitter.util.Future
+import org.jboss.netty.buffer.ChannelBuffer
+import com.twitter.util.Duration
+
+class RedisSetMap(database: Client, prefix: String, defaultTTL: Option[Duration]) {
+ private[this] def preface(key: String) = "%s:%s".format(prefix, key)
+
+ def add(key: String, buf: ChannelBuffer): Future[Unit] = database.sAdd(preface(key), List(buf)) flatMap { _ =>
+ defaultTTL match {
+ case Some(ttl) => database.expire(preface(key), ttl.inSeconds) flatMap (_ => Future.Unit)

.unit instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
.../twitter/zipkin/storage/redis/RedisSortedSetMap.scala
((18 lines not shown))
+
+import com.twitter.finagle.redis.Client
+import com.twitter.util.Future
+import org.jboss.netty.buffer.ChannelBuffer
+import com.twitter.finagle.redis.protocol.ZInterval
+import com.twitter.finagle.redis.protocol.Limit
+import com.twitter.finagle.redis.protocol.ZRangeResults
+import com.twitter.util.Duration
+
+class RedisSortedSetMap(database: Client, prefix: String, defaultTTL: Option[Duration]) {
+ def preface(key: String) = "%s:%s".format(prefix, key)
+
+ def add(key: String, score: Double, buffer: ChannelBuffer): Future[Unit] =
+ database.zAdd(preface(key), score, buffer) flatMap { _ =>
+ defaultTTL match {
+ case Some(ttl) => database.expire(preface(key), ttl.inSeconds) flatMap { _ => Future.Unit }

.unit?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@mosesn

Thanks for all of your prompt feedback. I've made changes that address all of the comments you've laid out for me. Looking forward to more input, thanks!

@mosesn

@johanoskarsson I added a redis doc, you can probably yum/apt-get install redis-server if you're on linux and try it out yourself. Not sure how fully featured you want the redis doc to be. I have basically no experience writing technical documents except for my own gratification, so I know that I am pretty nooby. Also, is it in the right place?

re: comments, I added comments where they seemed useful. I tended toward scaladoc-style comments rather than // comments because I feel like it's easier for // comments to get separated from the code they're referring to, unless you have something like

code() //comment describing code.
.../com/twitter/zipkin/storage/redis/ExpiringValue.scala
((15 lines not shown))
+ */
+
+package com.twitter.zipkin.storage.redis
+
+import com.twitter.util.Time
+
+/**
+ * ExpiringValue represents a value with a time to live.
+ * expiresAt is the time when ExpiringValue will expire.
+ * value is the value that will expire.
+ */
+case class ExpiringValue[A](expiresAt: Time, value: A)
+
+object ExpiringValue {
+
+ // expiresAt is a long value in seconds from the epoch.

I'd actually prefer javadoc style here with a @param expiresAt so that it shows up properly in generated docs.

@mosesn
mosesn added a note

Oops, I completely forgot how @param works, I'll go back and change all of those tonight. Good catch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@caniszczyk

Also not sure if you're aware guys, Redis is available on Travis CI for testing purposes... you just have to ensure it's started: http://about.travis-ci.org/docs/user/database-setup/

@franklinhu

@caniszczyk We have Travis CI disabled right now due to maven.twttr.com's flakiness :(

@caniszczyk

What dependencies are on maven.twttr.com that we can't publish to Sonatype OSS and Maven Central?

Finagle?

@franklinhu franklinhu commented on the diff
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((76 lines not shown))
+ database.release()
+ }
+
+ private[this] def zRangeResultsToSeqIds(arr: ZRangeResults): Seq[IndexedTraceId] =
+ arr.asTuples map (tup => IndexedTraceId(tup._1, tup._2.toLong))
+
+ private[redis] def redisJoin(items: String*) = items.mkString(":")
+
+ override def getTraceIdsByName(serviceName: String, span: Option[String],
+ endTs: Long, limit: Int): Future[Seq[IndexedTraceId]] =
+ serviceSpanMap.get(
+ serviceName,
+ span,
+ ttl map (dur => endTs - dur.inMicroseconds),
+ endTs,
+ limit) map zRangeResultsToSeqIds

would prefer to wrap each of these method blocks in braces

@mosesn
mosesn added a note

The scala style guide seems to suggest that I should not use braces in this case. I'm not certain that this will become more clear if I wrap it in curly braces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@franklinhu franklinhu commented on the diff
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((123 lines not shown))
+ )
+
+ override def getSpanNames(service: String): Future[Set[String]] = spanMap.get(service) map (
+ strings => (strings map (new String(_))).toSet
+ )
+
+ override def indexTraceIdByServiceAndName(span: Span) : Future[Unit] = Future.join(
+ (span.serviceNames toSeq) map { serviceName =>
+ (span.lastAnnotation map { last =>
+ serviceSpanMap.put(serviceName, Some(span.name), last.timestamp, span.traceId)
+ }).getOrElse(Future.Unit)
+ }
+ )
+
+ override def indexSpanByAnnotations(span: Span) : Future[Unit] = Future.join(
+ {

maybe a brace at the start of the function, and the Future.join inside?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((162 lines not shown))
+ anno <- span.annotations)
+ yield annotationsListMap.add(redisJoin(serviceName, anno.value), time, span.traceId)
+ annos ++ binaryAnnos
+ }
+ )
+
+ override def indexServiceName(span: Span): Future[Unit] = Future.join(
+ span.serviceNames.toSeq collect {case name if name != "" => serviceArray.add(name)}
+ )
+
+ override def indexSpanNameByService(span: Span): Future[Unit] =
+ if (span.name != "") Future.join(
+ for (serviceName <- span.serviceNames.toSeq
+ if serviceName != "")
+ yield spanMap.add(serviceName, span.name)
+ ) else Future.Unit

would be clearer if styled like:

if (...) {
  ...
} else {
  ...
}
@mosesn
mosesn added a note

The Scala style guide suggests omitting braces for if/else expressions, but I can see the argument for

if (span.name != "")
  Future.join(
    for (serviceName <- span.serviceNames.toSeq
      if serviceName != "")
      yield spanMap.add(serviceName, span.name)
  )
else
  Future.Unit

Is that more like what you would like to see?

Yeah that's a bit clearer

@mosesn
mosesn added a note

changed this to look like this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...ala/com/twitter/zipkin/storage/redis/RedisIndex.scala
((171 lines not shown))
+
+ override def indexSpanNameByService(span: Span): Future[Unit] =
+ if (span.name != "") Future.join(
+ for (serviceName <- span.serviceNames.toSeq
+ if serviceName != "")
+ yield spanMap.add(serviceName, span.name)
+ ) else Future.Unit
+
+ override def indexSpanDuration(span: Span): Future[Void] = {
+ traceHash.get(span.traceId) map {
+ case None => TimeRange.fromSpan(span) map { timeRange =>
+ traceHash.put(span.traceId, timeRange)
+ }
+ case Some(bytes) => indexNewStartAndEnd(span, bytes)
+ }
+ Future.Void

This breaks the future chaining. If converting to Future[Void], prefer myFuture.voided

@mosesn
mosesn added a note

Does Future[Void] even use future chaining? Also, why is it Future[Void] instead of Future[Unit]?

edit: ahh, I thought Future[Void] was special, didn't realize it was just a Future wrapped around a null. Still seems wrong to use Future[Void] instead of Future[Unit].

Yeah I think we should make the interface return Future[Unit] :\ I'll file a ticket

we can fix it in a future review

@mosesn
mosesn added a note

fixed this to use .voided for now, I'll switch it when the trait changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...a/com/twitter/zipkin/storage/redis/RedisListMap.scala
((55 lines not shown))
+
+ /**
+ * Returns whether following key refers to a data structure
+ */
+ def exists(key: String): Future[Boolean] = database.exists(preface(key)) map (_.booleanValue)
+
+ /**
+ * Inserts an item into a list
+ */
+ def put(key: String, value: ChannelBuffer): Future[Unit] = {
+ val string = preface(key)
+ Future.join(Seq(database.lPush(string, List(value)),
+ defaultTTL match {
+ case Some(ttl) => database.expire(string, ttl.inSeconds)
+ case None => Future.Unit
+ }))

A little confused about what this is supposed to do. It's pushing a key/value to Redis, then setting the expiration?
Is it okay for the expiration to be set before lPush does? It seems like this should be more like:

database.lPush(string, List(value)) flatMap { _ =>
  defaultTTL match {
    case Some(ttl) => database.expire(string, ttl.inSeconds).unit
    case None => Future.Unit
  }
}
@mosesn
mosesn added a note

Yeah, you're right. I didn't really understand Futures when I wrote that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...tter/zipkin/storage/redis/RedisSortedSetMapSpec.scala
((20 lines not shown))
+import com.twitter.zipkin.common.{Annotation, BinaryAnnotation, Endpoint, Span}
+import com.twitter.zipkin.conversions.thrift.thriftAnnotationTypeToAnnotationType
+import com.twitter.zipkin.gen
+import java.nio.ByteBuffer
+import java.nio.charset.Charset
+import org.jboss.netty.buffer.ChannelBuffers
+import scala.util.Random
+
+class RedisSortedSetMapSpec extends RedisSpecification {
+ val ep = Endpoint(123, 123, "service")
+
+ def binaryAnnotation(key: String, value: String) =
+ BinaryAnnotation(
+ key,
+ ByteBuffer.wrap(value.getBytes),
+ gen.AnnotationType.String.toAnnotationType,

Should use com.twitter.zipkin.common.AnnotationType.String rather than converting the generated thrift struct.

@mosesn
mosesn added a note

thanks, fixed all of these.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...m/twitter/zipkin/storage/redis/RedisStorageSpec.scala
((17 lines not shown))
+
+import com.twitter.conversions.time.intToTimeableNumber
+import com.twitter.zipkin.common.{Annotation, BinaryAnnotation, Endpoint, Span}
+import com.twitter.zipkin.conversions.thrift.thriftAnnotationTypeToAnnotationType
+import com.twitter.zipkin.gen
+import java.nio.ByteBuffer
+
+class RedisStorageSpec extends RedisSpecification {
+
+ var redisStorage: RedisStorage = null
+
+ def binaryAnnotation(key: String, value: String) =
+ BinaryAnnotation(
+ key,
+ ByteBuffer.wrap(value.getBytes),
+ gen.AnnotationType.String.toAnnotationType,

Same here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@franklinhu

Just had two small nits. Otherwise lgtm

@franklinhu

I tried running the tests, and didn't realize that they require for redis-server to be running in the background. Ideally this kind of a service dependency should be mocked out.

@mosesn

They don't require redis-server to be running, they do need redis to be installed on the machine that hosts it, though. RedisCluster spins up a new instance of redis. RedisCluster is also the way that finagle-redis tests itself, so it seems like right now it is the standard.

...la/com/twitter/zipkin/storage/redis/RedisSetMap.scala
((12 lines not shown))
+ * 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.twitter.zipkin.storage.redis
+
+import com.twitter.finagle.redis.Client
+import com.twitter.util.{Duration, Future}
+import org.jboss.netty.buffer.ChannelBuffer
+
+/**
+ * RedisSetMap is a map from strings to sets.
+ * @database the redis client to use
+ * @prefix the namespace of the set
+ * @defaultTTL the timeout on the set

These should be @param annotations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@franklinhu

Tests pass for me. Just one last nit

@mosesn

Ok, I think it should be good now. Weird, I thought I had fixed the last of the @param problems already.

@franklinhu franklinhu closed this pull request from a commit
@mosesn mosesn redis backend to zipkin, CR by Dan Simon
motivation
we didn't want to use the cassandra backend, so we used made an
alternative storage backend in redis.

implementation
redis data structures are generally pretty good, but we needed slightly
different ways of thinking about them for zipkin.  I have slightly
different abstractions around them, which comprise most of this code.
Index and Storage end up mostly being just about manipulating those data
structures.

Author: @mosesn
Fixes #201
URL: #201
fbc29a8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Nov 8, 2012
  1. @mosesn
Commits on Nov 9, 2012
  1. @mosesn
Commits on Nov 13, 2012
  1. @mosesn
Commits on Nov 14, 2012
  1. @mosesn
Commits on Nov 18, 2012
  1. @mosesn

    fixes @param problem again

    mosesn authored
Something went wrong with that request. Please try again.