Solr
  1. Solr
  2. SOLR-5773

CollapsingQParserPlugin should make elevated documents the group head

    Details

    • Type: Improvement Improvement
    • Status: Closed
    • Priority: Major Major
    • Resolution: Fixed
    • Affects Version/s: 4.6.1
    • Fix Version/s: 4.8
    • Component/s: query parsers
    • Labels:

      Description

      Hi Joel,
      I sent you an email but I'm not sure if you received it or not. I ran into a bit of trouble using the CollapsingQParserPlugin with elevated documents. To explain it simply, I want to exclude grouped documents when one of the members of the group are contained in the elevated document set. I'm not sure this is possible currently because as you explain above elevated documents are added to the request context after the original query is constructed.

      To try to better illustrate the problem. If I have 2 documents docid=1 and docid=2 and both have a groupid of 'a'. If a grouped query scores docid 2 first in the results but I have elevated docid 1 then both documents are shown in the results when I really only want the elevated document to be shown in the results.
      Is this something that would be difficult to implement? Any help is appreciated.

      I think the solution would be to remove the documents from liveDocs that share the same groupid in the getBoostDocs() function. Let me know if this makes any sense. I'll continue working towards a solution in the meantime.

      private IntOpenHashSet getBoostDocs(SolrIndexSearcher indexSearcher, Set<String> boosted) throws IOException {
            IntOpenHashSet boostDocs = null;
            if(boosted != null) {
              SchemaField idField = indexSearcher.getSchema().getUniqueKeyField();
              String fieldName = idField.getName();
              HashSet<BytesRef> localBoosts = new HashSet(boosted.size()*2);
              Iterator<String> boostedIt = boosted.iterator();
              while(boostedIt.hasNext()) {
                localBoosts.add(new BytesRef(boostedIt.next()));
              }
      
              boostDocs = new IntOpenHashSet(boosted.size()*2);
      
              List<AtomicReaderContext>leaves = indexSearcher.getTopReaderContext().leaves();
              TermsEnum termsEnum = null;
              DocsEnum docsEnum = null;
              for(AtomicReaderContext leaf : leaves) {
                AtomicReader reader = leaf.reader();
                int docBase = leaf.docBase;
                Bits liveDocs = reader.getLiveDocs();
                Terms terms = reader.terms(fieldName);
                termsEnum = terms.iterator(termsEnum);
                Iterator<BytesRef> it = localBoosts.iterator();
                while(it.hasNext()) {
                  BytesRef ref = it.next();
                  if(termsEnum.seekExact(ref)) {
                    docsEnum = termsEnum.docs(liveDocs, docsEnum);
                    int doc = docsEnum.nextDoc();
                    if(doc != -1) {
                      //Found the document.
                      boostDocs.add(doc+docBase);
      
                     *// HERE REMOVE ANY DOCUMENTS THAT SHARE THE GROUPID NOT ONLY THE DOCID //*
                      it.remove();
      
      
      
                    }
                  }
                }
              }
            }
      
            return boostDocs;
          }
      
      1. SOLR-5773.patch
        13 kB
        Joel Bernstein
      2. SOLR-5773.patch
        12 kB
        Joel Bernstein
      3. SOLR-5773.patch
        11 kB
        Joel Bernstein
      4. SOLR-5773.patch
        11 kB
        Joel Bernstein
      5. SOLR-5773.patch
        10 kB
        Joel Bernstein
      6. SOLR-5773.patch
        13 kB
        David Boychuck
      7. SOLR-5773.patch
        7 kB
        David Boychuck

        Issue Links

          Activity

          Hide
          David Boychuck added a comment -

          To better illustrate what I'm talking about add the following to TestCollapseQParserPlugin.java

           String[] doc7 = {"id","7", "term_s","YYYY", "group_s", "group1", "test_ti", "10", "test_tl", "1300", "test_tf", "4020"};
              assertU(adoc(doc7));
              assertU(commit());
          

          And now update elevate.xml

           <query text="YYYY">
            <doc id="7" />
           </query>
          

          The result after running the test for elevate is

          <response>
             <lst name="responseHeader">
                <int name="status">0</int>
                <int name="QTime">100</int>
                <lst name="params">
                   <str name="q">YYYY</str>
                   <str name="fq">{!collapse field=group_s}</str>
                   <str name="defType">edismax</str>
                   <str name="bf">field(test_ti)</str>
                   <str name="qf">term_s</str>
                   <str name="fl">id,term_s,group_s,test_ti,test_tl,test_tf,[elevated]</str>
                   <str name="qt">/elevate</str>
                </lst>
             </lst>
             <result name="response" numFound="3" start="0">
                <doc>
                   <float name="id">7.0</float>
                   <str name="term_s">YYYY</str>
                   <str name="group_s">group1</str>
                   <int name="test_ti">10</int>
                   <long name="test_tl">1300</long>
                   <float name="test_tf">4020.0</float>
                   <bool name="[elevated]">true</bool>
                </doc>
                <doc>
                   <float name="id">2.0</float>
                   <str name="term_s">YYYY</str>
                   <str name="group_s">group1</str>
                   <int name="test_ti">50</int>
                   <long name="test_tl">100</long>
                   <float name="test_tf">200.0</float>
                   <bool name="[elevated]">false</bool>
                </doc>
                <doc>
                   <float name="id">6.0</float>
                   <str name="term_s">YYYY</str>
                   <str name="group_s">group2</str>
                   <int name="test_ti">10</int>
                   <long name="test_tl">100</long>
                   <float name="test_tf">200.0</float>
                   <bool name="[elevated]">false</bool>
                </doc>
             </result>
          </response>
          

          You can see that there are now two results for group1 when all I really want is the elevated document to be shown for group1

          Show
          David Boychuck added a comment - To better illustrate what I'm talking about add the following to TestCollapseQParserPlugin.java String [] doc7 = { "id" , "7" , "term_s" , "YYYY" , "group_s" , "group1" , "test_ti" , "10" , "test_tl" , "1300" , "test_tf" , "4020" }; assertU(adoc(doc7)); assertU(commit()); And now update elevate.xml <query text= "YYYY" > <doc id= "7" /> </query> The result after running the test for elevate is <response> <lst name= "responseHeader" > < int name= "status" >0</ int > < int name= "QTime" >100</ int > <lst name= "params" > <str name= "q" >YYYY</str> <str name= "fq" >{!collapse field=group_s}</str> <str name= "defType" >edismax</str> <str name= "bf" >field(test_ti)</str> <str name= "qf" >term_s</str> <str name= "fl" >id,term_s,group_s,test_ti,test_tl,test_tf,[elevated]</str> <str name= "qt" >/elevate</str> </lst> </lst> <result name= "response" numFound= "3" start= "0" > <doc> < float name= "id" >7.0</ float > <str name= "term_s" >YYYY</str> <str name= "group_s" >group1</str> < int name= "test_ti" >10</ int > < long name= "test_tl" >1300</ long > < float name= "test_tf" >4020.0</ float > <bool name= "[elevated]" > true </bool> </doc> <doc> < float name= "id" >2.0</ float > <str name= "term_s" >YYYY</str> <str name= "group_s" >group1</str> < int name= "test_ti" >50</ int > < long name= "test_tl" >100</ long > < float name= "test_tf" >200.0</ float > <bool name= "[elevated]" > false </bool> </doc> <doc> < float name= "id" >6.0</ float > <str name= "term_s" >YYYY</str> <str name= "group_s" >group2</str> < int name= "test_ti" >10</ int > < long name= "test_tl" >100</ long > < float name= "test_tf" >200.0</ float > <bool name= "[elevated]" > false </bool> </doc> </result> </response> You can see that there are now two results for group1 when all I really want is the elevated document to be shown for group1
          Hide
          David Boychuck added a comment -

          Hmm... it looks like the getBoostDocs() function is operating on the un-collapsed set to determine wether or not it should be elevated. One potential solution (in all of my ignorance) seems like moving the order of operations of the getBoostDocs() until after the results have been collapsed.

          Show
          David Boychuck added a comment - Hmm... it looks like the getBoostDocs() function is operating on the un-collapsed set to determine wether or not it should be elevated. One potential solution (in all of my ignorance) seems like moving the order of operations of the getBoostDocs() until after the results have been collapsed.
          Hide
          David Boychuck added a comment - - edited

          Actually I think I found a much easier solution. Let me know if you think this will work or if it will have negative repercussions.

           public void collect(int docId) throws IOException {
                int globalDoc = docId+this.docBase;
                int ord = values.getOrd(globalDoc);
                if(ord > -1) {
                  if (this.collapsedSet.fastGet(globalDoc)) {
                    //If we have a document in the group that is potentially not 
                    //the top scorer but also exists as an elevated document
                    //set it as the globalDoc and it will be removed in
                    //favor of the elevated document
                    ords[ord] = globalDoc;
                  } else {
                    float score = scorer.score();
                    if(score > scores[ord]) {
                      ords[ord] = globalDoc;
                      scores[ord] = score;
                    }
                  }
                } else if (this.collapsedSet.fastGet(globalDoc)) {
                  //The doc is elevated so score does not matter
                  //We just want to be sure it doesn't fall into the null policy
                } else if(nullPolicy == CollapsingPostFilter.NULL_POLICY_COLLAPSE) {
                  float score = scorer.score();
                  if(score > nullScore) {
                    nullScore = score;
                    nullDoc = globalDoc;
                  }
                } else if(nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) {
                  collapsedSet.fastSet(globalDoc);
                  nullScores.add(scorer.score());
                }
              }
          

          This basically sets the ords[ord] to the document id that also exists in the elevated documents. The consequence is that the document that is the group head has the same id as the elevated document and it is removed from the result set.

          Show
          David Boychuck added a comment - - edited Actually I think I found a much easier solution. Let me know if you think this will work or if it will have negative repercussions. public void collect( int docId) throws IOException { int globalDoc = docId+ this .docBase; int ord = values.getOrd(globalDoc); if (ord > -1) { if ( this .collapsedSet.fastGet(globalDoc)) { //If we have a document in the group that is potentially not //the top scorer but also exists as an elevated document //set it as the globalDoc and it will be removed in //favor of the elevated document ords[ord] = globalDoc; } else { float score = scorer.score(); if (score > scores[ord]) { ords[ord] = globalDoc; scores[ord] = score; } } } else if ( this .collapsedSet.fastGet(globalDoc)) { //The doc is elevated so score does not matter //We just want to be sure it doesn't fall into the null policy } else if (nullPolicy == CollapsingPostFilter.NULL_POLICY_COLLAPSE) { float score = scorer.score(); if (score > nullScore) { nullScore = score; nullDoc = globalDoc; } } else if (nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) { collapsedSet.fastSet(globalDoc); nullScores.add(scorer.score()); } } This basically sets the ords [ord] to the document id that also exists in the elevated documents. The consequence is that the document that is the group head has the same id as the elevated document and it is removed from the result set.
          Hide
          David Boychuck added a comment - - edited

          Last fix had a bug. This is working for me.

           public CollapsingScoreCollector(int maxDoc,
                                              int segments,
                                              SortedDocValues values,
                                              int nullPolicy,
                                              IntOpenHashSet boostDocs) {
                this.maxDoc = maxDoc;
                this.contexts = new AtomicReaderContext[segments];
                this.collapsedSet = new OpenBitSet(maxDoc);
                this.boostDocs = boostDocs;
                if(this.boostDocs != null) {
                  //Set the elevated docs now.
                  Iterator<IntCursor> it = this.boostDocs.iterator();
                  while(it.hasNext()) {
                    IntCursor cursor = it.next();
                    this.collapsedSet.fastSet(cursor.value);
                  }
                }
                this.values = values;
                int valueCount = values.getValueCount();
                this.ords = new int[valueCount];
                this.groupIsBoosted = new boolean[valueCount];
                Arrays.fill(this.ords, -1);
                this.scores = new float[valueCount];
                Arrays.fill(this.scores, -Float.MAX_VALUE);
                this.nullPolicy = nullPolicy;
                if(nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) {
                  nullScores = new FloatArrayList();
                }
              }
          
              public boolean acceptsDocsOutOfOrder() {
                //Documents must be sent in order to this collector.
                return false;
              }
          
              public void setNextReader(AtomicReaderContext context) throws IOException {
                this.contexts[context.ord] = context;
                this.docBase = context.docBase;
              }
          
              public void collect(int docId) throws IOException {
                int globalDoc = docId+this.docBase;
                int ord = values.getOrd(globalDoc);
                
                
                if(ord > -1) {
                  if (this.collapsedSet.fastGet(globalDoc)) {
                    //If we have a document in the group that is potentially not 
                    //the top scorer but also exists as an elevated document
                    //set it as the globalDoc and it will be removed in
                    //favor of the elevated document
                    groupIsBoosted[ord] = true;
                    ords[ord] = globalDoc;
                  } else if (!groupIsBoosted[ord]) {
                    float score = scorer.score();
                    if(score > scores[ord]) {
                      ords[ord] = globalDoc;
                      scores[ord] = score;
                    }
                  }
                } if (this.collapsedSet.fastGet(globalDoc)) {
                  //The doc is elevated so score does not matter
                  //We just want to be sure it doesn't fall into the null policy
                  ords[ord] = globalDoc;
                } else if(nullPolicy == CollapsingPostFilter.NULL_POLICY_COLLAPSE) {
                  float score = scorer.score();
                  if(score > nullScore) {
                    nullScore = score;
                    nullDoc = globalDoc;
                  }
                } else if(nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) {
                  collapsedSet.fastSet(globalDoc);
                  nullScores.add(scorer.score());
                }
              }
          

          This approach will work for default grouping. Will still have to implement fixes for min max grouping. I will probably also want to make this a toggle-able feature.

          Show
          David Boychuck added a comment - - edited Last fix had a bug. This is working for me. public CollapsingScoreCollector( int maxDoc, int segments, SortedDocValues values, int nullPolicy, IntOpenHashSet boostDocs) { this .maxDoc = maxDoc; this .contexts = new AtomicReaderContext[segments]; this .collapsedSet = new OpenBitSet(maxDoc); this .boostDocs = boostDocs; if ( this .boostDocs != null ) { //Set the elevated docs now. Iterator<IntCursor> it = this .boostDocs.iterator(); while (it.hasNext()) { IntCursor cursor = it.next(); this .collapsedSet.fastSet(cursor.value); } } this .values = values; int valueCount = values.getValueCount(); this .ords = new int [valueCount]; this .groupIsBoosted = new boolean [valueCount]; Arrays.fill( this .ords, -1); this .scores = new float [valueCount]; Arrays.fill( this .scores, - Float .MAX_VALUE); this .nullPolicy = nullPolicy; if (nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) { nullScores = new FloatArrayList(); } } public boolean acceptsDocsOutOfOrder() { //Documents must be sent in order to this collector. return false ; } public void setNextReader(AtomicReaderContext context) throws IOException { this .contexts[context.ord] = context; this .docBase = context.docBase; } public void collect( int docId) throws IOException { int globalDoc = docId+ this .docBase; int ord = values.getOrd(globalDoc); if (ord > -1) { if ( this .collapsedSet.fastGet(globalDoc)) { //If we have a document in the group that is potentially not //the top scorer but also exists as an elevated document //set it as the globalDoc and it will be removed in //favor of the elevated document groupIsBoosted[ord] = true ; ords[ord] = globalDoc; } else if (!groupIsBoosted[ord]) { float score = scorer.score(); if (score > scores[ord]) { ords[ord] = globalDoc; scores[ord] = score; } } } if ( this .collapsedSet.fastGet(globalDoc)) { //The doc is elevated so score does not matter //We just want to be sure it doesn't fall into the null policy ords[ord] = globalDoc; } else if (nullPolicy == CollapsingPostFilter.NULL_POLICY_COLLAPSE) { float score = scorer.score(); if (score > nullScore) { nullScore = score; nullDoc = globalDoc; } } else if (nullPolicy == CollapsingPostFilter.NULL_POLICY_EXPAND) { collapsedSet.fastSet(globalDoc); nullScores.add(scorer.score()); } } This approach will work for default grouping. Will still have to implement fixes for min max grouping. I will probably also want to make this a toggle-able feature.
          Hide
          Joel Bernstein added a comment -

          David,

          I agree that the elevated document should become the group head. I'll begin working on a patch for this. I'm thinking of handling this during the finish() stage rather then the collect stage. I hope to have something to test this week.

          Joel

          Show
          Joel Bernstein added a comment - David, I agree that the elevated document should become the group head. I'll begin working on a patch for this. I'm thinking of handling this during the finish() stage rather then the collect stage. I hope to have something to test this week. Joel
          Hide
          David Boychuck added a comment -

          Thanks Joel,

          I'm just about to attach my patch for the collect stage just in case you want to use it.

          Show
          David Boychuck added a comment - Thanks Joel, I'm just about to attach my patch for the collect stage just in case you want to use it.
          Hide
          David Boychuck added a comment -

          I am attaching my patch which will set the group head to the elevated document if it exists. This operates during the collect method. I will update with some thorough tests later today or tomorrow.

          Show
          David Boychuck added a comment - I am attaching my patch which will set the group head to the elevated document if it exists. This operates during the collect method. I will update with some thorough tests later today or tomorrow.
          Hide
          David Boychuck added a comment -

          Updating my patch with a couple of unit tests

          Show
          David Boychuck added a comment - Updating my patch with a couple of unit tests
          Hide
          Joel Bernstein added a comment -

          Initial patch with a very simple test. Needs more testing for sure.

          Show
          Joel Bernstein added a comment - Initial patch with a very simple test. Needs more testing for sure.
          Hide
          Joel Bernstein added a comment -

          Hi David,

          Just looked through your patch. It looks like it assumes the boosted documents are collected ahead of the other docs. This won't actually be the case. The documents will be collected in lucene docId order. The actual boosting happens after the documents are delegated to the ranking collectors.

          Joel

          Show
          Joel Bernstein added a comment - Hi David, Just looked through your patch. It looks like it assumes the boosted documents are collected ahead of the other docs. This won't actually be the case. The documents will be collected in lucene docId order. The actual boosting happens after the documents are delegated to the ranking collectors. Joel
          Hide
          Joel Bernstein added a comment -

          Fixed a bug in the recursive merge join code. Added simple test of this code.

          Show
          Joel Bernstein added a comment - Fixed a bug in the recursive merge join code. Added simple test of this code.
          Hide
          David Boychuck added a comment -

          Hi Joel,

          You are correct I did assume the boosted docs were collected first. I take it that assumption is not correct. Could you explain at a high level how the delegating collectors work as to invalidate my assumption. I am trying to get a better understanding of how solr works on a high level. Your help is appreciated as always.

          Show
          David Boychuck added a comment - Hi Joel, You are correct I did assume the boosted docs were collected first. I take it that assumption is not correct. Could you explain at a high level how the delegating collectors work as to invalidate my assumption. I am trying to get a better understanding of how solr works on a high level. Your help is appreciated as always.
          Hide
          Joel Bernstein added a comment -

          Sure. Delegating collectors are passed each document that matches the query in the collect method. There are some collectors that can accept documents out of order but the CollapsingQParserPlugin cannot. The method acceptsDocsOutOfOrder() defines this. So for the CollapsingQParserPlugin the documents will be passed to the collect method in lucene document id order.

          Most delegating collectors act like a filter and delegate documents to the ranking collectors directly from the collect method. The CollapsingQParserPlugin is different in that it doesn't delegate in the collect method, it only collapses in the collect method. In the finish method it sends down all the collapsed documents to the ranking collectors.

          Show
          Joel Bernstein added a comment - Sure. Delegating collectors are passed each document that matches the query in the collect method. There are some collectors that can accept documents out of order but the CollapsingQParserPlugin cannot. The method acceptsDocsOutOfOrder() defines this. So for the CollapsingQParserPlugin the documents will be passed to the collect method in lucene document id order. Most delegating collectors act like a filter and delegate documents to the ranking collectors directly from the collect method. The CollapsingQParserPlugin is different in that it doesn't delegate in the collect method, it only collapses in the collect method. In the finish method it sends down all the collapsed documents to the ranking collectors.
          Hide
          David Boychuck added a comment -

          Thank you for the explanation Joel, I am sure it will help with my understanding.

          Show
          David Boychuck added a comment - Thank you for the explanation Joel, I am sure it will help with my understanding.
          Hide
          David Boychuck added a comment -

          I'm still having a bit of trouble understanding. If the CollapsingScoreCollector takes the boostDocs in as a parameter of the constructor then wouldn't the collect() method already have the full set of boosted documents to work with?

          Show
          David Boychuck added a comment - I'm still having a bit of trouble understanding. If the CollapsingScoreCollector takes the boostDocs in as a parameter of the constructor then wouldn't the collect() method already have the full set of boosted documents to work with?
          Hide
          Joel Bernstein added a comment -

          Yes, you could implement the filter in the collect method. But it would be less efficient then the approach I took for a couple of reasons. First, the collect method is called for every document, so adding a filter there means that each document needs to be tested by the filter. By filtering in the finished stage, only collapsed documents need to be tested.

          Second, because the documents are collapsed into an array based on ord value there is an opportunity to do a very efficient merge join when applying the filter. You can only do a merge join on two sorted sets. It turns out that the larger set was already sorted by the group ord value, so all I had to do was sort the group ords in the boosted documents. Take a look at the inner class SortedBoostSet.contains method to see the merge join. You'll see how few operations it takes to filter the larger result with very little memory used.

          Show
          Joel Bernstein added a comment - Yes, you could implement the filter in the collect method. But it would be less efficient then the approach I took for a couple of reasons. First, the collect method is called for every document, so adding a filter there means that each document needs to be tested by the filter. By filtering in the finished stage, only collapsed documents need to be tested. Second, because the documents are collapsed into an array based on ord value there is an opportunity to do a very efficient merge join when applying the filter. You can only do a merge join on two sorted sets. It turns out that the larger set was already sorted by the group ord value, so all I had to do was sort the group ords in the boosted documents. Take a look at the inner class SortedBoostSet.contains method to see the merge join. You'll see how few operations it takes to filter the larger result with very little memory used.
          Hide
          David Boychuck added a comment -

          Thank you for the explanation. I will study the code. Thanks again for all of your help.

          Show
          David Boychuck added a comment - Thank you for the explanation. I will study the code. Thanks again for all of your help.
          Hide
          Joel Bernstein added a comment -

          Tested this at scale and it seems to be functioning properly.

          David, let me know when you've had a chance to test out the patch.

          Thanks,
          Joel

          Show
          Joel Bernstein added a comment - Tested this at scale and it seems to be functioning properly. David, let me know when you've had a chance to test out the patch. Thanks, Joel
          Hide
          David Boychuck added a comment -

          I've got it running in a sandbox environment. Seems to be functioning without error under load of up to 3000 requests per minute, though most of these queries don't have elevated documents in their result set. But I haven't seen any errors so far.

          Show
          David Boychuck added a comment - I've got it running in a sandbox environment. Seems to be functioning without error under load of up to 3000 requests per minute, though most of these queries don't have elevated documents in their result set. But I haven't seen any errors so far.
          Hide
          Joel Bernstein added a comment -

          Add randomized testing for CollapsingQParserPlugin.SortedBoostSet

          Show
          Joel Bernstein added a comment - Add randomized testing for CollapsingQParserPlugin.SortedBoostSet
          Hide
          ASF subversion and git services added a comment -

          Commit 1583500 from Joel Bernstein in branch 'dev/trunk'
          [ https://svn.apache.org/r1583500 ]

          SOLR-5773: CollapsingQParserPlugin should make elevated documents the group head

          Show
          ASF subversion and git services added a comment - Commit 1583500 from Joel Bernstein in branch 'dev/trunk' [ https://svn.apache.org/r1583500 ] SOLR-5773 : CollapsingQParserPlugin should make elevated documents the group head
          Hide
          ASF subversion and git services added a comment -

          Commit 1583507 from Joel Bernstein in branch 'dev/branches/branch_4x'
          [ https://svn.apache.org/r1583507 ]

          SOLR-5773: CollapsingQParserPlugin should make elevated documents the group head

          Show
          ASF subversion and git services added a comment - Commit 1583507 from Joel Bernstein in branch 'dev/branches/branch_4x' [ https://svn.apache.org/r1583507 ] SOLR-5773 : CollapsingQParserPlugin should make elevated documents the group head
          Hide
          Uwe Schindler added a comment -

          Close issue after release of 4.8.0

          Show
          Uwe Schindler added a comment - Close issue after release of 4.8.0

            People

            • Assignee:
              Joel Bernstein
              Reporter:
              David Boychuck
            • Votes:
              0 Vote for this issue
              Watchers:
              3 Start watching this issue

              Dates

              • Created:
                Updated:
                Resolved:

                Time Tracking

                Estimated:
                Original Estimate - 8h
                8h
                Remaining:
                Remaining Estimate - 8h
                8h
                Logged:
                Time Spent - Not Specified
                Not Specified

                  Development